pocet = x => x * x;
+Console.WriteLine(pocet(4));
+Action pozdrav = () => Console.WriteLine("ahoj");
+pozdrav();
+```
diff --git a/content/programovani/4-rocnik/08-async.en.md b/content/programovani/4-rocnik/08-async.en.md
new file mode 100644
index 0000000..98da3e9
--- /dev/null
+++ b/content/programovani/4-rocnik/08-async.en.md
@@ -0,0 +1,4 @@
+---
+nazev: Async and await
+popis: Work that waits without freezing the way you write it.
+---
diff --git a/content/programovani/4-rocnik/08-async.md b/content/programovani/4-rocnik/08-async.md
new file mode 100644
index 0000000..9baebaf
--- /dev/null
+++ b/content/programovani/4-rocnik/08-async.md
@@ -0,0 +1,33 @@
+---
+id: cs-4-async
+nazev: Async a await
+popis: Práce, která počká, aniž by zamkla zbytek zápisu.
+---
+
+# Výklad
+
+## await
+*počkej*
+
+- await Task.Delay čeká, ale zápis zůstane řádek za řádkem.
+- Metoda s await má v hlavičce async a vrací Task.
+- V krátkém programu smí await stát i na nejvyšší úrovni.
+
+> Tip: Delay(300) čeká 300 milisekund. Výpis hotovo přijde až po něm.
+
+## Task
+*slib*
+
+- Task je slib, že práce doběhne.
+- await slib rozbalí. Bez await byste měli slib, ne výsledek.
+- Na síť a soubory se await hodí. Na 2 + 2 ne.
+
+> Tip: Dejte za první výpis ještě jeden await a druhý řádek.
+
+# Kód
+
+```
+Console.WriteLine("začátek");
+await Task.Delay(300);
+Console.WriteLine("hotovo");
+```
diff --git a/content/programove-vybaveni/1-rocnik/01-software-a-licence.en.md b/content/programove-vybaveni/1-rocnik/01-software-a-licence.en.md
new file mode 100644
index 0000000..77b2182
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/01-software-a-licence.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Software and licences
+popis: System and application programs, and the terms for using them.
+nazev_cviceni: Quiz: software and licences
+nadpis_kvizu: Name the kind of program and the kind of licence
+---
diff --git a/content/programove-vybaveni/1-rocnik/01-software-a-licence.md b/content/programove-vybaveni/1-rocnik/01-software-a-licence.md
new file mode 100644
index 0000000..e40dee4
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/01-software-a-licence.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-software-a-licence
+nazev: Software a licence
+popis: Systémové a aplikační programy a to, za jakých podmínek se smí používat.
+nazev_cviceni: Kvíz: software a licence
+nadpis_kvizu: Poznejte druh programu a druh licence
+---
+
+# Výklad
+
+## Dva druhy programů
+*Software*
+
+- Software je program. Hardware je zařízení. Samotný počítač bez programu nic neudělá.
+- Operační systém řídí počítač: Windows, Linux, macOS. Ve škole pracujeme ve Windows.
+- Aplikační software řeší jednu práci: Word dokument, Excel tabulku, prohlížeč web.
+- Ovladač je malý program k zařízení. Tiskárna bez ovladače netiskne, i když je zapnutá.
+
+> Tip: Když program spadne a systém běží dál, spadla aplikace. Když nesvítí plocha, spadl systém.
+
+## Licence
+*Podmínky*
+
+- Licence říká, kdo program smí nainstalovat a na kolik počítačů.
+- Komerční program se kupuje, nebo se platí předplatné. Microsoft 365 je předplatné.
+- OEM licence je přivázaná k jednomu počítači. Na nový stroj se nepřetahuje.
+- Stažený program bez licence není zdarma. Je to kopie, kterou škola nesmí používat.
+
+> Tip: Školní účet Microsoft 365 nepatří na domácí počítače navždy. Platí, dokud škola předplatné má.
+
+## Zdarma není jeden pojem
+*Freeware a open source*
+
+- Freeware je zdarma, ale zdrojový kód nedostanete. Smíte ho používat za podmínek autora.
+- Open source má otevřený kód. GPL navíc chce, aby odvozené programy zůstaly otevřené.
+- Zkušební verze (shareware) po čase skončí, nebo omezí funkce. Není to trvalá licence.
+- Na školní počítač patří jen programy, které škola schválila. Vlastní instalace se hlásí učiteli.
+
+> Tip: Cena nula neznamená, že smíte program rozdávat dál. I freeware má licenci.
+
+## Zadání: šest programů
+*Cvičení*
+
+- Do tabulky zapište šest programů ze školního počítače: systém, prohlížeč, Word, Excel a dva další.
+- U každého napište, jestli je systémový, nebo aplikační.
+- U Wordu a Excelu napište, jaká licence ve škole je: předplatné Microsoft 365, ne domácí krabice.
+- Soubor pojmenujte licence.txt a uložte ho do Dokumenty. Zatím nic neinstalujte.
+
+> Tip: Název systému zjistíte příkazem winver. Okno ukáže edici Windows a verzi.
+
+# Kvíz
+
+## Kam patří Microsoft Word?
+- Mezi operační systémy
+- [x] Mezi aplikační software
+- Mezi ovladače tiskárny
+- Mezi hardware
+> Word řeší jednu práci, dokument. Systém pod ním je Windows.
+
+## Co znamená OEM licence Windows?
+- Smí se přenést na každý další počítač
+- [x] Je vázaná na jeden počítač
+- Je to totéž co GPL
+- Platí jen 14 dní
+> OEM se prodává s počítačem a na jiný stroj se nepřesouvá.
+
+## Čím se freeware liší od open source?
+- Freeware má otevřený zdrojový kód
+- [x] Freeware je zdarma, ale kód obvykle nedostanete
+- Open source se nesmí používat ve škole
+- Je to totéž
+> Zdarma a otevřený kód jsou dvě různé věci. GPL je otevřená licence, ne jen nulová cena.
+
+## Který program je operační systém?
+- Excel
+- [x] Windows
+- WordPress
+- Power BI
+> Windows řídí počítač. Ostatní jsou aplikace, které na systému běží.
diff --git a/content/programove-vybaveni/1-rocnik/02-operacni-system.en.md b/content/programove-vybaveni/1-rocnik/02-operacni-system.en.md
new file mode 100644
index 0000000..572309c
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/02-operacni-system.en.md
@@ -0,0 +1,6 @@
+---
+nazev: The operating system
+popis: What the system does between the user and the hardware.
+nazev_cviceni: Quiz: the operating system
+nadpis_kvizu: Name the role of the system
+---
diff --git a/content/programove-vybaveni/1-rocnik/02-operacni-system.md b/content/programove-vybaveni/1-rocnik/02-operacni-system.md
new file mode 100644
index 0000000..352a114
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/02-operacni-system.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-operacni-system
+nazev: Operační systém
+popis: Co systém dělá mezi uživatelem a hardwarem.
+nazev_cviceni: Kvíz: operační systém
+nadpis_kvizu: Poznejte roli systému
+---
+
+# Výklad
+
+## Mezi člověkem a strojem
+*Role*
+
+- Systém spouští programy, hlídá paměť a přiděluje čas procesoru.
+- Souborový systém ukládá data na disk. Ve Windows je to NTFS.
+- Ovladač si systém načte, aby mluvil s tiskárnou, diskem a sítí.
+- Uživatel vidí plochu a okna. Jádro pod tím nevidí a do něj nesahá.
+
+> Tip: Když dva programy chtějí tiskárnu najednou, rozhodne systém. Samy se nedomluví.
+
+## Tři systémy
+*Windows, Linux, macOS*
+
+- Školní učebna je Windows. Příkazy a obrazovky v tomto předmětu jsou z Windows.
+- Linux je častý na serverech. Stejný dokument v Excelu tam sám od sebe není.
+- macOS běží na počítačích Apple. Zkratka Ctrl je tam často Cmd.
+- Soubor .docx je dokument Wordu. Není to program a není to systém.
+
+> Tip: Přípona souboru říká, čím ho otevřít. Systém podle ní vybere aplikaci.
+
+## Start počítače
+*Až po přihlášení*
+
+- Po zapnutí naběhne firmware, pak systém, pak přihlašovací obrazovka.
+- Dokud se nepřihlásíte, pracujete s účtem nikoho. Soubory uživatele jsou zamčené.
+- Každý účet má vlastní plochu a složku C:\Users\jméno.
+- Vypnutí přes nabídku Start uloží práci systému. Dlouhý stisk tlačítka je až poslední možnost.
+
+> Tip: Restart po aktualizaci není trest. Systém si domlouvá soubory, které za běhu nevymění.
+
+## Zadání: jaký systém to je
+*Cvičení*
+
+- Spusťte winver a opište edici a verzi, třeba Windows 11 Pro, verzi z okna.
+- Otevřete C:\Users a napište, které složky uživatelů tam jsou. Do cizích nesaháte.
+- Najděte jeden ovladač v názvu: ve Správci zařízení rozbalte Monitory nebo Tiskárny.
+- Zápis uložte jako system.txt do Dokumenty. Hesla ani sériová čísla nepište.
+
+> Tip: Správce zařízení je ve Windows devmgmt.msc. Žlutý vykřičník znamená chybějící nebo vadný ovladač.
+
+# Kvíz
+
+## Co dělá operační systém s pamětí?
+- Nic, paměť si berou programy samy
+- [x] Přiděluje ji programům
+- Maže ji při každém uložení dokumentu
+- Patří do Excelu
+> Program požádá, systém přidělí. Dva programy nesmí psát do stejného místa.
+
+## Jaký souborový systém používá školní Windows na disku?
+- [x] NTFS
+- Jen názvy souborů bez systému
+- SVG
+- MySQL
+> NTFS drží soubory, oprávnění i větší disky. Názvy složek jsou až uvnitř něj.
+
+## Kdy smíte sáhnout do složky jiného uživatele v C:\Users?
+- Kdykoli, je to jeden počítač
+- [x] Nesmíte, je to jeho účet
+- Jen když je složka na ploše
+- Když má dokument příponu docx
+> Každý účet má vlastní soubory. Cizí složka není společný šuplík.
+
+## Čím zjistíte edici Windows?
+- [x] Příkazem winver
+- Otevřením Excelu
+- Smazáním koše
+- Souborem .bat
+> winver otevře okno O systému Windows s edicí a verzí.
diff --git a/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.en.md b/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.en.md
new file mode 100644
index 0000000..be64b98
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Files and folders
+popis: A path, an extension and order in Documents.
+nazev_cviceni: Quiz: files
+nadpis_kvizu: Build the path and the name
+---
diff --git a/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.md b/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.md
new file mode 100644
index 0000000..8989f5a
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/03-soubory-a-slozky.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-soubory
+nazev: Soubory a složky
+popis: Cesta, přípona a pořádek v Dokumentech.
+nazev_cviceni: Kvíz: soubory
+nadpis_kvizu: Sestavte cestu a název
+---
+
+# Výklad
+
+## Cesta
+*Kde soubor leží*
+
+- Cesta začíná diskem. C:\Users\novak\Documents\Skola\1-rocnik je složka, ne soubor.
+- Zpětné lomítko odděluje složky. Dopředné lomítko je na webu, ne ve Windows.
+- Dokumenty jsou %USERPROFILE%\Documents. Na každém účtu to vede jinam, příkaz je stejný.
+- Plocha je taky složka. Soubor jen na ploše se ve školním pořádku ztratí.
+
+> Tip: Do řádku adresy v Průzkumníku vložte cestu a Enter. Nemusíte proklikávat pět složek.
+
+## Přípona
+*Čím se soubor otevře*
+
+- prihlaska.docx je Word. znamky.xlsx je Excel. poznamka.txt je prostý text.
+- Windows přípony schovává. Zapněte Zobrazit, Zobrazit, Přípony názvů souborů.
+- Přejmenovat foto.jpg na foto.docx z něj dokument neudělá. Změní se jen štítek.
+- Dvojitá přípona faktura.pdf.exe je program, který se tváří jako dokument. Neotevírejte ho.
+
+> Tip: Když přípony vidíte, podvodný soubor poznáte dřív, než na něj kliknete.
+
+## Názvy
+*Co je zakázané*
+
+- V názvu nesmí být znaky \ / : * ? " < > |.
+- Mezera smí být, ale v dávkových souborech pak název patří do uvozovek.
+- Školní název je věcný: 2026-06-12-zadost.docx. Final-final2.docx není název.
+- Datum pište rok-měsíc-den. Složka se seřadí od nejstaršího sama.
+
+> Tip: Kopírování je Ctrl+C, vložení Ctrl+V, přesun začíná Ctrl+X. Přesun z flashky na disk soubor na flashce smaže.
+
+## Zadání: strom školy
+*Cvičení*
+
+- V Dokumentech založte Skola, v ní 1-rocnik a v ní slozky word, excel a system.
+- Do word uložte prázdný soubor poznamka.txt. Příponu ať je vidět.
+- Cesta ať končí na \Skola\1-rocnik\word\poznamka.txt.
+- Na ploše nic nenechávejte. Koš nevysypávejte, dokud nevíte, co v něm je.
+
+> Tip: Nová složka je Ctrl+Shift+N. Když se jmenuje Nová složka, hned ji přejmenujte.
+
+# Kvíz
+
+## Která cesta vede k souboru v Dokumentech?
+- [x] C:\Users\novak\Documents\Skola\1-rocnik\poznamka.txt
+- https://skola/poznamka.txt
+- poznamka.txt bez složky, to stačí vždy
+- C:/Users/novak s obyčejnými lomítky jako jediná správná forma
+> Ve Windows se složky oddělují zpětným lomítkem a cesta začíná diskem.
+
+## Co se stane, když foto.jpg přejmenujete na foto.docx?
+- Stane se z něj upravitelný dokument Word
+- [x] Změní se přípona, obsah zůstane obrázek
+- Soubor se uloží do koše
+- Vznikne tabulka Excel
+> Přípona je nápověda pro systém. Bajty obrázku se samy nepřepíšou na dokument.
+
+## Který název je na školní odevzdání nejlepší?
+- final2.docx
+- [x] 2026-06-12-zadost.docx
+- Nový dokument.docx
+- ???.docx
+> Datum rok-měsíc-den se seřadí a je z něj poznat verze. Otazník v názvu Windows ani nepustí.
+
+## Proč zapnout zobrazení přípon?
+- Aby šly soubory mazat
+- [x] Aby byl vidět skutečný typ, třeba pdf.exe
+- Protože bez přípony soubor nejde uložit
+- Excel to vyžaduje u každé buňky
+> Skrytá přípona schová, že faktura.pdf.exe je program.
diff --git a/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.en.md b/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.en.md
new file mode 100644
index 0000000..2408177
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Installation and updates
+popis: Where a program comes from and why the system is updated.
+nazev_cviceni: Quiz: installation
+nadpis_kvizu: Pick a safe source
+---
diff --git a/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.md b/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.md
new file mode 100644
index 0000000..4b787c9
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/04-instalace-a-aktualizace.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-instalace
+nazev: Instalace a aktualizace
+popis: Odkud se program bere a proč se systém aktualizuje.
+nazev_cviceni: Kvíz: instalace
+nadpis_kvizu: Vyberte bezpečný zdroj
+---
+
+# Výklad
+
+## Odkud instalovat
+*Zdroj*
+
+- Školní programy berte z Microsoft Store, z balíčku školy, nebo z webu výrobce.
+- Odkaz v mailu, tlačítko Stáhnout zdarma na cizím webu a crack nejsou zdroj.
+- Instalace chce souhlas správce. Okno Řízení uživatelských účtů se neodklepává poslepu.
+- Na plochu nepatří deset instalačních souborů. Po instalaci je smažte, program už je v systému.
+
+> Tip: Název souboru setup.exe nic neříká. Říká to vydavatel v okně účtů a adresa, odkud soubor přišel.
+
+## Aktualizace
+*Windows Update*
+
+- Aktualizace zašívá chyby, přes které se do počítače leze. Odkládat ji týdny je riziko.
+- Windows Update je v Nastavení, Windows Update. Školní počítač nechte doběhnout.
+- Restart po aktualizaci udělejte, když o něj systém požádá. Ne uprostřed testu, ale ten den.
+- Word a Excel se v Microsoft 365 aktualizují se sadou Office, ne jen s Windows.
+
+> Tip: Verze systému z winver se po velké aktualizaci změní. Když se měsíc nemění, aktualizace stojí.
+
+## Odinstalace
+*Aplikace*
+
+- Program se odebírá v Nastavení, Aplikace, Nainstalované aplikace. Ne smazáním zástupce.
+- Zástupce na ploše je jen odkaz. Smazáním ikony program v počítači zůstane.
+- Neznámý program, který jste neinstalovali, nejdřív nahlaste. Nemusí to být hra spolužáka.
+- Odinstalace nesmaže vaše dokumenty. Smaže program. Dokumenty si před velkým úklidem zkopírujte.
+
+> Tip: Když v seznamu aplikací Word není a zástupce ano, zástupce vede do prázdna, nebo na jiný disk.
+
+## Zadání: stav počítače
+*Cvičení*
+
+- Z winver opište verzi. V Windows Update napište, jestli je systém aktuální, nebo něco čeká.
+- V nainstalovaných aplikacích najděte Word nebo Microsoft 365 a opište název, jak ho seznam uvádí.
+- Najděte jeden program, který nepoznáváte, a zapište ho. Neodinstalovávejte ho bez učitele.
+- Uložte stav.txt do Skola\1-rocnik\system. Nic z internetu nestahujte.
+
+> Tip: Čas poslední aktualizace je ve Windows Update dole v historii. Stačí datum, ne seznam všech záplat.
+
+# Kvíz
+
+## Odkud se má školní program instalovat?
+- Z odkazu v neznámém mailu
+- [x] Z webu výrobce, z Store, nebo z balíčku školy
+- Z stránky s nápisem crack
+- Z přílohy faktura.pdf.exe
+> Výrobce, obchod a škola jsou zdroje. Crack a náhodný mail nejsou.
+
+## Co udělá smazání zástupce na ploše?
+- Odinstaluje program
+- [x] Smaže jen odkaz, program zůstane
+- Smaže Dokumenty
+- Vypne aktualizace
+> Zástupce je zkratka. Program se odebírá v Nainstalovaných aplikacích.
+
+## Proč se Windows Update nenechává měsíc stát?
+- Protože jinak nejde otevřít Word
+- [x] Záplaty zavírají známé díry
+- Protože se jinak smaže plocha
+- Aktualizace jen mění tapetu
+> Neaktualizovaný systém má chyby, které už jsou veřejně popsané.
+
+## Kdy odklepnete okno Řízení uživatelských účtů?
+- Vždycky, ať instalace pokračuje
+- [x] Jen když víte, jaký program a odkud ho spouštíte
+- Nikdy, ani u školního balíčku
+- Jen v Excelu
+> Okno říká, že program chce právo správce. Neznámý vydavatel se neodklepává.
diff --git a/content/programove-vybaveni/1-rocnik/05-ucty.en.md b/content/programove-vybaveni/1-rocnik/05-ucty.en.md
new file mode 100644
index 0000000..b525df1
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/05-ucty.en.md
@@ -0,0 +1,6 @@
+---
+nazev: User accounts
+popis: A standard user, an administrator and a password that is not the account name.
+nazev_cviceni: Quiz: accounts
+nadpis_kvizu: Pick the account type and the password
+---
diff --git a/content/programove-vybaveni/1-rocnik/05-ucty.md b/content/programove-vybaveni/1-rocnik/05-ucty.md
new file mode 100644
index 0000000..fc41b33
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/05-ucty.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-ucty
+nazev: Uživatelské účty
+popis: Běžný uživatel, správce a heslo, které není jméno účtu.
+nazev_cviceni: Kvíz: účty
+nadpis_kvizu: Vyberte typ účtu a heslo
+---
+
+# Výklad
+
+## Dva typy
+*Správce a uživatel*
+
+- Správce smí instalovat programy a měnit systém. Ve škole je to účet učitele nebo správce učebny.
+- Běžný uživatel smí pracovat se svými soubory. Systémové složky mu systém zamkne.
+- Denní práce nepatří na účet správce. Virus by dostal stejná práva jako vy.
+- Nový školní účet pro cvičení se jmenuje zaci a je běžný uživatel, ne správce.
+
+> Tip: Když systém požádá o heslo správce, běžný účet na to nestačí. To je záměr, ne porucha.
+
+## Heslo
+*Osm znaků*
+
+- Heslo má aspoň 8 znaků a není to jméno účtu, ani slovo heslo, ani 12345678.
+- Do příkazu se heslo nepíše. Příkaz net user zaci * /add se na heslo zeptá a na obrazovce ho neukáže.
+- Heslo na papírek na monitor nepatří. Patří do hlavy, nebo do školního sejfu hesel, když ho učitel určí.
+- Cizí heslo se nezkouší. Ani když je účet spolužáka odhlášený a počítač je společný.
+
+> Tip: Hvězdička v net user znamená dotaz na heslo. Heslo napsané za příkazem zůstane v historii příkazů.
+
+## Vlastní soubory
+*Profil*
+
+- Profil je složka C:\Users\zaci. Jsou v ní Dokumenty, Plocha a Stažené soubory.
+- Soubor uložený do Dokumentů tohoto účtu jiný běžný uživatel neotevře.
+- Společná složka C:\Users\Public je pro věci, které mají vidět všichni. Úkoly tam nedávejte.
+- Odhlášení účet zavře. Vypnutí počítače taky. Přepnutí uživatele nechá první účet otevřený v paměti.
+
+> Tip: Na sdíleném počítači se odhlašujte. Přepnutí uživatele nechá otevřené dokumenty v paměti.
+
+## Zadání: účet na papíře
+*Cvičení*
+
+- Napište, jak by se založil běžný účet zaci: příkaz net user zaci * /add spuštěný jako správce.
+- Napište heslo, které pravidlům nevyhovuje, a druhé, které má aspoň 8 znaků a není jméno účtu. Skutečné školní heslo nepište.
+- Účet nesmí být ve skupině Administrators. Patří mezi běžné Users.
+- Uložte ucet.txt do Skola\1-rocnik\system. Účet v systému zakládejte jen když to učitel výslovně řekne.
+
+> Tip: Kdo jsem, zjistí příkaz whoami. Když vypíše správce, na denní práci jste na špatném účtu.
+
+# Kvíz
+
+## Jaký má být cvičný účet zaci?
+- Správce, ať jde všechno
+- [x] Běžný uživatel
+- Účet bez hesla
+- Účet hosta sdílený celou třídou bez hesla
+> Denní práce běží jako běžný uživatel. Správce je na instalaci a správu, ne na Word.
+
+## Jak se heslo předá příkazu net user?
+- Napíše se za příkaz, ať je v sešitě vidět
+- [x] Hvězdičkou, příkaz se zeptá a heslo neukáže
+- Do názvu účtu
+- Do souboru na plochu
+> net user zaci * /add se zeptá. Heslo v příkazu zůstane v historii.
+
+## Proč se denně nepracuje jako správce?
+- Word jinak nejde otevřít
+- [x] Program, který spustíte, dostane právo měnit systém
+- Správce nemá Dokumenty
+- Excel jinak nepočítá
+> Škodlivý program dědí vaše práva. U správce smí instalovat a měnit systém.
+
+## Kam patří osobní úkol?
+- Do C:\Users\Public
+- [x] Do Dokumentů vlastního účtu
+- Do složky Windows
+- Na účet spolužáka
+> Public vidí každý účet na počítači. Úkol patří do vlastního profilu.
diff --git a/content/programove-vybaveni/1-rocnik/06-spravce-uloh.en.md b/content/programove-vybaveni/1-rocnik/06-spravce-uloh.en.md
new file mode 100644
index 0000000..b9e7a54
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/06-spravce-uloh.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Task Manager
+popis: A process, memory and startup programs. The system is not ended.
+nazev_cviceni: Quiz: Task Manager
+nadpis_kvizu: Pick what you may end
+---
diff --git a/content/programove-vybaveni/1-rocnik/06-spravce-uloh.md b/content/programove-vybaveni/1-rocnik/06-spravce-uloh.md
new file mode 100644
index 0000000..45405b1
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/06-spravce-uloh.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-spravce-uloh
+nazev: Správce úloh
+popis: Proces, paměť a programy po spuštění. Systém se neukončuje.
+nazev_cviceni: Kvíz: Správce úloh
+nadpis_kvizu: Vyberte, co smíte ukončit
+---
+
+# Výklad
+
+## Okno
+*Ctrl+Shift+Esc*
+
+- Správce úloh se otevře Ctrl+Shift+Esc. Nehledá se přes náhodné ikony na ploše.
+- Karta Procesy ukáže aplikace a to, co běží na pozadí.
+- Sloupec Procesor je v procentech. Sloupec Paměť je v megabajtech.
+- Vysoká paměť u prohlížeče s dvaceti kartami je běžná. Vysoká paměť neznámého procesu se hlásí.
+
+> Tip: Když plocha nereaguje, Ctrl+Shift+Esc často projde i tehdy, když nabídka Start ne.
+
+## Ukončit se smí aplikace
+*Ne systém*
+
+- Ukončit úlohu smíte u svého Wordu, který nereaguje. Rozpracovaný dokument si předtím uložte, jinak o něj přijdete.
+- Proces Systém, Registr a aplikace systému Windows se neukončují. Počítač pak nemusí doběhnout do restartu.
+- Neznámý proces nejdřív vyhledejte podle přesného názvu. Ukončení není první krok.
+- Restart počítače je čistší než ukončit deset systémových procesů za sebou.
+
+> Tip: Word, který nereaguje, ukončete a otevřete znovu. Autorecover často dokument vrátí, ale nespoléhejte na něj.
+
+## Po spuštění
+*Co startuje samo*
+
+- Karta Aplikace po spuštění ukáže programy, které naběhnou s Windows.
+- Každý takový program bere čas startu a paměť. Ve škole jich má být málo.
+- Vypnutí Teams nebo prohlížeče po spuštění, když ho učitel nechce, je v pořádku. Vypnutí zabezpečení Windows není.
+- Změna se projeví po příštím přihlášení, ne vždy hned.
+
+> Tip: Povolené a zakázané je v posledním sloupci. Zakázaný program v počítači zůstane, jen se sám nespustí.
+
+## Zadání: jeden pohled
+*Cvičení*
+
+- Otevřete Správce úloh a zapište tři aplikace, které berou nejvíc paměti. Čísla opište.
+- Najděte, jestli s Windows startuje něco, co nepoznáváte. Neznámé nevypínejte, jen ho zapište.
+- Systémové procesy neukončujte. Když Word nereaguje, ukončete jen Word.
+- Uložte procesy.txt do Skola\1-rocnik\system.
+
+> Tip: Řazení podle paměti je kliknutí na záhlaví sloupce Paměť. První řádek je pak ten nejtěžší.
+
+# Kvíz
+
+## Jakou zkratkou se otevře Správce úloh?
+- [x] Ctrl+Shift+Esc
+- Ctrl+S
+- Win+E je Správce úloh
+- Alt+F4 na ploše
+> Ctrl+Shift+Esc otevře Správce přímo. Win+E je Průzkumník.
+
+## Co smíte ukončit, když nereaguje vaše práce?
+- Proces Systém
+- [x] Svůj Word, který nereaguje
+- Registr
+- Zabezpečení Windows
+> Ukončuje se zaseknutá aplikace. Systémové procesy drží počítač.
+
+## Co dělá vypnutí programu na kartě Po spuštění?
+- Odinstaluje ho
+- [x] Příště se sám nespustí s Windows
+- Smaže Dokumenty
+- Zakáže aktualizace
+> Program v počítači zůstane. Nenaběhne jen sám při přihlášení.
+
+## Vysoká paměť u prohlížeče s mnoha kartami znamená co?
+- Že se má hned ukončit Systém
+- [x] Že prohlížeč prostě drží otevřené karty
+- Že je disk NTFS rozbitý
+- Že vypršela licence
+> Každá karta bere paměť. Neznámý proces je jiný případ než prohlížeč, který jste sami otevřeli.
diff --git a/content/programove-vybaveni/1-rocnik/07-zabezpeceni.en.md b/content/programove-vybaveni/1-rocnik/07-zabezpeceni.en.md
new file mode 100644
index 0000000..aaec038
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/07-zabezpeceni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Securing the computer
+popis: Antivirus, updates and a file that pretends to be a document.
+nazev_cviceni: Quiz: security
+nadpis_kvizu: Pick what stays on and what stays closed
+---
diff --git a/content/programove-vybaveni/1-rocnik/07-zabezpeceni.md b/content/programove-vybaveni/1-rocnik/07-zabezpeceni.md
new file mode 100644
index 0000000..803fd77
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/07-zabezpeceni.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-zabezpeceni
+nazev: Zabezpečení počítače
+popis: Antivir, aktualizace a soubor, který se tváří jako dokument.
+nazev_cviceni: Kvíz: zabezpečení
+nadpis_kvizu: Vyberte, co se nezapíná a co se neotvírá
+---
+
+# Výklad
+
+## Zabezpečení Windows
+*Defender*
+
+- Školní Windows mají Zabezpečení Windows. Antivir se nevypíná kvůli instalaci hry.
+- Ochrana v reálném čase má být zapnutá. Vypnutá ochrana je nález, ne úleva.
+- Brána firewall má být zapnutá na školní síti. Vypnutí kvůli jedné hře se nedělá.
+- Druhý antivir vedle Defenderu se bez učitele neinstaluje. Dva se perou a oba pak hlídají hůř.
+
+> Tip: Zabezpečení Windows otevřete z nabídky Start. Zelené fajfky u položek jsou stav, který opisujete do úkolu.
+
+## Soubory a maily
+*Co neotvírat*
+
+- Příloha faktura.pdf.exe se neotevírá, ani když text mailu zní věcně.
+- Odkaz na přihlášení, který nečekáte, se neotvírá. Škola po hesle v mailu nepátrá.
+- Flash disk neznámého původu se do školního počítače nestrká. Nejdřív učitel.
+- Makro ve Wordu se povoluje, jen když dokument přišel ze školy a učitel řekl proč.
+
+> Tip: Heslo se neposílá mailem ani do chatu. Kdo ho po vás chce zprávou, nemá ho dostat.
+
+## Oprávnění k souboru
+*NTFS*
+
+- Pravý klik na složku, Vlastnosti, Zabezpečení ukáže, kdo smí číst a kdo zapisovat.
+- Běžný uživatel zapíše do svých Dokumentů. Do C:\Windows nezapíše.
+- Odebrat si vlastní právo číst vlastní Dokumenty je chyba. Složka pak nejde otevřít.
+- Společná složka Public nemá být místo, kam dáváte test s jménem.
+
+> Tip: Když soubor nejde uložit, často nemáte právo zápisu. Uložte ho do Dokumentů, ne do Program Files.
+
+## Zadání: pět kontrol
+*Cvičení*
+
+- Opište, jestli je ochrana v reálném čase zapnutá a jestli firewall hlásí zapnuto.
+- Z Windows Update opište, jestli něco čeká na restart.
+- Najděte ve stažených souborech, jestli tam není soubor s dvojitou příponou. Neotvírejte ho, zapište název.
+- Uložte bezpecnost.txt do Skola\1-rocnik\system. Ochranu nevypínejte.
+
+> Tip: Když je ochrana vypnutá, nezapínejte ji naslepo třemi kliky v neznámém okně. Řekněte to učiteli, může ji vypínat školní správa.
+
+# Kvíz
+
+## Co má být zapnuté v Zabezpečení Windows?
+- Nic, antivir zpomaluje Word
+- [x] Ochrana v reálném čase a firewall
+- Jen spořič obrazovky
+- Jen OneDrive
+> Antivir i firewall jsou součást školního počítače. Nevypínají se kvůli pohodlí.
+
+## Který soubor neotevřete?
+- poznamka.txt z vlastní složky
+- [x] faktura.pdf.exe z mailu
+- zadost.docx, kterou jste sami uložili
+- znamky.xlsx z hodiny
+> Dvojitá přípona exe je program. Mail s fakturou ho nesmí spustit.
+
+## Kam běžný uživatel smí uložit úkol?
+- Do C:\Windows
+- [x] Do svých Dokumentů
+- Do složky ovladačů
+- Přes cizí účet
+> Dokumenty vlastního účtu jsou na zápis. Systémové složky ne.
+
+## Proč se vedle Defenderu bez učitele nedává druhý antivir?
+- Windows umí jen jeden a druhý se vždy sám smaže
+- [x] Dva antiviry se navzájem ruší
+- Defender je freeware bez licence
+- Excel pak neumí SUMA
+> Dva programy, které hlídají stejné soubory, se blokují. Ve škole stačí Zabezpečení Windows.
diff --git a/content/programove-vybaveni/1-rocnik/08-zadani-windows.en.md b/content/programove-vybaveni/1-rocnik/08-zadani-windows.en.md
new file mode 100644
index 0000000..847262b
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/08-zadani-windows.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a school account and a tidy disk
+popis: Folders, a standard account, updates and protection in one note.
+nazev_cviceni: Quiz: the school account
+nadpis_kvizu: Check the whole assignment
+---
diff --git a/content/programove-vybaveni/1-rocnik/08-zadani-windows.md b/content/programove-vybaveni/1-rocnik/08-zadani-windows.md
new file mode 100644
index 0000000..862c127
--- /dev/null
+++ b/content/programove-vybaveni/1-rocnik/08-zadani-windows.md
@@ -0,0 +1,79 @@
+---
+id: sw-1-zadani-windows
+nazev: Zadání: školní účet a pořádek
+popis: Složky, běžný účet, aktualizace a ochrana v jednom zápisu.
+nazev_cviceni: Kvíz: školní účet
+nadpis_kvizu: Zkontrolujte celé zadání
+---
+
+# Výklad
+
+## Složky
+*Strom*
+
+- V Dokumentech je Skola\1-rocnik a v ní word, excel a system.
+- Na ploše nezůstane pracovní soubor. Plocha není archiv.
+- Přípony názvů jsou vidět. poznámka se nejmenuje poznámka.txt.txt.
+- Názvy jsou bez znaků \ / : * ? a datum v názvu je rok-měsíc-den.
+
+> Tip: Cestu opište z řádku adresy. Od oka se lomítka pletou.
+
+## Účet
+*zaci*
+
+- Cvičný účet zaci je běžný uživatel. Není ve skupině Administrators.
+- Heslo má aspoň 8 znaků a nezadává se do příkazu, jen přes hvězdičku v net user zaci * /add.
+- Do zápisu nepatří skutečné heslo. Patří tam pravidlo a příkaz bez hesla.
+- whoami na denní práci nemá ukázat účet správce.
+
+> Tip: Účet zakládejte jen pod dohledem. V zápisu stačí postup, když učitel účet na počítači nechce.
+
+## Stav systému
+*Tři řádky*
+
+- winver: edice a verze.
+- Windows Update: aktuální, nebo čeká restart. Datum stačí.
+- Zabezpečení Windows: ochrana v reálném čase zapnutá, firewall zapnutý.
+- Ze Správce úloh tři nejtěžší aplikace podle paměti. Systémové procesy neukončené.
+
+> Tip: Když je něco vypnuté, zapište to jako nález. Neopravujte firewall naslepo.
+
+## Odevzdání
+*Jeden soubor*
+
+- Všechno je v ucto-windows.txt ve složce Skola\1-rocnik\system.
+- Jsou tam cesta ke složce word, popis účtu, verze Windows a stav ochrany.
+- Není tam heslo, není tam cizí soubor a není tam vypnutý Defender.
+- Soubor otevřete znovu a přečtěte ho. Cesta v textu musí po vložení do adresy opravdu vyjít.
+
+> Tip: Když cesta z textu skončí chybou, složka neexistuje, nebo je v cestě obyčejné lomítko.
+
+# Kvíz
+
+## Jaký účet je zaci?
+- Správce
+- [x] Běžný uživatel mimo skupinu Administrators
+- Účet bez hesla v Public
+- Sdílený účet celé třídy
+> Denní účet není správce. Heslo má a do skupiny Administrators nepatří.
+
+## Kam se ukládá pracovní soubor?
+- Na plochu
+- [x] Do Skola\1-rocnik a příslušné složky
+- Do C:\Windows
+- Do stažených souborů bez názvu
+> Plocha se nearchivuje. Strom Skola drží word, excel a system odděleně.
+
+## Co má být zapnuté?
+- Jen spořič
+- [x] Ochrana v reálném čase a firewall
+- Dva antiviry
+- Sdílení celé složky Users
+> Defender a firewall zůstávají zapnuté. Do úkolu se opisuje jejich stav.
+
+## Co v souboru ucto-windows.txt být nesmí?
+- Verze z winver
+- [x] Skutečné heslo
+- Cesta ke složce
+- Stav Windows Update
+> Heslo se do textového úkolu nepíše. Příkaz ho bere přes hvězdičku, ne do sešitu.
diff --git a/content/programove-vybaveni/2-rocnik/01-word-styly.en.md b/content/programove-vybaveni/2-rocnik/01-word-styly.en.md
new file mode 100644
index 0000000..92c3344
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/01-word-styly.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Word: styles
+popis: Heading 1, body text and spacing that is not empty lines.
+nazev_cviceni: Quiz: styles
+nadpis_kvizu: Pick the style and the shortcut
+---
diff --git a/content/programove-vybaveni/2-rocnik/01-word-styly.md b/content/programove-vybaveni/2-rocnik/01-word-styly.md
new file mode 100644
index 0000000..b0a2f8d
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/01-word-styly.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-word-styly
+nazev: Word: styly
+popis: Nadpis 1, normální text a mezery, které nejsou prázdné řádky.
+nazev_cviceni: Kvíz: styly
+nadpis_kvizu: Vyberte styl a zkratku
+---
+
+# Výklad
+
+## Styl, ne ruční tučné
+*Nadpis*
+
+- Nadpis 1 je styl. Ctrl+Alt+1 ho zapne. Nadpis 2 je Ctrl+Alt+2.
+- Tučné a větší písmo místo stylu vypadá jako nadpis, ale obsah z něj Word nesestaví.
+- Normální text je styl Normální. Ctrl+Shift+N ho vrátí, když jste omylem v nadpisu.
+- V dokumentu jsou nejvýš tři úrovně nadpisů. Čtvrtá je už nepřehledná.
+
+> Tip: Podokno Styly otevřete z karty Domů. U odstavce je vidět, jaký styl opravdu má.
+
+## Odstavec
+*Mezery ze stylu*
+
+- Prázdný Enter mezi odstavci se nedělá. Mezera se nastaví ve stylu, třeba 8 bodů za odstavcem.
+- Řádkování školního textu je 1,15 nebo 1,5. Ne dvojité přes celou stránku bez důvodu.
+- Zarovnání do bloku u delšího textu, vlevo u nadpisu a u krátkého dopisu.
+- Stránka je A4. Okraje 2,5 cm ze všech stran, to je předvolba Normální.
+
+> Tip: Konec stránky je Ctrl+Enter. Řada Enterů na další stránku se při úpravě rozjede.
+
+## Soubor
+*docx a pdf*
+
+- Pracovní soubor je .docx. Jdou v něm styly i další úpravy.
+- Na odevzdání, které se už nemá měnit, je navíc PDF.
+- Jednou vyexportované PDF neupravujte dál jako jediný zdroj. Opravte docx a PDF udělejte znovu.
+- Název je věcný: 2026-06-12-protokol.docx. Dokument1.docx se neodevzdává.
+
+> Tip: Soubor, Export, Vytvořit soubor PDF. Před exportem se podívejte na náhled tisku, jestli nadpis nesedí sám na konci stránky.
+
+## Zadání: dvoustránkový text
+*Cvičení*
+
+- Dokument A4, okraje 2,5 cm, aspoň tři nadpisy stylem Nadpis 1.
+- Pod každým nadpisem aspoň jeden odstavec stylem Normální. Žádné prázdné Entery mezi nimi.
+- Konec první stránky je Ctrl+Enter, ne deset prázdných řádků.
+- Uložte protokol.docx do Skola\1-rocnik\word a k němu protokol.pdf.
+
+> Tip: Když v navigačním podokně nadpisy nejsou, jsou to jen tučná písma. Označte je a dejte Ctrl+Alt+1.
+
+# Kvíz
+
+## Jak se má udělat nadpis, aby z něj šel obsah?
+- Tučně a 16 bodů
+- [x] Stylem Nadpis 1, zkratka Ctrl+Alt+1
+- Pěti prázdnými Entery nad ním
+- Obrázkem textu
+> Obsah čte styly. Ruční tučné písmo v obsahu není.
+
+## Čím se přejde na další stránku?
+- Deseti Entery
+- [x] Ctrl+Enter
+- Mezerou za odstavcem 80 bodů
+- Smazáním okrajů
+> Ctrl+Enter je konec stránky. Prázdné řádky se při doplnění textu rozjedou.
+
+## Jaké jsou školní okraje na A4?
+- 0 cm, ať se vejde víc
+- [x] 2,5 cm, předvolba Normální
+- 5 cm vpravo a 0 vlevo
+- Okraje se ve Wordu nenastavují
+> Normální okraj je 2,5 cm. Text až do kraje papíru tiskárna často ořízne.
+
+## Který soubor se dál upravuje?
+- PDF
+- [x] docx
+- Jen vytištěná stránka
+- Příloha exe
+> docx drží styly. PDF je kopie na odevzdání.
diff --git a/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.en.md b/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.en.md
new file mode 100644
index 0000000..9a94510
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Word: a table, a header and a table of contents
+popis: A 4 by 3 table, a page number and a contents list from the headings.
+nazev_cviceni: Quiz: contents
+nadpis_kvizu: Pick where the contents come from
+---
diff --git a/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.md b/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.md
new file mode 100644
index 0000000..ce559e7
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/02-word-tabulka-a-obsah.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-word-obsah
+nazev: Word: tabulka, záhlaví a obsah
+popis: Tabulka 4×3, číslo stránky a obsah z nadpisů.
+nazev_cviceni: Kvíz: obsah
+nadpis_kvizu: Vyberte, odkud se obsah vezme
+---
+
+# Výklad
+
+## Tabulka
+*4 řádky a 3 sloupce*
+
+- Tabulka se vkládá z karty Vložení. Kreslení tužkou od ruky se ve školním protokolu nedělá.
+- Školní tabulka má 4 řádky a 3 sloupce: záhlaví a tři záznamy.
+- Záhlaví tabulky je tučné a řádek se neopakuje jako obyčejný text pod tabulkou.
+- Text v buňce se nezalamuje deseti mezerami. Šířka sloupce se táhne myší.
+
+> Tip: Když potřebujete řádek navíc, klepněte vpravo od řádku a dejte vložit. Enter v buňce jen zalomí text.
+
+## Záhlaví a zápatí
+*Číslo stránky*
+
+- Záhlaví je nahoře, zápatí dole. Dvojklik v okraji je otevře.
+- V záhlaví je název školy a název dokumentu. V zápatí je číslo stránky.
+- Číslo stránky se vloží jako pole. Ručně napsaná jednička se na druhé stránce neposune.
+- Záhlaví není nadpis dokumentu. Nadpis zůstává v textu jako Nadpis 1.
+
+> Tip: Pole čísla stránky má po označení šedé pozadí. Když je to obyčejná číslice, na další stránce se nezmění.
+
+## Obsah
+*Z nadpisů*
+
+- Obsah je na kartě Reference, Obsah. Word ho složí z Nadpis 1 a Nadpis 2.
+- Ručně psaný seznam stránek obsah není. Po vložení odstavce se rozejde.
+- Po změně textu se obsah aktualizuje pravým klikem, Aktualizovat pole, a volbou celá tabulka.
+- Obsah stojí na začátku, za titulní řádek, na vlastní stránce nebo hned pod názvem. Ne na konci.
+
+> Tip: Když je obsah prázdný, nadpisy nejsou styly. Ctrl+Alt+1 a obsah aktualizujte znovu.
+
+## Zadání: protokol s tabulkou
+*Cvičení*
+
+- Vezměte protokol.docx. Přidejte tabulku 4×3: sloupec Jméno, Třída, Stav.
+- Tři řádky: Anna Nováková, 1.A, odevzdáno. Druhý a třetí řádek doplňte vlastními jmény.
+- Záhlaví s názvem školy, zápatí s číslem stránky, obsah z nadpisů na začátku.
+- Uložte znovu docx a nové PDF. Staré PDF přepište, ať neexistují dvě verze.
+
+> Tip: Aktualizace obsahu se ptá, jestli jen čísla, nebo celá tabulka. Po nových nadpisech berte celou tabulku.
+
+# Kvíz
+
+## Jak velká je školní tabulka v tomto úkolu?
+- [x] 4 řádky a 3 sloupce
+- Jedna buňka
+- 10 sloupců bez záhlaví
+- Tabulka se ve Wordu nedělá
+> Záhlaví plus tři záznamy jsou čtyři řádky. Sloupce jsou Jméno, Třída a Stav.
+
+## Odkud Word vezme obsah?
+- Z tučných vět
+- [x] Ze stylů Nadpis 1 a Nadpis 2
+- Z zápatí
+- Z Excelu automaticky vždy
+> Bez stylů nadpisů je obsah prázdný. Tučné písmo nestačí.
+
+## Jak se má dostat na stránku číslo?
+- Napsat 1 do zápatí ručně
+- [x] Vložit pole čísla stránky
+- Dát ho do názvu souboru
+- Nepsat ho, Word ho do PDF doplní sám i bez pole
+> Pole se na každé stránce samo zvýší. Ruční jednička zůstane jednička.
+
+## Kdy se obsah aktualizuje celý, ne jen čísla stránek?
+- [x] Když přibyl nadpis
+- Když se jen změnila šířka okraje o milimetr a nadpisy zůstaly
+- Po každém písmenu
+- Obsah se neaktualizuje nikdy
+> Nový nadpis do obsahu přibude jen při aktualizaci celé tabulky.
diff --git a/content/programove-vybaveni/2-rocnik/03-word-dopis.en.md b/content/programove-vybaveni/2-rocnik/03-word-dopis.en.md
new file mode 100644
index 0000000..6a953ff
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/03-word-dopis.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Word: a formal letter
+popis: Sender, date, subject, greeting, body and signature on one page.
+nazev_cviceni: Quiz: the letter
+nadpis_kvizu: Put the parts in order
+---
diff --git a/content/programove-vybaveni/2-rocnik/03-word-dopis.md b/content/programove-vybaveni/2-rocnik/03-word-dopis.md
new file mode 100644
index 0000000..288e40f
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/03-word-dopis.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-word-dopis
+nazev: Word: úřední dopis
+popis: Odesílatel, datum, věc, oslovení, text a podpis na jedné straně.
+nazev_cviceni: Kvíz: dopis
+nadpis_kvizu: Seřaďte náležitosti
+---
+
+# Výklad
+
+## Náležitosti
+*Shora dolů*
+
+- Vlevo nahoře odesílatel: škola, jméno, adresa. Vpravo datum 12. 6. 2026.
+- Pod tím adresát. Pak řádek Věc: žádost o uvolnění z výuky.
+- Oslovení, text, rozloučení a jméno s podpisem. Podpis je pod jménem, ne místo něj.
+- Celý dopis je jedna strana A4. Druhá stránka s jedním řádkem je chyba.
+
+> Tip: Datum pište 12. 6. 2026, ne 12/6/26. Český úřední dopis má den, měsíc a rok.
+
+## Vzhled
+*Klidný dopis*
+
+- Písmo jedno, třeba Calibri 11 nebo 12. V dopise není Nadpis 1 přes celou šířku.
+- Řádek Věc je tučný nebo jako krátký nadpis, zbytek je Normální.
+- Odstavce mají mezeru za sebou. Mezi oslovením a textem není deset Enterů.
+- Okraje 2,5 cm. Text do bloku. Bez obrázku, bez tabulky, bez obsahu.
+
+> Tip: Když se poslední řádek podpisu sleze na druhou stránku, zkraťte mezeru za odstavcem, nepřidávejte menší okraje než 2 cm.
+
+## Hromadná pošta
+*Až když je řádků víc*
+
+- Jeden dopis se píše přímo. Hromadná korespondence se vyplatí, když je adresátů aspoň deset.
+- Zdroj dat je Excel s řádkem záhlaví: Jmeno, Prijmeni, Trida.
+- Word na kartě Korespondence vezme tento sešit a pole vloží do oslovení.
+- Sloučený výsledek se ukládá jako nový docx. Zdrojový dopis s poli si nechte zvlášť.
+
+> Tip: Když Excel nemá záhlaví, první žák se stane názvem sloupce a z hromadné pošty vypadne.
+
+## Zadání: žádost
+*Cvičení*
+
+- Napište žádost o uvolnění z výuky na 12. 6. 2026. Adresát je ředitel školy, odesílatel jste vy.
+- V dokumentu je Věc, oslovení, aspoň dvě věty důvodu a podpis.
+- Jedna strana, A4, okraje 2,5 cm. Uložte zadost.docx a zadost.pdf do složky word.
+- Hromadnou poštu v tomto úkolu nedělejte. Stačí jeden dopis.
+
+> Tip: Přečtěte dopis nahlas. Když v něm chybí, o co žádáte a který den, věta důvodu nestačí.
+
+# Kvíz
+
+## Kam patří datum v tomto dopisu?
+- Dospodu místo podpisu
+- [x] Dopravna, 12. 6. 2026
+- Do názvu souboru jako jediné místo
+- Do zápatí místo čísla, datum v textu být nesmí
+> Datum je vpravo nahoře. Podpis dole je jméno, ne datum.
+
+## Jak se označí, o co v dopise jde?
+- [x] Řádkem Věc
+- Stylem Nadpis 3 uprostřed stránky
+- Obrázkem razítka
+- Obsahem na druhé straně
+> Věc je krátký řádek nad oslovením, třeba žádost o uvolnění z výuky.
+
+## Kdy se použije hromadná korespondence?
+- Na jeden dopis řediteli
+- [x] Když je stejný dopis pro deset a víc adresátů a data jsou v Excelu
+- Na každou tabulku
+- Místo PDF
+> Jeden dopis se píše ručně. Hromadná pošta bere řádky z Excelu se záhlavím.
+
+## Jak dlouhý je školní dopis z tohoto zadání?
+- [x] Jedna strana A4
+- Přesně tři strany
+- Půl stránky a zbytek prázdných Enterů
+- Deset stran se styly Nadpis 1
+> Žádost se vejde na jednu stranu. Druhá stránka jen s podpisem je špatně zalomená.
diff --git a/content/programove-vybaveni/2-rocnik/04-excel-bunky.en.md b/content/programove-vybaveni/2-rocnik/04-excel-bunky.en.md
new file mode 100644
index 0000000..17e029c
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/04-excel-bunky.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Excel: cells and formulas
+popis: The address A1, a formula with an equals sign and a dollar lock.
+nazev_cviceni: Quiz: cells
+nadpis_kvizu: Complete the formula
+---
diff --git a/content/programove-vybaveni/2-rocnik/04-excel-bunky.md b/content/programove-vybaveni/2-rocnik/04-excel-bunky.md
new file mode 100644
index 0000000..9ac18a5
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/04-excel-bunky.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-excel-bunky
+nazev: Excel: buňky a vzorce
+popis: Adresa A1, vzorec se rovnítkem a ukotvení dolarama.
+nazev_cviceni: Kvíz: buňky
+nadpis_kvizu: Doplňte vzorec
+---
+
+# Výklad
+
+## Buňka
+*Adresa*
+
+- Sloupce jsou písmena, řádky čísla. B2 je druhý sloupec a druhý řádek.
+- Do buňky patří jedna hodnota. Jméno a třída do jedné buňky nepatří.
+- Číslo je číslo, text je text. Jeden apostróf před číslem z něj udělá text a přestane se sčítat.
+- Sešit je soubor xlsx. List je záložka dole. Školní sešit má listy Ceník a Součet.
+
+> Tip: Klik na záhlaví sloupce označí celý sloupec. Na číslo řádku celý řádek.
+
+## Vzorec
+*Rovnítko*
+
+- Vzorec začíná =. Bez rovnítka je B2*C2 jen text a nic nespočítá.
+- V ceníku je v D2 vzorec =B2*C2. B je počet, C je cena, D je částka.
+- Český Excel odděluje argumenty funkcí středníkem. Krát je hvězdička, děleno lomítko.
+- Vzorec se kopíruje za úchyt v pravém dolním rohu buňky. Neopisuje se do pěti buněk ručně.
+
+> Tip: Když buňka ukáže vzorec místo výsledku, je sloupec úzký, nebo je buňka text. Zkontrolujte rovnítko.
+
+## Posun a dolar
+*A1 a $A$1*
+
+- Zkopírování =B2*C2 o řádek níž udělá =B3*C3. Adresy se posunou.
+- =$B$2 se neposune ani ve sloupci, ani v řádku. Dolar kotví.
+- Sazba DPH v buňce F1 se ve vzorcích bere jako $F$1, ať kopírování neodjede na F2.
+- Smazání hodnoty, na kterou vzorec ukazuje, udělá chybu. Vzorec na prázdnou buňku není nula sám od sebe, když čekal číslo a dostal text.
+
+> Tip: Dolar jen u písmene, $B2, kotví sloupec. Dolar jen u čísla, B$2, kotví řádek.
+
+## Zadání: ceník
+*Cvičení*
+
+- List Ceník: sloupce Polozka, Pocet, Cena, Castka. Záhlaví v řádku 1, pět položek v řádcích 2 až 6.
+- Castka v D2 je =B2*C2 a dolů se táhne. Žádná částka není napsaná ručně.
+- Sazba v F1 je 0,21 a v E2 je =D2*$F$1. Po zkopírování dolů pořád ukazuje na F1.
+- Uložte cenik.xlsx do Skola\1-rocnik\excel.
+
+> Tip: Klik na D2 ukáže vzorec v řádku vzorců. Když tam je jen číslo, částka je opsaná a při změně počtu se nehne.
+
+# Kvíz
+
+## Čím začíná vzorec?
+- Závorkou
+- [x] Rovnítkem
+- Středníkem
+- Dolarama bez rovnítka
+> Bez = Excel nic nepočítá. Text B2*C2 zůstane text.
+
+## Co je v D2, když počet je v B2 a cena v C2?
+- [x] =B2*C2
+- B2*C2 bez rovnítka
+- =B2+C2
+- Ručně spočítané číslo
+> Částka je součin. Sčítání by sečetlo počet s cenou, což není částka.
+
+## Proč je sazba ve vzorci $F$1?
+- [x] Aby se při kopírování dolů adresa neposunula
+- Protože dolar je měna tabulky
+- Aby vzorec přestal počítat
+- $ se v Excelu nesmí
+> F1 je jedna buňka pro všechny řádky. Bez dolarů by druhý řádek četl F2.
+
+## Co udělá zkopírování =B2*C2 o řádek níž?
+- Nechá B2 a C2
+- [x] Vznikne =B3*C3
+- Smaže list
+- Změní se na text
+> Relativní adresa se posune o stejný počet řádků jako kopírování.
diff --git a/content/programove-vybaveni/2-rocnik/05-excel-funkce.en.md b/content/programove-vybaveni/2-rocnik/05-excel-funkce.en.md
new file mode 100644
index 0000000..06d600d
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/05-excel-funkce.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Excel: functions
+popis: SUM, IF and VLOOKUP. The Czech edition separates arguments with a semicolon.
+nazev_cviceni: Quiz: functions
+nadpis_kvizu: Pick the right formula
+---
diff --git a/content/programove-vybaveni/2-rocnik/05-excel-funkce.md b/content/programove-vybaveni/2-rocnik/05-excel-funkce.md
new file mode 100644
index 0000000..67c6907
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/05-excel-funkce.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-excel-funkce
+nazev: Excel: funkce
+popis: SUMA, KDYŽ a SVYHLEDAT. V české verzi se argumenty oddělují středníkem.
+nazev_cviceni: Kvíz: funkce
+nadpis_kvizu: Vyberte správný zápis
+---
+
+# Výklad
+
+## Souhrny
+*SUMA a spol.*
+
+- SUMA(D2:D6) sečte částky. Průměr je PRŮMĚR, nejmenší MIN, největší MAX.
+- POČET spočítá čísla. Prázdnou buňku nepočítá. Text nepočítá.
+- Rozsah D2:D6 zahrnuje oba konce. D2:D6 jsou řádky 2 až 6, ne jen 2 a 6.
+- Souhrn patří pod tabulku, nebo na list Součet. Nepočítá se na papír a neopisuje se.
+
+> Tip: Anglický Excel má SUM a čárku. Český má SUMA a středník. Ve škole pište česky, jak to má školní Office.
+
+## KDYŽ
+*Známky*
+
+- České známky: 1 je nejlepší, 5 je neprospěl. Prospěch je známka 1 až 4.
+- =KDYŽ(D2<=4;"prospěl";"neprospěl") vrací text podle známky v D2.
+- Středníky oddělují podmínku, větev ano a větev ne. Čárka v české verzi vzorec rozbije.
+- Podmínka, která čeká známku aspoň 4 jako úspěch, je obráceně. Čtyřka ještě prochází, pětka ne.
+
+> Tip: Uvozovky kolem textu ve funkci jsou povinné. Bez nich Excel hledá buňku nebo název.
+
+## SVYHLEDAT
+*Ceník*
+
+- SVYHLEDAT najde hodnotu v levém sloupci oblasti a vrátí jiný sloupec stejného řádku.
+- =SVYHLEDAT(A2;Ceník!$A$2:$C$20;3;NEPRAVDA) hledá A2, oblast je ukotvená, vrátí 3. sloupec, shoda je přesná.
+- NEPRAVDA znamená přesnou shodu. PRAVDA by směla vzít nejbližší nižší a u textu plete.
+- Hledaná hodnota musí být v prvním sloupci oblasti. Jinak funkce nehledá uprostřed tabulky.
+
+> Tip: Když funkce vrátí #N/A, hodnota v prvním sloupci není. Zkontrolujte mezeru za jménem a stejný typ, text proti číslu.
+
+## Zadání: známky a ceník
+*Cvičení*
+
+- Na list Znamky dejte pět žáků, sloupec Znamka a sloupec Stav se vzorcem KDYŽ, prospěl při známce 1 až 4.
+- Pod známky SUMA nedávejte, součet známek nic neříká. Dejte PRŮMĚR.
+- Na listu Ceník doplňte SVYHLEDAT, který podle kódu položky vrátí cenu. Oblast ukotvěte a dejte NEPRAVDA.
+- Uložte znamky.xlsx do složky excel. Oba listy ať jsou v jednom sešitu.
+
+> Tip: Průměr známek 1,0 až 5,0 vychází jako číslo. Když vyjde 0, počítáte prázdné buňky, nebo máte text místo čísel.
+
+# Kvíz
+
+## Jak se v českém Excelu zapíše součet D2 až D6?
+- =SUM(D2:D6)
+- [x] =SUMA(D2:D6)
+- =SUMA(D2,D6)
+- SUMA(D2:D6) bez rovnítka
+> Česká funkce je SUMA a rozsah je D2:D6. SUM je anglická verze.
+
+## Který vzorec označí známku 5 jako neprospěl?
+- [x] =KDYŽ(D2<=4;"prospěl";"neprospěl")
+- =KDYŽ(D2>=4;"prospěl";"neprospěl")
+- =KDYŽ(D2<=4,"prospěl","neprospěl")
+- =D2<=4
+> Prochází 1 až 4, tedy menší nebo rovno 4. V češtině jsou středníky, ne čárky.
+
+## Co znamená NEPRAVDA na konci SVYHLEDAT?
+- Vypne vzorec
+- [x] Chce přesnou shodu
+- Sečte sloupec
+- Vrátí vždy první řádek
+> NEPRAVDA je přesně ta hodnota. PRAVDA bere přibližnou shodu a u kódů položek lže.
+
+## Proč je oblast Ceník!$A$2:$C$20 ukotvená?
+- [x] Aby se při kopírování vzorce dolů neposunula
+- Protože jinak nejde uložit xlsx
+- Dolar sčítá ceny
+- SVYHLEDAT ukotvení zakazuje
+> Každý řádek hledá v téže tabulce. Bez dolarů by se oblast sunula dolů s vzorcem.
diff --git a/content/programove-vybaveni/2-rocnik/06-excel-grafy.en.md b/content/programove-vybaveni/2-rocnik/06-excel-grafy.en.md
new file mode 100644
index 0000000..a159302
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/06-excel-grafy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Excel: charts
+popis: A column chart to compare, a line chart for time. A pie only for parts of a whole.
+nazev_cviceni: Quiz: charts
+nadpis_kvizu: Pick the chart type
+---
diff --git a/content/programove-vybaveni/2-rocnik/06-excel-grafy.md b/content/programove-vybaveni/2-rocnik/06-excel-grafy.md
new file mode 100644
index 0000000..4eda9dd
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/06-excel-grafy.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-excel-grafy
+nazev: Excel: grafy
+popis: Sloupcový graf pro porovnání, spojnicový pro čas. Koláč jen pro podíl celku.
+nazev_cviceni: Kvíz: grafy
+nadpis_kvizu: Vyberte typ grafu
+---
+
+# Výklad
+
+## Který graf
+*Typ*
+
+- Porovnání tříd nebo položek je sloupcový graf.
+- Vývoj po měsících je spojnicový. Osa x je čas, zleva doprava.
+- Koláč je jen pro podíly jednoho celku, nejvýš pět výsečí. Dvanáct měsíců v koláči nečtěte.
+- Dva grafy přes sebe, které říkají totéž, se nedělají.
+
+> Tip: Když váháte, sloupec je ve škole skoro vždycky bezpečnější než koláč.
+
+## Co musí být vidět
+*Popisky*
+
+- Graf má název, třeba Tržba po měsících. Název Graf 1 se neodevzdává.
+- Osa hodnot má jednotku, Kč nebo počet. Osa kategorií má názvy, ne 1 až 12 bez legendy měsíce, pokud to z dat není jasné.
+- Legenda se dá pryč, když je řada jen jedna. Zabírá místo a nic neříká.
+- Data grafu jsou v buňkách. Namalovat sloupec obrázkem nejde aktualizovat.
+
+> Tip: Klik na graf, Vybrat data. Když oblast nesedí, graf kreslí i záhlaví nebo prázdné řádky.
+
+## Odkud bere čísla
+*Tabulka*
+
+- Nejdřív čísla, potom graf. Graf bez zdroje je obrázek.
+- Nula v buňce je nula a v grafu je vidět. Prázdná buňka čáru přeruší.
+- Text v číselném sloupci graf vynechá nebo ho položí jako nulu. Čísla nepište s jednotkou Kč v téže buňce.
+- Změna čísla v tabulce změní graf. Když ne, graf je kopie jako obrázek.
+
+> Tip: Kč patří do záhlaví sloupce nebo do názvu osy, ne za číslo do buňky. Jinak je buňka text.
+
+## Zadání: jeden graf
+*Cvičení*
+
+- Na novém listu Mesice dejte leden až červen a k nim tržbu v Kč, šest čísel.
+- Vložte spojnicový graf s názvem Tržba leden až červen. Jednotka ať je v názvu osy nebo v názvu grafu.
+- Koláč nedělejte. Legendu jedné řady vypněte.
+- Uložte do znamky.xlsx jako další list, nebo do trzba.xlsx ve složce excel.
+
+> Tip: Náhled tisku ať graf neroztrhne na dvě stránky. Dejte ho pod tabulku, ne přes ni.
+
+# Kvíz
+
+## Jaký graf je na tržbu leden až červen?
+- Koláč se šesti výsečemi
+- [x] Spojnicový
+- Náhodný 3D kužel
+- Obrázek sloupců vložený z Wordu
+> Čas jde zleva doprava ve spojnicovém grafu. Koláč neukáže pořadí měsíců.
+
+## Kdy je koláč na místě?
+- Na dvanáct měsíců
+- [x] Na podíly jednoho celku, jen pár výsečí
+- Na každou tabulku
+- Když chybí název
+> Koláč je celek rozdělený na pár dílů. Časová řada do něj nepatří.
+
+## Kam nepatří Kč?
+- Do názvu osy
+- [x] Do buňky za číslo, třeba 120 Kč
+- Do názvu grafu
+- Do záhlaví sloupce
+> 120 Kč je text. Graf i SUMA pak číslo nevidí.
+
+## Co se stane po změně čísla v tabulce, když je graf v pořádku?
+- Graf se musí nakreslit znovu ručně
+- [x] Sloupec nebo bod se změní sám
+- Excel graf smaže
+- Změní se licence
+> Graf je navázaný na buňky. Obrázek vložený místo grafu se sám nezmění.
diff --git a/content/programove-vybaveni/2-rocnik/07-excel-kontingence.en.md b/content/programove-vybaveni/2-rocnik/07-excel-kontingence.en.md
new file mode 100644
index 0000000..bb01bed
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/07-excel-kontingence.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Excel: a table, a filter and a pivot
+popis: A list as a table, a filter and a total by class.
+nazev_cviceni: Quiz: the pivot table
+nadpis_kvizu: Say what goes in rows and what goes in values
+---
diff --git a/content/programove-vybaveni/2-rocnik/07-excel-kontingence.md b/content/programove-vybaveni/2-rocnik/07-excel-kontingence.md
new file mode 100644
index 0000000..8f91004
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/07-excel-kontingence.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-excel-kontingence
+nazev: Excel: tabulka, filtr a kontingenční tabulka
+popis: Seznam jako tabulka, filtr a součet podle třídy.
+nazev_cviceni: Kvíz: kontingenční tabulka
+nadpis_kvizu: Řekněte, co patří do řádků a co do hodnot
+---
+
+# Výklad
+
+## Tabulka
+*Ctrl+T*
+
+- Seznam se záhlavím se označí a Ctrl+T z něj udělá tabulku. Filtr v záhlaví vznikne sám.
+- Záhlaví je jeden řádek: Polozka, Trida, Castka. Dva řádky nadpisů Ctrl+T rozbije.
+- Hned pod tabulku se nepíše další text. Souhrn je v řádku souhrnů, nebo na jiném listu.
+- Řazení je šipka v záhlaví. Seřadit jen půlku sloupce mimo tabulku rozhodí řádky.
+
+> Tip: Tabulka dostane na kartě Návrh název, třeba Prodej. Pak se na ni dá odkazovat jménem.
+
+## Filtr
+*Jen 1.A*
+
+- Filtr Trida = 1.A ostatní řádky schová. Nesmaže je.
+- Zrušení filtru je Vybrat vše, nebo Vymazat filtr na kartě Data.
+- Řádek souhrnů tabulky sečte jen to, co filtr nechá vidět.
+- Obyčejná SUMA přes stejné buňky sečte i řádky, které filtr schoval. Na filtrovaný součet to není.
+
+> Tip: Když po filtru zmizí skoro všechno, podívejte se, jestli nemáte mezeru v názvu třídy. 1.A a 1.A s mezerou navíc jsou dvě hodnoty.
+
+## Kontingenční tabulka
+*Součet podle třídy*
+
+- Kontingenční tabulka je na kartě Vložení. Zdroj je tabulka i se záhlavím.
+- Do řádků přijde Trida. Do hodnot Součet z Castka. Sloupce nechte prázdné, dokud není druhý rozměr.
+- Známky se v kontingenci nesčítají. U známek dejte průměr, u peněz součet.
+- Kontingence patří na nový list. Když ji položíte přes zdroj, zdroj přepíšete.
+
+> Tip: Po změně dat dejte Obnovit. Kontingenční tabulka se sama nehne.
+
+## Zadání: prodej po třídách
+*Cvičení*
+
+- List Prodej: aspoň 12 řádků, sloupce Polozka, Trida, Castka. Třídy jen 1.A a 1.B.
+- Ctrl+T a název tabulky Prodej. Filtr ať umí ukázat jen 1.A a zase všechno.
+- Kontingenční tabulka na novém listu: řádky Trida, hodnoty součet Castka.
+- Uložte prodej.xlsx do složky Skola\1-rocnik\excel. Ruční součty vedle kontingence nepište.
+
+> Tip: Když kontingence ukáže Počet místo Součet, v hodnotách počítá buňky. Přepněte souhrn na Součet.
+
+# Kvíz
+
+## Co udělá Ctrl+T na seznamu se záhlavím?
+- Smaže ho
+- [x] Udělá z něj tabulku s filtrem
+- Převede ho na PDF
+- Uzamkne list heslem
+> Tabulka má filtr v záhlaví a jde pojmenovat. Obyčejná oblast to nemá.
+
+## Co sečte jen řádky, které filtr nechá vidět?
+- Obyčejná SUMA přes celou oblast
+- [x] Řádek souhrnů tabulky
+- Nadpis grafu
+- Ručně opsaný součet
+> SUMA schované řádky filtru pořád počítá. Řádek souhrnů tabulky bere jen viditelné.
+
+## Kam v kontingenční tabulce patří Trida a kam Castka?
+- Trida do hodnot jako součet, Castka do řádků
+- [x] Trida do řádků, součet Castka do hodnot
+- Obojí do filtrů a nikam jinam
+- Trida do názvu souboru
+> Řádky jsou třídy. Hodnoty jsou součet peněz. Součet názvů tříd nedává smysl.
+
+## Kdy se u známek v kontingenci použije průměr?
+- Vždy se známky sčítají
+- [x] Když má vyjít průměrná známka, ne součet známek
+- Průměr Excel neumí
+- Jen u textu
+> Součet známek nic neříká. Peníze se sčítají, známky se průměrují.
diff --git a/content/programove-vybaveni/2-rocnik/08-zadani-office.en.md b/content/programove-vybaveni/2-rocnik/08-zadani-office.en.md
new file mode 100644
index 0000000..04488f3
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/08-zadani-office.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a report and a sheet
+popis: A Word letter and an Excel grade sheet in one folder.
+nazev_cviceni: Quiz: the Office hand-in
+nadpis_kvizu: Check both files
+---
diff --git a/content/programove-vybaveni/2-rocnik/08-zadani-office.md b/content/programove-vybaveni/2-rocnik/08-zadani-office.md
new file mode 100644
index 0000000..4c2a906
--- /dev/null
+++ b/content/programove-vybaveni/2-rocnik/08-zadani-office.md
@@ -0,0 +1,79 @@
+---
+id: sw-2-zadani-office
+nazev: Zadání: protokol a tabulka
+popis: Dopis ve Wordu a známky v Excelu v jedné složce.
+nazev_cviceni: Kvíz: odevzdání Office
+nadpis_kvizu: Zkontrolujte oba soubory
+---
+
+# Výklad
+
+## Word
+*Žádost*
+
+- zadost.docx je jedna strana A4, okraje 2,5 cm, datum 12. 6. 2026 vpravo nahoře.
+- Je v ní řádek Věc, oslovení, aspoň dvě věty a podpis. Není v ní obsah ani tabulka.
+- K protokolu z dřívějška, pokud ho máte, patří styly Nadpis 1 a PDF.
+- PDF se jmenuje stejně jako docx. Dvě verze final a final2 se neodevzdávají.
+
+> Tip: Náhled tisku má ukázat jednu stránku. Podpis na druhé stránce znamená moc velké mezery.
+
+## Excel
+*Známky*
+
+- znamky.xlsx má list Znamky: jméno, známka, stav.
+- Stav je vzorec =KDYŽ(D2<=4;"prospěl";"neprospěl") nebo se sloupcem známky, který opravdu máte. Známka 5 je neprospěl.
+- Pod tabulkou je PRŮMĚR známek, ne jejich součet.
+- Čísla jsou čísla. Text 1 nebo 120 Kč ve stejné buňce se nepočítá.
+
+> Tip: Klik na buňku stavu musí v řádku vzorců ukázat KDYŽ. Opsané slovo prospěl se při změně známky nehne.
+
+## Kontrola vzorců
+*Ne opis*
+
+- V ceníku, pokud ho přikládáte, je částka =B2*C2 a sazba ukotvená jako $F$1.
+- SVYHLEDAT, pokud ho máte, končí na NEPRAVDA a oblast je ukotvená.
+- Graf tržby je spojnicový, má název a není to koláč dvanácti měsíců.
+- Kontingenční tabulka, pokud je v sešitu, stojí na vlastním listu a sčítá částky po třídách.
+
+> Tip: Stačí žádost a známky. Ceník, graf a kontingence jsou navíc, ale když je přiložíte, musí sedět.
+
+## Složka
+*Dva hlavní soubory*
+
+- Ve složce Skola\1-rocnik\word je zadost.docx a zadost.pdf.
+- Ve složce Skola\1-rocnik\excel je znamky.xlsx.
+- Na ploše tyto soubory nejsou. Přípony jsou vidět.
+- Otevřete oba znovu. Datum, věc a aspoň jedna pětka se stavem neprospěl mají být vidět.
+
+> Tip: Když PDF nejde otevřít, export nedoběhl. Udělejte ho z docx znovu.
+
+# Kvíz
+
+## Jaký je školní dopis z tohoto zadání?
+- Tři strany se styly a obsahem
+- [x] Jedna strana A4 s řádkem Věc a datem 12. 6. 2026
+- Jen tabulka 4×3
+- Prezentace
+> Žádost je jeden list. Obsah a Nadpis 1 patří protokolu, ne tomuto dopisu.
+
+## Který vzorec označí pětku jako neprospěl?
+- [x] =KDYŽ(D2<=4;"prospěl";"neprospěl")
+- =KDYŽ(D2>=4;"prospěl";"neprospěl")
+- napsané slovo prospěl
+- =SUMA(D2)
+> Prochází známka 1 až 4. Pětka padne do druhé větve. V češtině jsou středníky.
+
+## Co patří pod sloupec známek?
+- Součet známek
+- [x] Průměr
+- Kontingenční součet peněz
+- Nic, známky se mažou
+> Součet známek nemá význam. PRŮMĚR ano.
+
+## Které soubory se odevzdávají jako základ?
+- Jen PDF bez docx
+- [x] zadost.docx, zadost.pdf a znamky.xlsx
+- Jen xlsx na ploše
+- prezentace a video
+> Word se odevzdává i jako zdroj, i jako PDF. Excel je xlsx, protože v PDF by nebyly vzorce.
diff --git a/content/programove-vybaveni/3-rocnik/01-databaze.en.md b/content/programove-vybaveni/3-rocnik/01-databaze.en.md
new file mode 100644
index 0000000..e466ba7
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/01-databaze.en.md
@@ -0,0 +1,6 @@
+---
+nazev: A database
+popis: A table, a record, a key. When a sheet is no longer enough.
+nazev_cviceni: Quiz: databases
+nadpis_kvizu: Name the key and the reason for a database
+---
diff --git a/content/programove-vybaveni/3-rocnik/01-databaze.md b/content/programove-vybaveni/3-rocnik/01-databaze.md
new file mode 100644
index 0000000..bcda465
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/01-databaze.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-databaze
+nazev: Databáze
+popis: Tabulka, záznam, klíč. Kdy už nestačí Excel.
+nazev_cviceni: Kvíz: databáze
+nadpis_kvizu: Poznejte klíč a důvod databáze
+---
+
+# Výklad
+
+## Excel nestačí
+*Vztahy*
+
+- Excel je výborný na jeden seznam. Jakmile se třída opakuje na každém řádku žáka, začne se název třídy rozcházet.
+- Databáze drží věci v samostatných tabulkách a spojuje je klíčem.
+- Tabulka tridy má název třídy jednou. Tabulka zaci se na ni jen odkáže.
+- Access i MySQL umí totéž v principu. Access je soubor accdb, MySQL je server a příkazy SQL.
+
+> Tip: Když opravujete 1.A na 1. A v padesáti řádcích Excelu, má to být tabulka tříd, ne opis.
+
+## Řádek a sloupec
+*Záznam a pole*
+
+- Řádek je záznam, třeba jeden žák. Sloupec je pole, třeba prijmeni.
+- Každá hodnota je atomická. Jméno a příjmení nepatří do jedné buňky.
+- Primární klíč je hodnota, která se v tabulce neopakuje. U žáka je to id, ne příjmení.
+- Cizí klíč je pole, které ukazuje na primární klíč jiné tabulky. trida_id v tabulce zaci ukazuje na id v tridy.
+
+> Tip: Dva Nováci se smějí jmenovat stejně. Jejich id je různé. Proto příjmení klíč není.
+
+## Typy
+*Číslo, text, datum*
+
+- id je číslo. Jméno je krátký text. Datum narození je datum, ne text 12.6.
+- Ano/ne je samostatný typ, ne text ano.
+- Do číselného pole nepatří jednotka. Počet žáků je 28, ne 28 žáků.
+- Prázdné pole a nula nejsou totéž. Nula je známá hodnota, prázdno je nevíme.
+
+> Tip: Text v číselném poli se nepodaří uložit, nebo se sečte špatně. Typ zvolte při založení sloupce.
+
+## Zadání: návrh na papír
+*Cvičení*
+
+- Navrhněte dvě tabulky: tridy (id, nazev) a zaci (id, jmeno, prijmeni, trida_id).
+- U každého pole napište typ. id v obou tabulkách je primární klíč.
+- Napište dva řádky tříd, 1.A a 1.B, a tři žáky, z toho Annu Novákovou ve třídě 1.A.
+- Soubor navrh.txt uložte k databázím. Access ani MySQL ještě neotevírejte, dokud návrh nesedí.
+
+> Tip: Šipka vede od trida_id k id tabulky tridy. Opačně by žák byl klíčem třídy, což není.
+
+# Kvíz
+
+## Co je primární klíč žáka?
+- Příjmení
+- [x] id, které se neopakuje
+- Název třídy
+- První sloupec, ať je v něm cokoli
+> Příjmení se opakuje. id jednoho žáka ne.
+
+## Co je trida_id v tabulce zaci?
+- Primární klíč třídy v téže tabulce
+- [x] Cizí klíč na tabulku tridy
+- Text 1.A opsaný do každého řádku
+- Heslo
+> Cizí klíč drží jen číslo třídy. Název třídy žije v tabulce tridy jednou.
+
+## Kdy už je Excel na seznam žáků těsný?
+- Jakmile má víc než jeden sloupec
+- [x] Když se údaj, třeba název třídy, opakuje a musí zůstat stejný
+- Excel nestačí nikdy
+- Jakmile je soubor xlsx
+> Opakovaný název se rozjede. Databáze ho má v jedné tabulce a ostatní se na ni odkážou.
+
+## Čím se liší nula a prázdné pole?
+- Ničím
+- [x] Nula je hodnota, prázdné pole znamená, že hodnota chybí
+- Prázdné pole je vždy nula
+- Nula je text
+> Průměr nulu počítá. Prázdnou buňku často ne. Typ i význam se liší.
diff --git a/content/programove-vybaveni/3-rocnik/02-access-tabulky.en.md b/content/programove-vybaveni/3-rocnik/02-access-tabulky.en.md
new file mode 100644
index 0000000..3ee054f
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/02-access-tabulky.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Access: tables and a relationship
+popis: An AutoNumber, a foreign key and a one-to-many link without a cascade.
+nazev_cviceni: Quiz: Access
+nadpis_kvizu: Pick the key type and the cascade
+---
diff --git a/content/programove-vybaveni/3-rocnik/02-access-tabulky.md b/content/programove-vybaveni/3-rocnik/02-access-tabulky.md
new file mode 100644
index 0000000..c92654e
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/02-access-tabulky.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-access-tabulky
+nazev: Access: tabulky a vztah
+popis: Automatické číslo, cizí klíč a vztah 1:N bez kaskády.
+nazev_cviceni: Kvíz: Access
+nadpis_kvizu: Vyberte typ klíče a kaskádu
+---
+
+# Výklad
+
+## Soubor
+*accdb*
+
+- Databáze Accessu je jeden soubor accdb. Jmenuje se evidence.accdb.
+- Tabulku založíte v návrhovém zobrazení, ne jen v datovém listu. V návrhu je vidět typ.
+- Tabulka tridy má pole id a nazev. Tabulka zaci má id, jmeno, prijmeni a trida_id.
+- Název pole je bez mezery a bez diakritiky: trida_id. Popisek pro lidi se dá nastavit zvlášť.
+
+> Tip: Datový list je na zapisování řádků. Typ pole se mění v návrhu. Když už v tabulce data jsou, změna typu může selhat.
+
+## Klíče
+*Automatické číslo*
+
+- id je typ Automatické číslo a je to primární klíč. Access ho doplňuje sám: 1, 2, 3.
+- trida_id je typ Číslo, dlouhé celé číslo. Není to automatické číslo. Píšete ho vy, nebo ho vybere vztah.
+- nazev, jmeno a prijmeni jsou Krátký text. Délka 50 stačí.
+- Dva primární klíče v jedné tabulce školního cvičení nejsou. Stačí id.
+
+> Tip: Když trida_id necháte jako text, vztah na číselné id třídy Access odmítne. Typy obou konců musí sedět.
+
+## Vztah 1:N
+*Bez kaskády*
+
+- Jedna třída má víc žáků. Žák má jednu třídu. To je vztah 1:N.
+- Ve vztazích přetáhnete tridy.id na zaci.trida_id.
+- Zaškrtněte vynucení referenční integrity. Žák se nesmí odkázat na třídu, která neexistuje.
+- Kaskádové odstranění nechte vypnuté. Smazání třídy by jinak smazalo i žáky.
+
+> Tip: Na straně tridy je jednička, na straně zaci je znak nekonečna. Když je to obráceně, táhli jste vztah špatným směrem.
+
+## Zadání: evidence.accdb
+*Cvičení*
+
+- Založte evidence.accdb s tabulkami tridy a zaci podle návrhu.
+- Vložte třídy 1.A a 1.B. Vložte Annu Novákovou s trida_id třídy 1.A a ještě dva žáky.
+- Vztah 1:N s referenční integritou, bez kaskádového odstranění.
+- Zkuste uložit žáka s trida_id 99. Access ho má odmítnout. Pak to číslo vraťte na platnou třídu.
+
+> Tip: Když jde uložit trida_id 99, integrita není zapnutá. Otevřete vztahy a vztah vytvořte znovu.
+
+# Kvíz
+
+## Jaký typ má id žáka?
+- Krátký text, který píšete ručně
+- [x] Automatické číslo a primární klíč
+- Ano/ne
+- Dlouhý text s celým jménem třídy
+> Automatické číslo žák nedoplňuje. Access ho dá samo a v tabulce se neopakuje.
+
+## Jaký typ má trida_id?
+- Automatické číslo
+- [x] Číslo, dlouhé celé číslo
+- Datum
+- Ano/ne
+> Je to cizí klíč na id třídy, které je taky číslo. Automatické číslo by si vymýšlelo vlastní hodnoty.
+
+## Co dělá referenční integrita?
+- Smaže žáky při smazání třídy
+- [x] Nedovolí žáka s třídou, která neexistuje
+- Schová tabulku
+- Převede accdb na xlsx
+> Odkaz musí vést na skutečný řádek. Třída 99 bez řádku v tridy projít nemá.
+
+## Proč nechat kaskádové odstranění vypnuté?
+- Access ho neumí
+- [x] Smazání třídy by smazalo i její žáky
+- Jinak nejde uložit accdb
+- Kvůli Excelu
+> Třída a žáci jsou různá data. Smazat třídu omylem a přijít o žáky je špatná pojistka pro školní cvičení.
diff --git a/content/programove-vybaveni/3-rocnik/03-access-dotazy.en.md b/content/programove-vybaveni/3-rocnik/03-access-dotazy.en.md
new file mode 100644
index 0000000..721c282
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/03-access-dotazy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Access: queries
+popis: A query that shows only class 1.A, and a criterion in quotes.
+nazev_cviceni: Quiz: queries
+nadpis_kvizu: Fill in the criterion
+---
diff --git a/content/programove-vybaveni/3-rocnik/03-access-dotazy.md b/content/programove-vybaveni/3-rocnik/03-access-dotazy.md
new file mode 100644
index 0000000..679b605
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/03-access-dotazy.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-access-dotazy
+nazev: Access: dotazy
+popis: Dotaz, který ukáže jen třídu 1.A, a kritérium v uvozovkách.
+nazev_cviceni: Kvíz: dotazy
+nadpis_kvizu: Doplňte kritérium
+---
+
+# Výklad
+
+## Dotaz není kopie
+*Pohled*
+
+- Dotaz data nekopíruje do nové tabulky. Přečte je a ukáže výsledek.
+- Když ve třídě přibude žák, stejný dotaz ho příště ukáže taky.
+- Dotaz se dělá v návrhovém zobrazení. Tabulky se přidají a pole se přetáhnou dolů.
+- Uložený dotaz se jmenuje zaci_1A. Dotaz1 se neodevzdává.
+
+> Tip: Když dotaz otevřete a data změníte, měníte zdrojovou tabulku. Na prohlížení to vadit nemusí, na omyl ano.
+
+## Kritérium
+*1.A*
+
+- Pod pole nazev z tabulky tridy patří kritérium "1.A" včetně uvozovek.
+- Bez uvozovek Access text nepozná a dotaz selže, nebo se zeptá na parametr.
+- Číselné kritérium uvozovky nemá. id 1 je prostě 1.
+- Chcete-li žáky obou tříd, kritérium 1.A tam nedávejte. Filtr, který tam zbyl z minula, výsledek ořeže.
+
+> Tip: Řádek Kritéria je filtr. Řádek Zobrazit schová sloupec, který v dotazu být musí, ale ve výsledku ho nechcete.
+
+## Spojení v dotazu
+*Obě tabulky*
+
+- Do dotazu přidejte tridy i zaci. Access použije vztah, který už máte.
+- Ven dejte prijmeni, jmeno a nazev třídy. id ven dávat nemusíte.
+- Řazení podle prijmeni je vzestupně. V návrhové mřížce je na to řádek Řadit.
+- Žák bez třídy se při vnitřním spojení neukáže. Díky integritě takový řádek stejně nemá vzniknout.
+
+> Tip: Když se každý žák ukáže dvakrát, tabulky nejsou spojené a Access udělal všechny kombinace. Vraťte vztah.
+
+## Zadání: jen 1.A
+*Cvičení*
+
+- Dotaz zaci_1A ukáže jméno, příjmení a název třídy, jen pro 1.A, seřazené podle příjmení.
+- Anna Nováková v něm je. Žák z 1.B v něm není.
+- Uložte dotaz do evidence.accdb. Výsledek nekopírujte do Excelu jako jediný výstup.
+- Změňte v tabulce příjmení Anny na Novák a dotaz otevřete znovu. Má ukázat Novák.
+
+> Tip: Když se zeptá na parametr 1.A, kritérium nemá uvozovky a Access ho bere jako otázku.
+
+# Kvíz
+
+## Kam patří podmínka "1.A"?
+- Do názvu souboru
+- [x] Do řádku Kritéria pod polem nazev
+- Do primárního klíče místo id
+- Do kaskádového odstranění
+> Kritéria filtrují řádky. Pole nazev drží text 1.A.
+
+## Proč jsou u 1.A uvozovky?
+- [x] Access jinak text pochopí jako parametr nebo chybu
+- Protože 1.A je číslo
+- Uvozovky se v Accessu nepoužívají
+- Aby se zapnula kaskáda
+> Textové kritérium se píše v uvozovkách. Číslo ne.
+
+## Co se stane s dotazem, když do třídy 1.A přidáte žáka?
+- Dotaz je stará kopie a žáka neukáže
+- [x] Po novém otevření ho ukáže
+- Access databázi zavře
+- Musíte ho opsat do Excelu
+> Dotaz čte tabulky. Není to zmrazený export.
+
+## Proč se žák v dotazu objeví dvakrát u každé třídy?
+- Má dva primární klíče
+- [x] Tabulky v dotazu nejsou spojené vztahem
+- Je seřazený podle příjmení
+- Filtr je vypnutý
+> Bez spojení Access zkombinuje každý řádek s každým. Vztah tomu zabrání.
diff --git a/content/programove-vybaveni/3-rocnik/04-access-formulare.en.md b/content/programove-vybaveni/3-rocnik/04-access-formulare.en.md
new file mode 100644
index 0000000..b3cd488
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/04-access-formulare.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Access: a form and a report
+popis: Entry through a form and printing through a report. The datasheet is not for users.
+nazev_cviceni: Quiz: forms
+nadpis_kvizu: Pick what is for entry and what is for print
+---
diff --git a/content/programove-vybaveni/3-rocnik/04-access-formulare.md b/content/programove-vybaveni/3-rocnik/04-access-formulare.md
new file mode 100644
index 0000000..37f9b48
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/04-access-formulare.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-access-formulare
+nazev: Access: formulář a sestava
+popis: Zápis přes formulář a tisk přes sestavu. Datový list není pro uživatele.
+nazev_cviceni: Kvíz: formulář
+nadpis_kvizu: Vyberte, čím se data zadávají a čím tisknou
+---
+
+# Výklad
+
+## Formulář
+*Jeden žák*
+
+- Formulář ukáže jeden záznam jako políčka. Datový list ukáže mřížku a tam se běžný uživatel snadno trefí do špatného řádku.
+- Formulář Zaci se založí z tabulky zaci. Pole id ať je vidět, ale zamčené. Automatické číslo se nepíše.
+- trida_id je lépe rozbalovací seznam, který ukazuje název třídy a ukládá id.
+- Formulář se jmenuje frmZaci. Výchozí Formulář1 se neodevzdává.
+
+> Tip: Rozbalovací seznam bere řádky z tabulky tridy. Viditelný sloupec je nazev, vázaný sloupec je id.
+
+## Sestava
+*Tisk*
+
+- Sestava je na tisk a na PDF. Formulář je na zápis. Neslouží k tomu totéž.
+- Sestava žáků je seskupená podle třídy a v rámci třídy seřazená podle příjmení.
+- Záhlaví skupiny ukáže 1.A jednou, ne na každém řádku jako jediný nadpis celé sestavy.
+- Do sestavy nepatří tlačítka formuláře. Patří do ní nadpis Evidence žáků a číslo stránky.
+
+> Tip: Náhled sestavy je před tiskem povinný. Prázdná druhá stránka znamená velkou zápatí nebo prázdný oddíl.
+
+## Kdo kam smí
+*Ne do návrhu*
+
+- Uživatel, který jen zapisuje žáky, dostane formulář. Návrhové zobrazení ne.
+- Mazání záznamu na formuláři je vypnuté, dokud učitel neřekne jinak.
+- Sestava se needituje. Když v ní přepíšete jméno, často přepisujete data, a to z sestavy dělat nechcete.
+- Záloha je kopie accdb před mazáním. Jediný soubor na ploše záloha není.
+
+> Tip: Soubor, Uložit jako, a datum v názvu: evidence-2026-06-12.accdb. Pak teprve mažete zkušební řádky.
+
+## Zadání: formulář a sestava
+*Cvičení*
+
+- frmZaci zapíše jméno, příjmení a třídu. id nejde přepsat.
+- Sestava seskupí žáky podle třídy. Anna Nováková je pod 1.A.
+- Z formuláře přidejte jednoho žáka do 1.B a v sestavě ho najděte.
+- Uložte vše do evidence.accdb. PDF sestavy je evidence-zaci.pdf vedle souboru.
+
+> Tip: Když v rozbalovacím seznamu vidíte čísla místo 1.A, zobrazujete id. Přepněte šířku sloupců, ať je vidět nazev.
+
+# Kvíz
+
+## Čím se ve škole zadává nový žák?
+- Přímo v návrhovém zobrazení tabulky
+- [x] Formulářem frmZaci
+- Přepsáním sestavy
+- Excelem místo Accessu bez uložení accdb
+> Formulář ukáže jeden záznam a umí seznam tříd. Návrh tabulky je pro autora databáze.
+
+## K čemu je sestava?
+- K mazání sloupců
+- [x] K tisku a PDF, seskupenému podle třídy
+- K nastavení automatického čísla
+- K instalaci Accessu
+> Sestava formátuje výstup. Zápis zůstává formuláři.
+
+## Proč je id na formuláři zamčené?
+- [x] Automatické číslo se nepíše ručně
+- Access id neumí ukázat
+- Jinak nejde sestava
+- Protože je to text
+> Číslo přidělí Access. Ruční přepsání by rozbilo klíč.
+
+## Co uděláte před mazáním zkušebních řádků?
+- Nic, accdb se sám verzuje
+- [x] Kopie souboru s datem v názvu
+- Smažete vztah
+- Export jen do JPG
+> Jedna kopie accdb je záloha. Mazání bez ní nejde vzít zpět tak snadno jako Ctrl+Z ve Wordu.
diff --git a/content/programove-vybaveni/3-rocnik/05-mysql-select.en.md b/content/programove-vybaveni/3-rocnik/05-mysql-select.en.md
new file mode 100644
index 0000000..4e04663
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/05-mysql-select.en.md
@@ -0,0 +1,6 @@
+---
+nazev: MySQL: SELECT
+popis: Choosing columns, a WHERE condition and a sort. Text sits in single quotes.
+nazev_cviceni: Quiz: SELECT
+nadpis_kvizu: Pick the right statement
+---
diff --git a/content/programove-vybaveni/3-rocnik/05-mysql-select.md b/content/programove-vybaveni/3-rocnik/05-mysql-select.md
new file mode 100644
index 0000000..2595d1b
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/05-mysql-select.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-mysql-select
+nazev: MySQL: SELECT
+popis: Výběr sloupců, podmínka WHERE a řazení. Text je v jednoduchých uvozovkách.
+nazev_cviceni: Kvíz: SELECT
+nadpis_kvizu: Vyberte správný příkaz
+---
+
+# Výklad
+
+## Databáze skola
+*Dvě tabulky*
+
+- Na serveru je databáze skola. Tabulka tridy má id a nazev. Tabulka zaci má id, jmeno, prijmeni a trida_id.
+- Příkazy se píšou v MySQL, ne do Excelu. Středník příkaz ukončí.
+- Nejdřív USE skola; ať další příkazy nemíří do jiné databáze.
+- Heslo k serveru do sešitu nepatří. Patří učiteli a do klienta, který ho neukazuje v příkazu.
+
+> Tip: Když klient hlásí, že tabulka neexistuje, jste v jiné databázi, nebo je v názvu překlep. SHOW TABLES; ukáže, co tam je.
+
+## SELECT
+*Jen to, co potřebujete*
+
+- SELECT prijmeni, jmeno FROM zaci; vrátí dva sloupce všech žáků.
+- Hvězdička SELECT * FROM zaci; je na průzkum. Do odevzdaného dotazu vyberte sloupce jménem.
+- WHERE trida_id = 1 nechá jen jednu třídu. Číslo je bez uvozovek.
+- Text se píše v jednoduchých uvozovkách: WHERE nazev = '1.A'.
+
+> Tip: Dvojité uvozovky kolem 1.A záleží na režimu serveru. Ve škole pište jednoduché, ty platí spolehlivě.
+
+## Pořadí
+*ORDER BY*
+
+- ORDER BY prijmeni; seřadí vzestupně, tedy A až Ž.
+- ORDER BY prijmeni DESC; je sestupně.
+- WHERE patří před ORDER BY. Obrácené pořadí příkazů server odmítne.
+- LIMIT 5 vrátí nejvýš pět řádků. Na kontrolu ano, na úplný seznam třídy ne.
+
+> Tip: Nejdřív FROM, pak WHERE, pak ORDER BY. SELECT je na začátku, i když se sloupce vybírají jako první v hlavě.
+
+## Zadání: tři dotazy
+*Cvičení*
+
+- Do souboru dotazy.sql napište USE skola; a tři SELECT.
+- První: příjmení a jméno všech žáků podle příjmení.
+- Druhý: jen žáci s trida_id = 1.
+- Třetí: název třídy 1.A z tabulky tridy. Text '1.A' je v jednoduchých uvozovkách.
+
+> Tip: Každý příkaz ukončete středníkem. Bez něj klient čeká na pokračování.
+
+# Kvíz
+
+## Který příkaz vrátí příjmení žáků třídy s id 1?
+- [x] SELECT prijmeni FROM zaci WHERE trida_id = 1;
+- SELECT prijmeni WHERE trida_id = 1;
+- SELECT * FROM tridy WHERE nazev = 1.A;
+- WHERE trida_id = 1;
+> SELECT říká sloupce, FROM tabulku, WHERE podmínku. Číslo je bez uvozovek.
+
+## Jak se v podmínce zapíše název třídy?
+- nazev = 1.A
+- [x] nazev = '1.A'
+- nazev = "bez uvozovek a bez čísla"
+- nazev := 1.A
+> Text patří do jednoduchých uvozovek. Bez nich to není řetězec.
+
+## Kam patří ORDER BY?
+- Před SELECT
+- [x] Za WHERE
+- Místo FROM
+- Do názvu tabulky
+> Pořadí je WHERE, potom ORDER BY. SELECT zůstává první.
+
+## K čemu je ve škole SELECT * ?
+- Na odevzdaný tisk všech osobních údajů
+- [x] Na rychlý pohled, co v tabulce je
+- Místo primárního klíče
+- Na smazání řádků
+> Hvězdička je průzkum. Odevzdaný dotaz jmenuje sloupce, které mají být vidět.
diff --git a/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.en.md b/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.en.md
new file mode 100644
index 0000000..a00bb9c
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.en.md
@@ -0,0 +1,6 @@
+---
+nazev: MySQL: INSERT, UPDATE and DELETE
+popis: A new row, a change and a delete. An UPDATE or DELETE without WHERE is not written in class.
+nazev_cviceni: Quiz: changing data
+nadpis_kvizu: Recognise the safe statement
+---
diff --git a/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.md b/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.md
new file mode 100644
index 0000000..63fc270
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/06-mysql-zmeny.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-mysql-zmeny
+nazev: MySQL: INSERT, UPDATE a DELETE
+popis: Nový řádek, oprava a smazání. UPDATE a DELETE bez WHERE se ve škole nepíšou.
+nazev_cviceni: Kvíz: změny dat
+nadpis_kvizu: Poznejte bezpečný příkaz
+---
+
+# Výklad
+
+## INSERT
+*Nový žák*
+
+- INSERT INTO zaci (jmeno, prijmeni, trida_id) VALUES ('Anna', 'Nováková', 1); přidá jeden řádek.
+- Sloupce v závorce a hodnoty za VALUES musí být ve stejném pořadí a stejném počtu.
+- id s automatickým číslem do výčtu nedávejte, server ho doplní.
+- Jeden INSERT, jeden žák. Balit pět žáků do nepřehledného příkazu ve cvičení nemusíte.
+
+> Tip: Když server hlásí neznámý sloupec, název nesedí s tabulkou. SHOW COLUMNS FROM zaci; ukáže pravdu.
+
+## UPDATE
+*Jen jeden řádek*
+
+- UPDATE zaci SET trida_id = 2 WHERE id = 15; přepíše třídu jen žáku 15.
+- Bez WHERE by stejný příkaz přepsat třídu všem. Ve škole se neodevzdává.
+- Nejdřív SELECT s stejnou podmínkou WHERE. Až když vidíte jeden správný řádek, pusťte UPDATE.
+- SET mění hodnoty. Není to SELECT a nic nevrací kromě počtu ovlivněných řádků.
+
+> Tip: Klient ukáže, kolik řádků se změnilo. Když vidíte desítky a čekali jste jeden, zastavte se a volejte učitele. Záloha se hodí předtím, ne potom.
+
+## DELETE
+*Také s WHERE*
+
+- DELETE FROM zaci WHERE id = 15; smaže jeden řádek.
+- DELETE FROM zaci; bez WHERE smaže všechny žáky. To není čištění tabulky, to je ztráta dat.
+- Smazaný řádek Ctrl+Z v MySQL nevrátí. Vrátí ho záloha, nebo ho vložíte znovu, když znáte hodnoty.
+- Mazat třídu, na kterou ještě ukazují žáci, referenční pravidlo nemá dovolit. Nejdřív žáci, nebo třídu nemazat.
+
+> Tip: Před DELETE udělejte SELECT se stejnou podmínkou. Počet řádků musí být ten, který čekáte, obvykle jeden.
+
+## Zadání: tři změny na kopii
+*Cvičení*
+
+- Na cvičných datech vložte žáka Petr Svoboda do třídy 1, tedy trida_id 1.
+- UPDATE mu změňte třídu na 2, ale jen přes jeho id, které jste předtím přečetli SELECTem.
+- Téhož žáka DELETE smažte zase jen podle id. Ostatní žáci v tabulce zůstanou.
+- Tři příkazy připojte do dotazy.sql pod komentář, který říká, že UPDATE a DELETE mají WHERE.
+
+> Tip: Komentář v SQL začíná dvěma pomlčkami. Do příkazu se nepočítá, příštímu čtenáři řekne, proč tam WHERE je.
+
+# Kvíz
+
+## Který příkaz přidá Annu do třídy 1?
+- [x] INSERT INTO zaci (jmeno, prijmeni, trida_id) VALUES ('Anna', 'Nováková', 1);
+- SELECT Anna INTO zaci;
+- UPDATE zaci SET Anna;
+- DELETE FROM zaci WHERE jmeno = 'Anna';
+> INSERT vkládá řádek. Sloupce a hodnoty drží pořadí.
+
+## Jak se změní třída jen žáku s id 15?
+- UPDATE zaci SET trida_id = 2;
+- [x] UPDATE zaci SET trida_id = 2 WHERE id = 15;
+- SELECT trida_id = 2;
+- INSERT INTO zaci VALUES (15);
+> Bez WHERE by se třída přepsala všem. WHERE id = 15 drží jeden řádek.
+
+## Co udělá DELETE FROM zaci; bez WHERE?
+- Nesmaže nic, WHERE je volitelné a server se zeptá
+- [x] Smaže všechny řádky v zaci
+- Smaže jen poslední řádek
+- Jen vypíše žáky
+> Bez podmínky je cílem celá tabulka. Ve školním cvičení takový příkaz nepište.
+
+## Co uděláte těsně před UPDATE?
+- DELETE bez WHERE
+- [x] SELECT se stejnou podmínkou, ať vidíte ty řádky
+- DROP tabulky
+- Vypnete integritu
+> SELECT ukáže, na koho UPDATE dopadne. Až pak má smysl stejné WHERE použít ke změně.
diff --git a/content/programove-vybaveni/3-rocnik/07-mysql-join.en.md b/content/programove-vybaveni/3-rocnik/07-mysql-join.en.md
new file mode 100644
index 0000000..b97432f
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/07-mysql-join.en.md
@@ -0,0 +1,6 @@
+---
+nazev: MySQL: joining tables
+popis: An INNER JOIN on trida_id, and why the class name is not repeated on every row.
+nazev_cviceni: Quiz: JOIN
+nadpis_kvizu: Complete the join
+---
diff --git a/content/programove-vybaveni/3-rocnik/07-mysql-join.md b/content/programove-vybaveni/3-rocnik/07-mysql-join.md
new file mode 100644
index 0000000..62e5a1b
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/07-mysql-join.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-mysql-join
+nazev: MySQL: spojení tabulek
+popis: INNER JOIN přes trida_id a proč se název třídy neopakuje v každém řádku.
+nazev_cviceni: Kvíz: JOIN
+nadpis_kvizu: Doplňte spojení
+---
+
+# Výklad
+
+## Jedna pravda
+*Název jen jednou*
+
+- Název 1.A je v tabulce tridy jednou. V zaci je jen trida_id.
+- Když by název ležel i u žáka, oprava překlepu znamená projít všechny žáky.
+- To je důvod dvou tabulek. Excel to umí jen ručně, databáze vztahem.
+- JOIN tabulky spojí ve výsledku dotazu. Nesloučí je do jedné natrvalo.
+
+> Tip: Po JOIN pořád existují dvě tabulky. Výsledek dotazu je jen pohled pro tento příkaz.
+
+## INNER JOIN
+*Shoda klíčů*
+
+- INNER JOIN vrátí řádky, kde se klíče sejdou. Žák a jeho třída.
+- ON z.trida_id = t.id je podmínka spojení. Není to totéž co WHERE na název.
+- Bez ON server buď příkaz odmítne, nebo spojí každý řádek s každým.
+- Přezdívky z a t jsou v dotazu proto, aby bylo jasné, která tabulka je která.
+
+> Tip: WHERE t.nazev = '1.A' přijde až po spojení. Nejdřív se tabulky potkají, pak se filtruje.
+
+## Celý příkaz
+*Jak má vypadat*
+
+- SELECT z.prijmeni, z.jmeno, t.nazev FROM zaci AS z INNER JOIN tridy AS t ON z.trida_id = t.id;
+- WHERE t.nazev = '1.A' z toho udělá jen jednu třídu.
+- ORDER BY z.prijmeni výsledek seřadí.
+- Sloupce pište s přezdívkou tabulky. Samotné id by bylo nejasné, mají ho obě tabulky.
+
+> Tip: Když příkaz hlásí nejednoznačný sloupec id, napište z.id nebo t.id. Server nehádá.
+
+## Zadání: jeden JOIN
+*Cvičení*
+
+- Do dotazy.sql přidejte dotaz, který vypíše příjmení, jméno a název třídy.
+- Jen třída 1.A, seřazené podle příjmení.
+- Anna Nováková v něm je včetně textu 1.A, ne jen čísla 1.
+- Příkaz spusťte dvakrát. Výsledek má být stejný, JOIN data nemění.
+
+> Tip: Když vidíte číslo místo 1.A, vybíráte trida_id a ne t.nazev. Doplňte sloupec z tabulky tridy.
+
+# Kvíz
+
+## Které spojení ukáže žáka i název jeho třídy?
+- SELECT * FROM zaci;
+- [x] SELECT z.prijmeni, t.nazev FROM zaci AS z INNER JOIN tridy AS t ON z.trida_id = t.id;
+- DELETE FROM tridy;
+- INSERT INTO tridy VALUES ('1.A');
+> JOIN vezme příjmení z žáků a název z tříd tam, kde se id shoduje.
+
+## Co patří do ON?
+- [x] z.trida_id = t.id
+- t.nazev = '1.A'
+- ORDER BY z.prijmeni
+- USE skola
+> ON je shoda klíčů. Filtr na název 1.A je WHERE, ne spojení.
+
+## Proč není název třídy uložený u každého žáka?
+- MySQL to neumí
+- [x] Aby se název měnil na jednom místě
+- Protože text v MySQL nejde uložit
+- Aby nešel JOIN
+> Jedna pravda v tabulce tridy. Žáci drží jen číslo.
+
+## Co JOIN udělá s uloženými tabulkami?
+- Sloučí je natrvalo do jedné
+- [x] Jen složí výsledek dotazu, tabulky zůstanou dvě
+- Smaže cizí klíče
+- Převede je na Excel
+> Výsledek je odpověď na příkaz. tridy a zaci na serveru zůstávají.
diff --git a/content/programove-vybaveni/3-rocnik/08-zadani-databaze.en.md b/content/programove-vybaveni/3-rocnik/08-zadani-databaze.en.md
new file mode 100644
index 0000000..b69e293
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/08-zadani-databaze.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a student register
+popis: Classes, students, a relationship and one query that shows 1.A by name.
+nazev_cviceni: Quiz: the register
+nadpis_kvizu: Walk the keys and the query
+---
diff --git a/content/programove-vybaveni/3-rocnik/08-zadani-databaze.md b/content/programove-vybaveni/3-rocnik/08-zadani-databaze.md
new file mode 100644
index 0000000..d3a812b
--- /dev/null
+++ b/content/programove-vybaveni/3-rocnik/08-zadani-databaze.md
@@ -0,0 +1,79 @@
+---
+id: sw-3-zadani-databaze
+nazev: Zadání: evidence žáků
+popis: Třídy, žáci, vztah a jeden dotaz, který ukáže 1.A jménem.
+nazev_cviceni: Kvíz: evidence
+nadpis_kvizu: Projděte klíče i dotaz
+---
+
+# Výklad
+
+## Struktura
+*Dvě tabulky*
+
+- tridy: id jako klíč, nazev. Řádky 1.A a 1.B.
+- zaci: id jako klíč, jmeno, prijmeni, trida_id jako cizí klíč.
+- Anna Nováková je ve třídě 1.A. Ve třídě 1.B je aspoň jeden jiný žák.
+- Název 1.A není opsaný v tabulce žáků. Je jen v tridy.
+
+> Tip: Ať děláte Access, nebo MySQL, tahle dvě pole a vztah jsou povinné. Nástroj je obal.
+
+## Access
+*Když odevzdáváte accdb*
+
+- evidence.accdb má vztah 1:N, referenční integritu a vypnutou kaskádu.
+- Dotaz zaci_1A a formulář frmZaci. Sestava je seskupená podle třídy.
+- Žák s neexistující třídou nejde uložit. Vyzkoušeno na trida_id 99.
+- PDF sestavy leží vedle accdb.
+
+> Tip: Když učitel otevře jen accdb, formulář a dotaz v něm musí být. Samotné PDF bez souboru nestačí.
+
+## MySQL
+*Když odevzdáváte SQL*
+
+- dotazy.sql obsahuje SELECT se WHERE, INSERT, UPDATE i DELETE, oba měnící příkazy s WHERE.
+- JOIN vypíše příjmení a název 1.A, seřazené podle příjmení.
+- V souboru není heslo k serveru a není DELETE bez WHERE.
+- Příkazy jdou spustit znovu jen tam, kde to dává smysl. DELETE testovacího žáka v souboru má konkrétní id a komentář.
+
+> Tip: Odevzdává se text příkazů, ne snímek obrazovky klienta. Snímek smí být navíc.
+
+## Jedno odevzdání
+*Ne obě poloviční*
+
+- Stačí jeden nástroj, Access nebo MySQL, ale celý: struktura, data, dotaz na 1.A.
+- V dotazu je Anna vidět jako 1.A, ne jako holé číslo, pokud je součástí této části zadání JOIN nebo dotaz s názvem.
+- Záloha souboru má datum v názvu, třeba evidence-2026-06-12.accdb.
+- Do textu úkolu nepište heslo a nepište, že jste kaskádu zapnuli, pokud jste ji zapnout neměli.
+
+> Tip: Když umíte obojí, odevzdejte jedno pořádně. Dvě rozdělané databáze jsou horší než jedna hotová.
+
+# Kvíz
+
+## Kde leží text 1.A?
+- V každém řádku žáka jako jediná pravda
+- [x] V tabulce tridy, v poli nazev
+- V názvu souboru jako jediném místě
+- V hesle
+> Žák má jen trida_id. Název je v tabulce tříd jednou.
+
+## Jaký vztah mají třídy a žáci?
+- [x] 1:N, jedna třída a víc žáků
+- N:N bez třetí tabulky uprostřed školního cvičení
+- Žádný, stačí Excel bez klíče
+- 1:1, každý žák vlastní tabulku třídy
+> Jedna třída, mnoho žáků. Na straně žáků je cizí klíč.
+
+## Co musí mít UPDATE a DELETE v odevzdaném SQL?
+- Žádnou podmínku, ať je příkaz krátký
+- [x] WHERE, které trefí jen zamýšlené řádky
+- Hvězdičku místo sloupců
+- Kaskádu v příkazu SELECT
+> Bez WHERE se mění nebo maže celá tabulka. Ve cvičení se to neodevzdává.
+
+## Co Access odmítne, když je integrita zapnutá?
+- Žáka se třídou 1.A
+- [x] Žáka s trida_id 99, které v tridy není
+- Formulář
+- Sestavu podle příjmení
+> Cizí klíč musí ukazovat na existující třídu. 99 v tabulce tříd není.
diff --git a/content/programove-vybaveni/4-rocnik/01-wordpress.en.md b/content/programove-vybaveni/4-rocnik/01-wordpress.en.md
new file mode 100644
index 0000000..d8adbc0
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/01-wordpress.en.md
@@ -0,0 +1,6 @@
+---
+nazev: WordPress: pages and posts
+popis: A school site, a static page and a dated news item. The system is not hand-written HTML for every sentence.
+nazev_cviceni: Quiz: WordPress
+nadpis_kvizu: Tell a page from a post
+---
diff --git a/content/programove-vybaveni/4-rocnik/01-wordpress.md b/content/programove-vybaveni/4-rocnik/01-wordpress.md
new file mode 100644
index 0000000..de527c6
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/01-wordpress.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-wordpress
+nazev: WordPress: stránky a příspěvky
+popis: Web školy, statická stránka a datovaná zpráva. Systém není ručně psané HTML každé věty.
+nazev_cviceni: Kvíz: WordPress
+nadpis_kvizu: Rozlište stránku a příspěvek
+---
+
+# Výklad
+
+## Co to je
+*Správa obsahu*
+
+- WordPress je systém pro web. Text se píše v administraci, ne do každé stránky jako do samostatného souboru ve Wordu.
+- Školní web se jmenuje Školní den. Název se nastaví v Nastavení, Obecné.
+- Administrace je cesta /wp-admin/ na adrese webu. Bez účtu tam nejste.
+- Veřejná stránka a administrace jsou dva pohledy. Návštěvník administraci nevidí.
+
+> Tip: Když se po uložení na webu nic nezměnilo, často jste jen uložili koncept. Zveřejnit je samostatný krok.
+
+## Stránka a příspěvek
+*Dva druhy*
+
+- Stránka je trvalá: O škole, Kontakt. Nemá datum jako hlavní údaj.
+- Příspěvek je zpráva: co se stalo 12. 6. 2026. Řadí se od nejnovějšího.
+- Školní den má aspoň jednu stránku O škole a jeden příspěvek o dni otevřených dveří.
+- Příspěvek není stránka s jiným názvem. V menu se s nimi pracuje jinak.
+
+> Tip: V administraci jsou Příspěvky a Stránky dvě samostatné položky. Když novinku přidáte jako stránku, na výpisu zpráv nebude.
+
+## Adresa
+*Trvalý odkaz*
+
+- Trvalý odkaz v Nastavení, Trvalé odkazy, ať je název příspěvku, ne ?p=123.
+- Adresa pak nese slova, třeba /den-otevrenych-dveri/.
+- Jednou zveřejněnou adresu neměňte jen kvůli stylu. Odkaz, který už někdo má, by se rozbil.
+- Název webu a adresa příspěvku jsou dvě věci. Název je Školní den, adresa je konkrétní článek.
+
+> Tip: Když škola už odkazy rozeslala, změnu trvalých odkazů nechte na učiteli. Cvičný web si klidně nastavte hned na začátku.
+
+## Zadání: dvě věci na webu
+*Cvičení*
+
+- Na cvičném webu nastavte název Školní den.
+- Zveřejněte stránku O škole, aspoň dvě věty, bez data jako hlavní osy.
+- Zveřejněte příspěvek Den otevřených dveří s datem 12. 6. 2026.
+- Do souboru web.txt napište adresu stránky a adresu příspěvku. Heslo do souboru nepište.
+
+> Tip: Koncept se na veřejné stránce neukáže. U obou má být stav Publikováno.
+
+# Kvíz
+
+## Čím se liší stránka od příspěvku?
+- Ničím, je to stejné tlačítko
+- [x] Stránka je trvalá, příspěvek je datovaná zpráva
+- Příspěvek nejde zveřejnit
+- Stránka musí být v Excelu
+> O škole je stránka. Novinka s datem je příspěvek.
+
+## Kam se nastaví název webu Školní den?
+- Do názvu počítače
+- [x] Do Nastavení, Obecné
+- Do buňky A1
+- Do příkazu DELETE
+> Název webu je nastavení WordPressu, ne název souboru Windows.
+
+## Jaký trvalý odkaz ve cvičení chcete?
+- ?p=123 natrvalo
+- [x] Název příspěvku ve adrese
+- Jen číslo id žáka
+- Adresu z Accessu
+> Adresa se slovy se čte a pamatuje. Číselný parametr ne.
+
+## Co napsat do web.txt?
+- Heslo správce
+- [x] Adresy zveřejněné stránky a příspěvku
+- Celou databázi MySQL
+- Soubor accdb
+> Úkol dokládá, že stránky jsou venku. Heslo do textového souboru nepatří.
diff --git a/content/programove-vybaveni/4-rocnik/02-wordpress-role.en.md b/content/programove-vybaveni/4-rocnik/02-wordpress-role.en.md
new file mode 100644
index 0000000..bee86a9
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/02-wordpress-role.en.md
@@ -0,0 +1,6 @@
+---
+nazev: WordPress: users and appearance
+popis: An author writes their own news, an editor also edits others. There is one administrator.
+nazev_cviceni: Quiz: roles
+nadpis_kvizu: Match the role
+---
diff --git a/content/programove-vybaveni/4-rocnik/02-wordpress-role.md b/content/programove-vybaveni/4-rocnik/02-wordpress-role.md
new file mode 100644
index 0000000..a4d5f49
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/02-wordpress-role.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-wordpress-role
+nazev: WordPress: uživatelé a vzhled
+popis: Autor píše své zprávy, šéfredaktor i cizí. Správce je jeden.
+nazev_cviceni: Kvíz: role
+nadpis_kvizu: Přiřaďte roli
+---
+
+# Výklad
+
+## Role
+*Nejmíň práv*
+
+- Správce instaluje doplňky, mění šablonu a zakládá uživatele. Ve třídě je jeden, účet učitele.
+- Šéfredaktor zveřejňuje a upravuje i cizí příspěvky a stránky. Správce to být nemusí.
+- Autor zveřejňuje vlastní příspěvky. Cizí ne. Stránky O škole nezakládá.
+- Spolužák, který jen přidává novinky, dostane roli Autor. Ne správce.
+
+> Tip: Přihlášení každého člověka je vlastní účet. Heslo se nepůjčuje, ani když jde o školní cvičení.
+
+## Šablona a doplňky
+*Málo*
+
+- Šablona je vzhled. Ve cvičení stačí jedna, třeba výchozí, kterou učitel určí.
+- Doplněk přidává funkci. Každý doplněk je další věc, která se musí aktualizovat.
+- Doplněk z neznámého webu se neinstaluje. Stačí jeden překlep a web běží s cizím kódem.
+- Aktualizace WordPressu, šablony a doplňků se dělají v klidný den a po záloze, ne minutu před dnem otevřených dveří.
+
+> Tip: Neaktivní doplněk, který nepoužíváte, smažte. Vypnutý pořád leží na disku a pořád se může hodit útočníkovi, když v něm je chyba.
+
+## Menu
+*Co návštěvník vidí*
+
+- Menu má položku O škole na stránku, ne na koncept.
+- Příspěvky se často vypisují samy na úvodu. Do menu se nemusí přidávat každý.
+- Zkouškový příspěvek s textem test test do veřejného menu nepatří. Smažte ho, nebo ho nechte jako koncept.
+- Název v menu a nadpis stránky ať říkají totéž. O škole a O nas jsou dvě stránky, ne jedna.
+
+> Tip: Náhled v anonymním okně ukáže web bez vašeho přihlášení. To je pohled návštěvníka.
+
+## Zadání: účet autora
+*Cvičení*
+
+- Založte uživatele redakce s rolí Autor. Heslo mu nastavte mimo sešit, do web.txt ho nepište.
+- Ověřte, že tento účet neumí instalovat doplňky. Když umí, je to správce a roli změňte.
+- Šablonu neměňte na náhodnou z internetu. Nechte tu, kterou web už má, nebo tu od učitele.
+- Do web.txt dopište přihlašovací jméno autora a že role je Autor. Ne heslo.
+
+> Tip: Uživatelé, Všichni uživatelé, sloupec Role. Autor tam musí být napsaný, nestačí, že to tak bylo v úmyslu.
+
+# Kvíz
+
+## Jakou roli dostane spolužák, který jen píše novinky?
+- Správce
+- [x] Autor
+- Jediný účet s vaším heslem
+- Roli MySQL
+> Autor zveřejní vlastní příspěvek a nesahá na doplňky ani na cizí texty.
+
+## Kdo smí instalovat doplňky?
+- Každý návštěvník
+- [x] Správce
+- Autor
+- Kdokoli s odkazem na článek
+> Doplňky mění celý web. To je práce správce, ne autora zprávy.
+
+## Proč se doplněk nestahuje z náhodného webu?
+- WordPress doplňky neumí
+- [x] Je to cizí kód, který běží na školním webu
+- Protože pak nejde napsat stránku
+- Doplňky jsou vždycky v Excelu
+> Doplněk je program. Neznámý zdroj do školního webu nepatří.
+
+## Co do web.txt o účtu redakce nepatří?
+- Přihlašovací jméno
+- Role Autor
+- [x] Heslo
+- To, že účet nezvládne instalovat doplňky
+> Heslo do textového úkolu nepište. Jméno a role ano.
diff --git a/content/programove-vybaveni/4-rocnik/03-power-bi.en.md b/content/programove-vybaveni/4-rocnik/03-power-bi.en.md
new file mode 100644
index 0000000..fb41b75
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/03-power-bi.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Power BI: data from Excel
+popis: One workbook, one table, a chart by class. The same data is not loaded twice.
+nazev_cviceni: Quiz: Power BI
+nadpis_kvizu: Pick the source and the visual
+---
diff --git a/content/programove-vybaveni/4-rocnik/03-power-bi.md b/content/programove-vybaveni/4-rocnik/03-power-bi.md
new file mode 100644
index 0000000..e3c9d21
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/03-power-bi.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-power-bi
+nazev: Power BI: data z Excelu
+popis: Jeden sešit, jedna tabulka, graf podle třídy. Stejná data se nenačítají dvakrát.
+nazev_cviceni: Kvíz: Power BI
+nadpis_kvizu: Vyberte zdroj a vizuál
+---
+
+# Výklad
+
+## Soubor
+*pbix*
+
+- Power BI Desktop ukládá práci do pbix. Není to xlsx a není to obrázek grafu.
+- Na odevzdání vedle pbix patří PDF stránky sestavy, ať ji otevře i člověk bez Power BI.
+- Zdroj je prodej.xlsx, tabulka Prodej se sloupci Polozka, Trida, Castka.
+- Načtení je Načíst data, Excel, a vybere se tabulka Prodej, ne celý sešit naslepo dvakrát.
+
+> Tip: Když v modelu vidíte Prodej a Prodej (2), tabulka se načetla dvakrát. Jednu odeberte, než postavíte graf.
+
+## Model
+*Jedna řádka, jedna skutečnost*
+
+- Každý řádek je jeden prodej: položka, třída, částka. Součet v tomtéž sloupci navíc řádek souhrnu být nemá.
+- Řádek souhrnů z Excelu do Power BI nepatří. Součet si spočítá vizuál.
+- Trida je text 1.A a 1.B. Castka je číslo bez Kč v buňce.
+- Když Excel opravíte, v Power BI dejte Obnovit. Sám se o uložení xlsx nedozví.
+
+> Tip: Sloupec, který Power BI bere jako text, se nesčítá. Často je v něm mezera nebo Kč. V Excelu ho opravte a obnovte.
+
+## Vizuál
+*Sloupec a karta*
+
+- Sloupcový graf: osa Trida, hodnota součet Castka. Porovnání dvou tříd.
+- Karta ukáže jedno číslo, celkový součet Castka.
+- Koláč pro dvě třídy je zbytečný. Pro dvanáct měsíců byl špatně už v Excelu.
+- Průřez, anglicky slicer, na pole Trida nechte na příští lekci. Teď ať graf ukáže obě třídy.
+
+> Tip: Když graf ukáže počet řádků místo součtu korun, v hodnotách je Počet. Přepněte souhrn na Součet.
+
+## Zadání: první pbix
+*Cvičení*
+
+- prodej.pbix načte tabulku Prodej jednou.
+- Na stránce jsou sloupcový graf podle třídy a karta s celkovou částkou.
+- Obnovte data potom, co v Excelu změníte jednu částku. Karta se musí pohnout.
+- Uložte pbix do složky Skola\1-rocnik\excel a vedle něj prodej.pdf.
+
+> Tip: PDF je snímek sestavy. pbix je práce, ve které jde vizuál opravit. Odevzdávají se oba.
+
+# Kvíz
+
+## Jaký soubor je pracovní v Power BI?
+- xlsx jako jediný
+- [x] pbix
+- pdf jako jediný
+- accdb
+> pbix drží model a vizuály. Excel je zdroj. PDF je náhled na odevzdání.
+
+## Jak se mají načíst data prodeje?
+- [x] Tabulka Prodej jednou
+- Tentýž list dvakrát
+- Jako obrázek grafu z Excelu
+- Přepisem do Wordu
+> Jedna tabulka v modelu. Druhá kopie téhož zdroje mate součty.
+
+## Jaký vizuál porovná částku tříd 1.A a 1.B?
+- Koláč s dvanácti díly
+- [x] Sloupcový graf, osa Trida, součet Castka
+- Karta bez popisku a bez čísla
+- Průřez místo grafu jako jediný prvek
+> Dvě kategorie a jejich součet jsou sloupce. Karta je jen jedno číslo celkem.
+
+## Co uděláte v Power BI potom, co změníte číslo v Excelu?
+- Nic, pbix se mění sám v tu vteřinu
+- [x] Obnovit
+- Smazat pbix a začít ve Wordu
+- Převést Castka na text
+> Power BI si data při načtení vezme. Další změna Excelu se projeví až po obnovení.
diff --git a/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.en.md b/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.en.md
new file mode 100644
index 0000000..29027c6
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Power BI: a slicer and a report
+popis: A class slicer filters the whole page. A filter on one visual leaves the others.
+nazev_cviceni: Quiz: the slicer
+nadpis_kvizu: Say what the slicer affects
+---
diff --git a/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.md b/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.md
new file mode 100644
index 0000000..c2e7f08
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/04-power-bi-prurez.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-power-bi-prurez
+nazev: Power BI: průřez a sestava
+popis: Průřez třídy přefiltruje celou stránku. Filtr jednoho vizuálu ostatní nechá.
+nazev_cviceni: Kvíz: průřez
+nadpis_kvizu: Řekněte, co průřez ovlivní
+---
+
+# Výklad
+
+## Průřez
+*Celá stránka*
+
+- Průřez na pole Trida je ovládací prvek na plátně. Čtenář klikne na 1.A.
+- Sloupcový graf i karta se mají změnit. Celková částka pak není celá škola, ale jen 1.A.
+- Když se karta nehne, průřez na ni není napojený, nebo karta bere jinou tabulku.
+- Výběr se zruší gumou na průřezu, nebo volbou všech tříd. Data se tím nesmažou.
+
+> Tip: Průřez filtruje. Není to nový součet uložený do Excelu.
+
+## Filtr vizuálu
+*Jen jeden graf*
+
+- Filtr v podokně konkrétního vizuálu ovlivní jen ten vizuál.
+- Školní sestava pro porovnání tříd filtr vizuálu nepotřebuje. Má ukázat obě, dokud čtenář nezvolí průřez.
+- Filtr stránky schová třídu všem vizuálům na stránce, i bez klikání. Na odevzdání ho nenechávejte zapnutý omylem.
+- Než sestavu vyexportujete, zrušte výběr průřezu, pokud má PDF ukázat celou školu.
+
+> Tip: PDF zachytí průřez tak, jak je zrovna vybraný. Vybraná 1.A v PDF už druhou třídu neukáže.
+
+## Stránka
+*Jedna otázka*
+
+- Stránka se jmenuje Prodej. Název Stránka 1 se neodevzdává.
+- Nadpis na plátně říká Prodej po třídách. Graf má popisky tříd.
+- Na jedné stránce jsou nejvýš průřez, jeden graf a jedna karta. Druhý graf se stejným obsahem je navíc.
+- Barvy nechte klidné. Červená a zelená těsně vedle sebe se špatně čte.
+
+> Tip: Zobrazte sestavu na projektoru, nebo aspoň v celém okně. Drobný popisek, který na vašem monitoru ujde, vzadu ve třídě zanikne.
+
+## Zadání: stránka s průřezem
+*Cvičení*
+
+- Na stránku Prodej přidejte průřez Trida.
+- Klik na 1.A změní graf i kartu. Po zrušení výběru je zase vidět celý součet.
+- PDF vyexportujte bez vybrané třídy, ať ukazuje 1.A i 1.B.
+- pbix uložte znovu. V modelu je tabulka Prodej pořád jen jednou.
+
+> Tip: Když průřez nabízí prázdný řádek, v Excelu je prázdný řádek tabulky. Smažte ho ve zdroji a obnovte.
+
+# Kvíz
+
+## Co má průřez na Trida změnit?
+- Jen barvu pozadí
+- [x] Graf i kartu na stránce
+- Soubor xlsx na disku
+- Heslo k WordPressu
+> Průřez filtruje vizuály na stránce. Karta s celkovou částkou se musí změnit taky.
+
+## Čím se liší filtr jednoho vizuálu od průřezu?
+- Ničím
+- [x] Filtr vizuálu platí jen pro něj, průřez je ovládání pro stránku
+- Filtr vizuálu smaže Excel
+- Průřez nejde zrušit
+> Průřez je na plátně pro čtenáře. Filtr v podokně vizuálu ostatní grafy nechá.
+
+## Jaké PDF se odevzdává, když mají být vidět obě třídy?
+- S vybranou jen 1.A
+- [x] Bez výběru v průřezu
+- Prázdná stránka
+- Jen karta bez grafu a bez tříd
+> PDF drží aktuální výběr. Než ho uděláte, výběr zrušte.
+
+## Jak se má stránka jmenovat?
+- Stránka 1
+- [x] Prodej
+- pbix
+- DELETE
+> Název říká, co na stránce je. Výchozí Stránka 1 se neodevzdává.
diff --git a/content/programove-vybaveni/4-rocnik/05-batch.en.md b/content/programove-vybaveni/4-rocnik/05-batch.en.md
new file mode 100644
index 0000000..bbfeb4d
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/05-batch.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Batch files
+popis: A bat file that creates the school folders and deletes nothing.
+nazev_cviceni: Quiz: batch
+nadpis_kvizu: Pick the safe command
+---
diff --git a/content/programove-vybaveni/4-rocnik/05-batch.md b/content/programove-vybaveni/4-rocnik/05-batch.md
new file mode 100644
index 0000000..b110c71
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/05-batch.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-batch
+nazev: Dávkové soubory
+popis: Soubor bat, který založí složky školy a nic nemaže.
+nazev_cviceni: Kvíz: batch
+nadpis_kvizu: Vyberte bezpečný příkaz
+---
+
+# Výklad
+
+## Soubor bat
+*Příkazy za sebou*
+
+- Dávkový soubor je text s příponou bat. Příkazový řádek ho spustí řádek po řádku.
+- První řádek cvičení je @echo off, ať se příkazy nevypisují podruhé do okna.
+- rem je komentář. Do příkazu se nepočítá.
+- Soubor se jmenuje slozky.bat a leží v Dokumentech, ne na cizím flash disku, kterému nerozumíte.
+
+> Tip: Dvojklik spustí bat v té složce, kde soubor je. Neznámý bat z mailu se nespouští, stejně jako pdf.exe.
+
+## Složky
+*mkdir*
+
+- mkdir založí složku. Když už existuje, příkaz ohlásí chybu a pokračuje se dál, složku nesmaže.
+- set "ROOT=%USERPROFILE%\Documents\Skola" uloží cestu. %USERPROFILE% je složka účtu, u každého jiná.
+- mkdir "%ROOT%\1-rocnik\word" založí i mezistupně, když je napíšete takto v nových Windows. Když ne, založte nejdřív 1-rocnik a pak word.
+- Uvozovky kolem cesty jsou povinné v okamžiku, kdy v cestě je mezera. V Documents mezera není, v názvu účtu být může.
+
+> Tip: echo Hotovo vypíše větu. pause nechá okno otevřené, ať výsledek přečtete. Bez pause dvojklik okno hned zavře.
+
+## Co se nepouští
+*del*
+
+- del smaže soubor. Ve školním bat na založení složek žádný del není.
+- Příkaz, který maže podle masky celý disk, se nepíše ani z legrace. Obnova není Ctrl+Z.
+- cd /d přejde na jiný disk a složku. Než něco mažete, echo vypíše, kde jste. V tomto cvičení nemažete.
+- Bat, kterému nerozumíte řádek po řádku, nespouštějte. Krátký soubor se čte celý.
+
+> Tip: Když chcete zkusit del, zkuste ho na jeden soubor test.txt, který jste sami právě vytvořili. Ne na složku Windows.
+
+## Zadání: slozky.bat
+*Cvičení*
+
+- slozky.bat nastaví ROOT na %USERPROFILE%\Documents\Skola a založí 1-rocnik\word, 1-rocnik\excel a 1-rocnik\system.
+- Na konci je echo s cestou a pause. Není tam del.
+- Spusťte ho jednou. Složky v Průzkumníku opravdu jsou.
+- Spusťte ho podruhé. Smí ohlásit, že složka už existuje. Nemá nic smazat.
+
+> Tip: Úprava bat je v Poznámkovém bloku, ne ve Wordu. Word do něj doplní formátování a příkazový řádek pak řádek nepochopí.
+
+# Kvíz
+
+## Co má být první řádek školního bat?
+- del C:\
+- [x] @echo off
+- formát disku
+- heslo účtu
+> @echo off jen ztiší opisování příkazů. Nic nemaže.
+
+## Co dělá mkdir?
+- Smaže složku
+- [x] Založí složku
+- Otevře Word
+- Změní heslo
+> mkdir je make directory. Mazání je jiný příkaz a v tomto cvičení není.
+
+## Kam ukazuje %USERPROFILE%?
+- Na stejnou složku na všech účtech počítače
+- [x] Na profil přihlášeného účtu
+- Na web WordPressu
+- Na tabulku tridy
+> Každý účet má vlastní profil. Stejný bat tedy založí Skola v Dokumentech toho, kdo ho spustil.
+
+## Který bat ve cvičení nespouštíte?
+- Ten, který jen zakládá složky a na konci čeká
+- [x] Neznámý soubor z mailu
+- slozky.bat, který jste sami přečetli
+- Soubor, který ohlásí, že složka už existuje
+> Dávka je program. Z mailu se nespouští, stejně jako soubor s příponou exe.
diff --git a/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.en.md b/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.en.md
new file mode 100644
index 0000000..840ca87
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Users and groups in Windows
+popis: The zaci account is in Users. It is not in Administrators.
+nazev_cviceni: Quiz: groups
+nadpis_kvizu: Put the account in the right group
+---
diff --git a/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.md b/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.md
new file mode 100644
index 0000000..75a3c74
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/06-uzivatele-a-skupiny.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-uzivatele
+nazev: Uživatelé a skupiny ve Windows
+popis: Účet zaci je ve Users. Ve skupině Administrators není.
+nazev_cviceni: Kvíz: skupiny
+nadpis_kvizu: Zařaďte účet do skupiny
+---
+
+# Výklad
+
+## Skupina
+*Práva najednou*
+
+- Skupina sdružuje účty, které mají mít stejná práva. Neměníte právo každému účtu zvlášť po jednom souboru.
+- Users jsou běžní uživatelé. Administrators smí měnit systém a instalovat.
+- Účet zaci patří do Users. Do Administrators ne.
+- Nový účet je často v Users sám. Práce navíc je nestrkat ho mezi správce, ne dopisovat Users.
+
+> Tip: Kdo je správce, vypíše net localgroup Administrators. Jméno zaci v tom seznamu být nemá.
+
+## Příkazy
+*Bez hesla v řádku*
+
+- net user zaci * /add účet založí a na heslo se zeptá. Hvězdička znamená dotaz, ne heslo zapsané do bat.
+- net user zaci vypíše účet. Je tam vidět, jestli je aktivní.
+- net localgroup Users zaci /add ho přidá do Users, kdyby tam nebyl.
+- net localgroup Administrators zaci /delete ho ze správců odebere, pokud tam omylem je. Příkaz chce spustit správce.
+
+> Tip: lusrmgr.msc otevře stejné účty v okně, ale jen ve Windows Pro, Education a Enterprise. V edici Home toto okno není a zůstává net user.
+
+## Kdo příkaz spouští
+*Správce*
+
+- Běžný uživatel účet nezaloží. Okno se musí spustit jako správce, nebo systém požádá o souhlas.
+- Souhlas správce není důvod založit účet rovnou jako správce. zaci zůstává běžný.
+- whoami na účtu zaci nesmí skončit na Administrators.
+- Cvičný účet se po hodině dá odebrat: net user zaci /delete. Nejdřív si ověřte, že nemaže účet učitele.
+
+> Tip: Jméno účtu zaci je cvičné. Účet, který se jmenuje jako učitel nebo správce, se nedeaktivuje a nemaže.
+
+## Zadání: zápis, ne cizí účty
+*Cvičení*
+
+- Do ucty.txt napište příkaz, kterým se zaci založí, včetně hvězdičky místo hesla.
+- Napište, v které skupině má být a ve které být nemá.
+- Pokud učitel dovolí, účet opravdu založte a net localgroup Administrators opište. zaci v seznamu není.
+- Heslo do ucty.txt nepište. Cizí účty na počítači neměňte.
+
+> Tip: Když lusrmgr.msc hlásí, že neexistuje, máte edici Home. Použijte net user a do zápisu napište, že grafické okno v této edici není.
+
+# Kvíz
+
+## Do které skupiny patří zaci?
+- Administrators
+- [x] Users
+- Do žádné, účet bez skupiny smí instalovat
+- Do skupiny hesel
+> Běžná práce je skupina Users. Správci jsou Administrators.
+
+## Jak se účet založí, aniž by heslo zůstalo v příkazu?
+- [x] net user zaci * /add
+- net user zaci heslo123 /add
+- mkdir zaci
+- DELETE FROM zaci
+> Hvězdička se zeptá. Heslo napsané v příkazu zůstane v historii.
+
+## Kde lusrmgr.msc není?
+- Ve Windows Pro
+- [x] Ve Windows Home
+- Ve Windows Education
+- Ve Windows Enterprise
+> Grafická správa účtů je v Pro, Education a Enterprise. Home ji nemá.
+
+## Co whoami nesmí ukázat při denní práci na účtu zaci?
+- Že jde o běžného uživatele
+- [x] Že účet je ve skupině Administrators
+- Název počítače
+- To, že účet existuje
+> Denní účet není správce. whoami to prozradí dřív, než něco nainstalujete.
diff --git a/content/programove-vybaveni/4-rocnik/07-zasady.en.md b/content/programove-vybaveni/4-rocnik/07-zasady.en.md
new file mode 100644
index 0000000..a14b512
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/07-zasady.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Local policy
+popis: A minimum password of 8 characters and a lock after 300 seconds. A school domain overrides the local policy.
+nazev_cviceni: Quiz: policy
+nadpis_kvizu: Fill in the value and the command
+---
diff --git a/content/programove-vybaveni/4-rocnik/07-zasady.md b/content/programove-vybaveni/4-rocnik/07-zasady.md
new file mode 100644
index 0000000..f850445
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/07-zasady.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-zasady
+nazev: Místní zásady
+popis: Minimální heslo 8 znaků a zámek po 300 sekundách. Doména školy místní zásadu přebije.
+nazev_cviceni: Kvíz: zásady
+nadpis_kvizu: Doplňte hodnotu a příkaz
+---
+
+# Výklad
+
+## Kde se zásady berou
+*gpedit*
+
+- Místní zásady otevře gpedit.msc. Je ve Windows Pro, Education a Enterprise, v edici Home není.
+- Konfigurace počítače platí pro stroj. Konfigurace uživatele platí pro účty, které se přihlásí.
+- Školní cvičení mění dvě věci: délku hesla a zámek při nečinnosti. Ostatní zásady nechte být.
+- Když je počítač v doméně školy, doménová zásada vyhraje nad místní. Místní změna se pak jakoby ztratí a není to chyba klikání.
+
+> Tip: Než něco změníte, napište si původní hodnotu. Vrátit Neznámé číslo zpět nejde, když jste si ho nezapsali.
+
+## Heslo
+*Osm znaků*
+
+- Cesta vede přes Konfigurace počítače, Nastavení systému Windows, Nastavení zabezpečení, Zásady účtů, Zásady hesel.
+- Minimální délka hesla se nastaví na 8.
+- Nula znamená, že heslo smí být prázdné. To školní účet nechce.
+- Zásada neopraví heslo, které už je krátké, dokud ho uživatel příště nemění. Platí pro nová hesla.
+
+> Tip: Heslo v zásadě nenastavujete. Nastavujete pravidlo. Samotné heslo účtu zaci se zadává při net user, ne tady.
+
+## Zámek
+*300 sekund*
+
+- Nečinnost: Konfigurace počítače, Nastavení systému Windows, Nastavení zabezpečení, Místní zásady, Možnosti zabezpečení.
+- Položka Interaktivní přihlášení: limit nečinnosti počítače se nastaví na 300 sekund, tedy 5 minut.
+- Nula nebo nenastaveno znamená, že tato zásada zámek nevynucuje.
+- Po pěti minutách bez myši a klávesnice se má počítač zamknout, ne vypnout a ne smazat Dokumenty.
+
+> Tip: Zámek je Win+L hned. Zásada totéž udělá sama, když odejdete od stroje a zapomenete.
+
+## Zadání: dvě hodnoty
+*Cvičení*
+
+- Pokud gpedit.msc máte, nastavte minimální délku hesla na 8 a limit nečinnosti na 300 sekund. Původní hodnoty si zapište.
+- gpupdate /force zásady načte hned, bez čekání na restart. Příkaz chce okno spuštěné jako správce.
+- Do zasady.txt napište obě hodnoty a jestli je počítač v doméně. Heslo nepište.
+- V edici Home napište, že gpedit.msc v ní není, a hodnoty jen popište. Nevymýšlejte, že jste je nastavili.
+
+> Tip: Když po gpupdate hodnota v gpedit zůstane a systém se nechová podle ní, počítač je v doméně a doména místní číslo přebíjí.
+
+# Kvíz
+
+## Jaká je školní minimální délka hesla?
+- 0
+- [x] 8
+- 300
+- Prázdné heslo
+> Osm znaků je pravidlo z první hodiny o účtech. Nula by prázdné heslo dovolila.
+
+## Na kolik sekund se nastaví limit nečinnosti?
+- 5
+- [x] 300
+- 8
+- Na nulu, ať se počítač nezamyká
+> 300 sekund je pět minut. Číslo 5 by bylo pět sekund, to v této položce není.
+
+## Čím se místní zásady načtou hned?
+- Smazáním účtu zaci
+- [x] gpupdate /force
+- mkdir
+- SELECT * FROM zaci
+> gpupdate zásady znovu načte. Bez něj si počítač může počkat na své vlastní obnovení.
+
+## Co platí, když je počítač v doméně školy a místní heslo nastavíte jinak?
+- Vždy vyhraje místní gpedit
+- [x] Vyhraje doménová zásada
+- Hesla se vypnou
+- WordPress přestane běžet
+> Doména je nad místním počítačem. Místní změna, která jí odporuje, se neuplatní.
diff --git a/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.en.md b/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.en.md
new file mode 100644
index 0000000..fdca7fe
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Exam assignment
+popis: Folders, a letter, grades, a register, a site, a report and an account with a password rule.
+nazev_cviceni: Quiz: the whole assignment
+nadpis_kvizu: Walk the hand-in
+---
diff --git a/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.md b/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.md
new file mode 100644
index 0000000..ef404a3
--- /dev/null
+++ b/content/programove-vybaveni/4-rocnik/08-maturitni-zadani.md
@@ -0,0 +1,79 @@
+---
+id: sw-4-maturita
+nazev: Maturitní zadání
+popis: Složky, dopis, známky, evidence, web, sestava a účet s zásadou hesla.
+nazev_cviceni: Kvíz: celé zadání
+nadpis_kvizu: Projdi odevzdání
+---
+
+# Výklad
+
+## Systém
+*Složky a účet*
+
+- slozky.bat založí Skola\1-rocnik\word, excel a system v Dokumentech toho účtu, který ho spustí.
+- V bat není del a není v něm heslo. Je v něm @echo off, mkdir a pause.
+- Účet zaci je běžný uživatel ve skupině Users, není v Administrators.
+- Minimální délka hesla je 8 znaků. Zámek po nečinnosti je 300 sekund, pokud edice Windows zásady má.
+
+> Tip: Do textového souboru patří příkazy a hodnoty. Skutečné heslo ne.
+
+## Office
+*Dopis a známky*
+
+- zadost.docx je jedna strana, datum 12. 6. 2026, řádek Věc, k tomu PDF.
+- znamky.xlsx počítá stav vzorcem KDYŽ. Známka 5 je neprospěl, 1 až 4 prospěl.
+- Pod známkami je průměr, ne součet.
+- prodej.xlsx a prodej.pbix: tabulka načtená jednou, průřez třídy, PDF bez výběru, ať jsou vidět obě třídy.
+
+> Tip: pbix bez xlsx zdroje učitel neobnoví. Xlsx vedle pbix nechte.
+
+## Data a web
+*Evidence a Školní den*
+
+- Access nebo MySQL: tabulky tridy a zaci, vztah 1:N, Anna Nováková ve třídě 1.A.
+- Dotaz ukáže název 1.A, ne jen číslo. UPDATE a DELETE, pokud jsou v SQL, mají WHERE.
+- WordPress: název Školní den, stránka O škole, příspěvek s datem, účet autora. Správce je jeden.
+- V textech o webu a účtech není heslo.
+
+> Tip: Stačí jeden databázový nástroj, ale celý. Access s integritou, nebo SQL s JOINem.
+
+## Složka odevzdání
+*Pořádek*
+
+- Všechno leží pod Skola, ne na ploše a ne v Stažených.
+- Přípony jsou vidět. Názvy nemají final2.
+- Na obhajobu otevřete nejdřív bat a složky, pak Excel a Power BI, pak databázi a nakonec web.
+- U každé části umíte říct, kdo smí heslo, kdo smí instalovat a proč pětka není prospěl.
+
+> Tip: Když se zeptají, proč zaci není správce, odpověď je: denní účet nemá právo měnit systém.
+
+# Kvíz
+
+## Co slozky.bat nesmí obsahovat?
+- mkdir
+- @echo off
+- [x] del přes celý disk nebo cizí složky
+- pause
+> Cvičení složky zakládá. Mazání do něj nepatří a neznámé mazání už vůbec ne.
+
+## Jaký je stav známky 5?
+- prospěl
+- [x] neprospěl
+- průměr
+- 1.A
+> Prochází 1 až 4. Pětka je neprospěl. Rozhoduje vzorec KDYŽ, ne rukou psaný text.
+
+## Jakou roli má účet, který ve WordPressu jen píše novinky?
+- Správce
+- [x] Autor
+- Jediný sdílený účet učitele
+- Administrators ve Windows
+> Autor zveřejní vlastní příspěvek. Doplňky a uživatele spravuje jeden správce.
+
+## Jaké jsou dvě hodnoty zásad?
+- Heslo 0 znaků a zámek 8 sekund
+- [x] Heslo aspoň 8 znaků a zámek po 300 sekundách
+- Heslo v souboru bat a zámek vypnutý
+- 300 znaků hesla a 8 sekund
+> Osm znaků je délka hesla. 300 sekund je pět minut nečinnosti.
diff --git a/content/technicka-grafika/1-rocnik/01-format-a-meritko.en.md b/content/technicka-grafika/1-rocnik/01-format-a-meritko.en.md
new file mode 100644
index 0000000..2de20d7
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/01-format-a-meritko.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Sheet size, scale and the title block
+popis: A sizes, drawing scale and the title block.
+nazev_cviceni: Quiz: sheet and scale
+nadpis_kvizu: Check the sheet, the scale and the title block
+---
diff --git a/content/technicka-grafika/1-rocnik/01-format-a-meritko.md b/content/technicka-grafika/1-rocnik/01-format-a-meritko.md
new file mode 100644
index 0000000..2ef74ba
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/01-format-a-meritko.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-format-a-meritko
+nazev: Formát, měřítko a razítko
+popis: Řada A, měřítko výkresu a popisové pole.
+nazev_cviceni: Kvíz: formát a měřítko
+nadpis_kvizu: Ověřte si formát, měřítko a razítko
+---
+
+# Výklad
+
+## Řada formátů A
+*Papír*
+
+- Základní formát A4 má 210 × 297 mm. Delší strana je na výšku.
+- A3 vznikne složením dvou A4: 297 × 420 mm.
+- A2 je 420 × 594 mm. Každý větší formát má dvojnásobnou plochu.
+- Na škole se součást kreslí nejčastěji na A4 nebo A3.
+
+> Tip: kratší strana většího formátu je delší stranou menšího. A3 je na šířku, A4 na výšku.
+
+## Co znamená měřítko
+*Měřítko*
+
+- Měřítko 1:1 znamená, že výkres je ve skutečné velikosti.
+- Měřítko 1:2 zmenšuje kresbu na polovinu. Měřítko 2:1 ji zvětšuje na dvojnásobek.
+- Kóta se vždy píše ve skutečném rozměru, ne v rozměru nakreslené čáry.
+- Do razítka se měřítko zapíše tak, jak se kreslilo, třeba 1:2.
+
+> Tip: díra o průměru 20 mm má na výkresu 1:2 čáru dlouhou 10 mm, ale kóta říká 20.
+
+## Popisové pole
+*Razítko*
+
+- Razítko je v pravém dolním rohu. Čte se zprava, když je výkres ve správné poloze.
+- Patří do něj název, měřítko, materiál, datum a jméno.
+- Číslo výkresu a název součásti se nesmí vyměnit za ozdobný text.
+- Rámeček je od okraje papíru odsazený, na školním A4 obvykle o 5 až 10 mm.
+
+> Tip: než začnete kreslit součást, vyhraďte si hladinu pro rámeček a razítko.
+
+## Zadání: rámeček A4
+*Cvičení*
+
+- Nakreslete obdélník 210 × 297 a dovnitř rámeček odsazený o 10 mm od každého okraje.
+- Vpravo dole vyhraďte razítko 180 × 40 mm, které lícuje s vnitřním rámečkem.
+- Do razítka vepište název Destička, měřítko 1:1 a své jméno.
+- Soubor uložte jako A4-ramecek.dwg, jednotky milimetry.
+
+> Tip: vnitřní rámeček má 190 × 277 mm. Levý dolní roh dejte do bodu 0,0.
+
+
+# Kvíz
+
+## Jaké rozměry má formát A4?
+- 297 × 420 mm
+- [x] 210 × 297 mm
+- 420 × 594 mm
+- 148 × 210 mm
+> A3 je 297 × 420 mm. A4 je polovina této plochy, 210 × 297 mm.
+
+## Co se stane s kótou na výkresu v měřítku 1:2?
+- Píše se polovina skutečného rozměru
+- [x] Píše se skutečný rozměr
+- Kóty se vynechají
+- Měřítko se do razítka nepíše
+> Kresba je poloviční, číslo na kótě zůstává skutečné. Měřítko 1:2 je v razítku.
+
+## Kam se na výkresu umísťuje razítko?
+- Doprostřed plochy
+- [x] Do pravého dolního rohu
+- Nad součást, vlevo
+- Mimo rámeček
+> Razítko lícuje s vnitřním rámečkem vpravo dole, aby šlo výkres složit a razítko zůstalo navrchu.
+
+## Jak je velký vnitřní rámeček A4 při odsazení 10 mm?
+- 210 × 297 mm
+- [x] 190 × 277 mm
+- 200 × 287 mm
+- 180 × 40 mm
+> Od 210 i od 297 se odečte 10 mm z každé strany, tedy 20 mm. 190 × 277 mm.
diff --git a/content/technicka-grafika/1-rocnik/02-druhy-car.en.md b/content/technicka-grafika/1-rocnik/02-druhy-car.en.md
new file mode 100644
index 0000000..afc6ade
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/02-druhy-car.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Line types and line weights
+popis: Outlines, hidden edges, centrelines and dimensions.
+nazev_cviceni: Quiz: lines
+nadpis_kvizu: Name the line
+---
diff --git a/content/technicka-grafika/1-rocnik/02-druhy-car.md b/content/technicka-grafika/1-rocnik/02-druhy-car.md
new file mode 100644
index 0000000..542e176
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/02-druhy-car.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-druhy-car
+nazev: Druhy a tloušťky čar
+popis: Obrys, skryté hrany, osy a kóty podle normy.
+nazev_cviceni: Kvíz: čáry
+nadpis_kvizu: Poznejte druh čáry
+---
+
+# Výklad
+
+## Tlustá plná čára
+*Obrys*
+
+- Viditelné hrany a obrysy se kreslí souvislou tlustou čarou.
+- V AutoCADu jí patří vlastní hladina, třeba OBRYS, s tloušťkou 0,5 mm.
+- Tloušťka se nastavuje na hladině, ne u každé čáry zvlášť.
+- Obrys nesmí být stejně tenký jako kóta, jinak se výkres nedá číst.
+
+> Tip: hladinu OBRYS nastavte jako aktuální dřív, než začnete kreslit tvar.
+
+## Tenké čáry
+*Kóty a hrany*
+
+- Kóty, odkazy, šrafy a pomocné čáry jsou souvislé a tenké, kolem 0,25 mm.
+- Skryté hrany, které z daného pohledu nejsou vidět, jsou čárkované a tenké.
+- Osa díry nebo válce je čerchovaná: dlouhá čárka, tečka, dlouhá čárka.
+- Osa přesahuje obrys jen o kousek, nepřetahuje se přes celý výkres.
+
+> Tip: čárkovaná čára je skrytá hrana. Čerchovaná je osa. Nejsou totéž.
+
+## Jak to nastavit v AutoCADu
+*Hladiny*
+
+- Příkaz `_LAYER` otevře správce hladin. Založte OBRYS, SKRYTE, OSA a KOTA.
+- U SKRYTE zvolte typ čáry DASHED, u OSA typ CENTER.
+- Typ čáry se načítá příkazem `_LINETYPE`. Bez načtení ho hladina nenabídne.
+- Měřítko typu čáry `_LTSCALE` zvětší nebo zmenší mezery v čárkování, ať je čára čitelná.
+
+> Tip: když čárkování vypadá jako plná čára, je LTSCALE moc velké nebo moc malé.
+
+## Zadání: tři čáry jedné díry
+*Cvičení*
+
+- Nakreslete obdélník 60 × 40 tlustou plnou čarou.
+- Svislou osu veďte středem a nechte ji přesáhnout obrys o 3 mm na každou stranu.
+- Jednu zadní hranu, která z pohledu není vidět, nakreslete tenkou čárkovanou čarou.
+- Každý druh čáry musí ležet na své hladině.
+
+> Tip: osa dlouhá 46 mm při výšce tělesa 40 mm přesahuje o 3 mm nahoře i dole.
+
+
+# Kvíz
+
+## Jakou čarou se kreslí viditelný obrys?
+- Tenkou čárkovanou
+- [x] Tlustou plnou
+- Tenkou čerchovanou
+- Tlustou čerchovanou
+> Viditelné hrany jsou souvislé a tlustší než kóty, osy i skryté hrany.
+
+## Co znamená tenká čárkovaná čára?
+- Osu díry
+- [x] Skrytou hranu
+- Obrys
+- Šrafu
+> Čárkovaná čára je hrana, kterou v daném pohledu nevidíme. Osa je čerchovaná.
+
+## Jakou čarou se kreslí osa válce?
+- Tlustou plnou
+- Tenkou čárkovanou
+- [x] Tenkou čerchovanou
+- Vlnovkou
+> Osa je dlouhá čárka, tečka, dlouhá čára. V AutoCADu je to typ CENTER.
+
+## Proč má mít obrys vlastní hladinu?
+- Aby šel smazat jen razítko
+- [x] Aby měla tloušťka a typ čáry jedno místo
+- Hladiny v AutoCADu nejsou
+- Obrys se kreslí jen od ruky
+> Tloušťka i typ se nastaví na hladině. Všechny obrysy je pak mají stejné.
diff --git a/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.en.md b/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.en.md
new file mode 100644
index 0000000..2d39a07
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: The AutoCAD workspace
+popis: Model space, the command line, units and limits.
+nazev_cviceni: Quiz: the workspace
+nadpis_kvizu: Check the first drawing setup
+---
diff --git a/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.md b/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.md
new file mode 100644
index 0000000..7a16b82
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/03-prostredi-autocadu.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-prostredi
+nazev: Prostředí AutoCADu
+popis: Model, příkazový řádek, jednotky a meze.
+nazev_cviceni: Kvíz: prostředí
+nadpis_kvizu: Ověřte si první nastavení výkresu
+---
+
+# Výklad
+
+## Kam se kreslí
+*Model*
+
+- Součást se kreslí v modelu. Papír, rámeček a výřezy patří až do rozvržení.
+- Příkazový řádek je dole. Čte se pořád: AutoCAD se ptá na další bod.
+- Stisk Enter nebo mezerník zopakuje poslední příkaz.
+- Esc příkaz zruší. Neukládejte rozpracovaný příkaz klávesou Esc uprostřed bodu.
+
+> Tip: když AutoCAD „nic nedělá“, podívejte se, na co se příkazový řádek ptá.
+
+## Příkaz, který funguje vždy
+*Podtržítko*
+
+- Český AutoCAD má přeložené příkazy. Úsečka se jmenuje ÚSEČKA.
+- Mezinárodní jméno se píše s podtržítkem: `_LINE`. Funguje v každé jazykové verzi.
+- Na pásu karet je stejný příkaz pod ikonou. Ikona i text dělají totéž.
+- V těchto lekcích je u každého kroku mezinárodní příkaz. Naučte se ho psát.
+
+> Tip: `_LINE` a ÚSEČKA spustí stejné kreslení. Podtržítko se hodí, když čeština na klávesnici zlobí.
+
+## Jednotky a meze
+*Milimetry*
+
+- `_UNITS` nastavte na desetinné milimetry. Úhly nechte ve stupních.
+- Přesnost 0 stačí na celé milimetry, 0,0 když budete potřebovat desetinu.
+- `_LIMITS` určí levý dolní a pravý horní roh kreslicí plochy, třeba 0,0 a 420,297.
+- `_ZOOM` a volba All ukáže celé meze. Kolečko myši přibližuje ke kurzoru.
+
+> Tip: nový výkres začněte jednotkami. Přepočet z palců na milimetry uprostřed práce nic nespraví.
+
+## Zadání: prázdný výkres A3
+*Cvičení*
+
+- Založte soubor v milimetrech a nastavte meze 0,0 až 420,297.
+- Příkazem `_RECTANG` nakreslete obrys papíru z bodu 0,0 do bodu 420,297.
+- Přibližte vše příkazem `_ZOOM` a volbou Extents.
+- Uložte soubor jako A3-prazdny.dwg.
+
+> Tip: Extents přiblíží to, co je nakreslené. All přiblíží meze, i když jsou prázdné.
+
+
+# Kvíz
+
+## Kde se kreslí tvar součásti?
+- V rozvržení, přes razítko
+- [x] V modelu
+- Jen na pásu karet
+- V příkazu PLOT
+> Model je prostor součásti. Rozvržení je papír, na který se model později vloží výřezem.
+
+## Co udělá příkaz `_LINE` v českém AutoCADu?
+- Nastaví jednotky
+- [x] Spustí kreslení úsečky
+- Smaže poslední čáru
+- Otevře hladiny
+> Podtržítko volá mezinárodní příkaz. `_LINE` je úsečka, ať je AutoCAD v jakémkoli jazyce.
+
+## Jakou jednotku má školní výkres?
+- Palec
+- [x] Milimetr
+- Metr
+- Pixel
+> `_UNITS` nastavte na desetinné milimetry dřív, než zadáte první rozměr.
+
+## Co ukáže `_ZOOM` s volbou Extents?
+- Celé nastavené meze, i prázdné
+- [x] Všechno, co je nakreslené
+- Jen razítko
+- Poslední příkaz
+> Extents orámuje nakreslené objekty. All ukáže meze výkresu.
diff --git a/content/technicka-grafika/1-rocnik/04-souradnice.en.md b/content/technicka-grafika/1-rocnik/04-souradnice.en.md
new file mode 100644
index 0000000..95648e1
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/04-souradnice.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Coordinates
+popis: Absolute points, relative moves and polar entry.
+nazev_cviceni: Quiz: coordinates
+nadpis_kvizu: Find the next point
+---
diff --git a/content/technicka-grafika/1-rocnik/04-souradnice.md b/content/technicka-grafika/1-rocnik/04-souradnice.md
new file mode 100644
index 0000000..626ba0c
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/04-souradnice.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-souradnice
+nazev: Souřadnice
+popis: Absolutní bod, relativní posun a polární zápis.
+nazev_cviceni: Kvíz: souřadnice
+nadpis_kvizu: Dopočítejte další bod
+---
+
+# Výklad
+
+## Absolutní souřadnice
+*Od počátku*
+
+- Bod 40,25 leží 40 mm vpravo a 25 mm nahoru od počátku 0,0.
+- Čárka odděluje X a Y. Desetinná čárka v čísle se v AutoCADu píše tečkou: 10.5
+- Osa X míří vpravo, osa Y nahoru. Tak je natočený model při startu.
+- Absolutní bod nezávisí na tom, kde jste skončili minule.
+
+> Tip: když se výkres „rozjede“ mimo papír, někdo zadal absolutní bod místo posunu.
+
+## Relativní souřadnice
+*Zavináč*
+
+- `@30,0` znamená 30 mm vpravo od posledního bodu, výška se nemění.
+- `@0,40` je 40 mm nahoru. `@-20,0` je 20 mm doleva.
+- Zavináč se píše před čísla. Bez něj AutoCAD čte absolutní bod.
+- Při kreslení `_LINE` se tak skládá tvar bod za bodem.
+
+> Tip: obdélník 70 × 40 z bodu 10,10: `@70,0` pak `@0,40` pak `@-70,0` a zavřít volbou Close.
+
+## Polární souřadnice
+*Délka a úhel*
+
+- `@50<90` je úsečka dlouhá 50 mm směrem 90 stupňů, tedy kolmo nahoru.
+- Úhel se měří od kladné osy X proti směru hodinových ručiček.
+- `@40<0` míří vpravo, `@40<180` vlevo, `@40<270` dolů.
+- Špičatá závorka se píše bez mezery. Mezera by příkaz ukončila.
+
+> Tip: úhel 45 stupňů je šikmo vpravo nahoru. Délka je vždy skutečná, ne průmět.
+
+## Zadání: obdélník ze souřadnic
+*Cvičení*
+
+- Spusťte `_LINE` v bodě 20,20.
+- Pokračujte `@80,0`, potom `@0,50`, potom `@-80,0`.
+- Útvar zavřete volbou Close, ne ručním bodem.
+- Změřte `_DIST` mezi levým dolním a pravým horním rohem. Má vyjít 94,3 mm, tedy přepona 80 a 50.
+
+> Tip: Close spojí poslední bod s prvním. Ruční bod 20,20 nemusí trefit počátek a obrys zůstane otevřený.
+
+
+# Kvíz
+
+## Co znamená zápis `@0,40`?
+- Bod 0,40 od počátku výkresu
+- [x] 40 mm nahoru od posledního bodu
+- 40 mm vpravo od počátku
+- Úhel 40 stupňů
+> Zavináč čte posun od posledního bodu. Nula v X a 40 v Y je čistě nahoru.
+
+## Kam míří `@60<180`?
+- 60 mm nahoru
+- [x] 60 mm doleva
+- 180 mm vpravo
+- Do počátku
+> Úhel 180 stupňů je záporná osa X, tedy doleva. 60 je délka úsečky.
+
+## Jak AutoCAD pozná desetinné číslo?
+- Čárkou: 10,5,0
+- [x] Tečkou: 10.5
+- Zlomkem 21/2
+- Mezerou 10 5
+> Čárka odděluje X a Y. Desetina se píše tečkou, třeba 10.5,20.
+
+## Proč útvar zavřít volbou Close?
+- Aby se smazala první úsečka
+- [x] Aby se poslední bod trefil do prvního
+- Close nastaví měřítko
+- Close je totéž co Esc
+> Ručně opsaný první bod často mine o zlomek milimetru a obrys nejde vyšrafovat.
diff --git a/content/technicka-grafika/1-rocnik/05-zakladni-entity.en.md b/content/technicka-grafika/1-rocnik/05-zakladni-entity.en.md
new file mode 100644
index 0000000..2ca03d0
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/05-zakladni-entity.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Lines, circles, arcs and rectangles
+popis: The four commands every part starts with.
+nazev_cviceni: Quiz: basic objects
+nadpis_kvizu: Pick the right command
+---
diff --git a/content/technicka-grafika/1-rocnik/05-zakladni-entity.md b/content/technicka-grafika/1-rocnik/05-zakladni-entity.md
new file mode 100644
index 0000000..e41ea6b
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/05-zakladni-entity.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-zakladni-entity
+nazev: Úsečka, kružnice, oblouk a obdélník
+popis: Čtyři příkazy, kterými začíná každá součást.
+nazev_cviceni: Kvíz: základní entity
+nadpis_kvizu: Vyberte správný příkaz
+---
+
+# Výklad
+
+## Úsečka a obdélník
+*Hrany*
+
+- `_LINE` chce první a další bod. Končí se Enterem, zavření obrysu volbou Close.
+- `_RECTANG` chce dva protilehlé rohy. Z bodu 0,0 a `@90,40` vznikne obdélník 90 × 40.
+- Obdélník je jedna uzavřená křivka. Úsečky jsou samostatné objekty.
+- Uzavřená křivka se později líp šrafuje a vysouvá do 3D.
+
+> Tip: když budete tvar vysouvat, kreslete ho jako `_RECTANG` nebo uzavřenou `_PLINE`, ne jako čtyři volné úsečky.
+
+## Kružnice
+*Díry*
+
+- `_CIRCLE` se nejčastěji zadá středem a poloměrem.
+- Volba D přepne na průměr. U díry Ø12 je průměr 12 a poloměr 6.
+- Střed díry se kótuje, ne její okraj. Okraj je důsledek průměru.
+- Víc stejných děr se nekreslí znovu: první se zkopíruje nebo rozloží do pole.
+
+> Tip: v zadání Ø znamená průměr. Do příkazu s poloměrem dáte polovinu.
+
+## Oblouk
+*Zaoblení z příkazu*
+
+- `_ARC` umí oblouk třemi body nebo středem, počátkem a úhlem.
+- Zaoblení rohu ale nedělejte obloukem od ruky. Na to je později `_FILLET`.
+- Oblouk má směr. Špatné pořadí bodů ho vyklenu na druhou stranu.
+- U drážky se oblouk často nahradí `_PLINE` s volbou Arc uprostřed úseček.
+
+> Tip: tři body oblouku zadávejte v pořadí, v jakém oblouk plyne. Prostřední bod je na oblouku, ne střed kružnice.
+
+## Zadání: destička s dírou
+*Cvičení*
+
+- `_RECTANG` z 0,0 do `@90,50`.
+- Střed destičky je 45,25. Tam `_CIRCLE`, volba D, průměr 16.
+- Osu svislou i vodorovnou nechte přesáhnout obrys o 3 mm. Patří na hladinu OSA.
+- Uložte jako desticka-90x50.dwg.
+
+> Tip: polovina z 90 je 45 a polovina z 50 je 25. Střed neměřte od oka.
+
+
+# Kvíz
+
+## Čím nakreslíte obdélník 90 × 40 jedním objektem?
+- Čtyřmi úsečkami `_LINE`
+- [x] `_RECTANG` a druhým rohem `@90,40`
+- `_CIRCLE` s poloměrem 90
+- `_ARC` o úhlu 90
+> `_RECTANG` chce dva rohy. Relativní `@90,40` je šířka a výška od prvního rohu.
+
+## Zadání říká díra Ø12. Jaký poloměr má kružnice?
+- 12
+- [x] 6
+- 24
+- 3
+> Průměr je 12, poloměr je polovina. V `_CIRCLE` buď poloměr 6, nebo volba D a průměr 12.
+
+## Kde leží střed destičky 90 × 50, která začíná v 0,0?
+- 90,50
+- [x] 45,25
+- 0,0
+- 25,45
+> Střed je v polovině šířky a polovině výšky.
+
+## Kdy dát přednost uzavřené křivce před čtyřmi úsečkami?
+- Nikdy, úsečka je přesnější
+- [x] Když se obrys bude šrafovat nebo vysouvat
+- Jen u razítka
+- Když je tvar otevřený
+> Šrafa i vysunutí potřebují uzavřenou oblast. Čtyři úsečky, které se netrefí, oblast neutvoří.
diff --git a/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.en.md b/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.en.md
new file mode 100644
index 0000000..08596b5
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Snaps, ortho and polar tracking
+popis: How to hit an endpoint and draw square.
+nazev_cviceni: Quiz: precise drawing
+nadpis_kvizu: Pick the snap and the toggle
+---
diff --git a/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.md b/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.md
new file mode 100644
index 0000000..c9f7f0e
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/06-uchopeni-a-orto.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-uchopeni
+nazev: Úchop, orto a polární trasování
+popis: Jak trefit konec čáry a kreslit kolmo.
+nazev_cviceni: Kvíz: přesné kreslení
+nadpis_kvizu: Vyberte úchop a přepínač
+---
+
+# Výklad
+
+## Úchopy
+*Koncový bod*
+
+- Úchop přitáhne kurzor ke geometrii. Bez něj minuta minete konec čáry.
+- Zapíná se klávesou F3 nebo v stavovém řádku. Nastavení je `_OSNAP`.
+- Na začátek stačí koncový bod, střed a střed kružnice.
+- Kolmice a tečna se hodí, až budete napojovat čáru na kružnici.
+
+> Tip: úchop Střed u úsečky je její polovina. Střed u kružnice je její osa. Jsou to dva různé úchopy.
+
+## Orto a polární úhel
+*F8 a F10*
+
+- Orto, klávesa F8, pustí kurzor jen vodorovně a svisle.
+- Polární trasování F10 drží i šikmé úhly, obvykle po 45 nebo po 15 stupních.
+- Orto a volné šikmé kreslení nejdou dohromady. Na šikmou čáru orto vypněte.
+- Délku při zapnutém ortu napište jen číslem a Enter. Směr určuje myš.
+
+> Tip: vodorovná čára dlouhá 40 mm je F8, kurzor doprava, napsat 40 a Enter. Nemusíte počítat druhý bod.
+
+## Proč se výkres rozpadne
+*Minutí*
+
+- Dvě čáry, které se „téměř“ dotýkají, nejdou oříznout ani vyšrafovat.
+- Mezera vznikne, když kliknete vedle koncového bodu a úchop je vypnutý.
+- `_ZOOM` na roh ukáže mezeru, která na celém výkresu není vidět.
+- Opravený roh se napojí úchopem, ne posunem od oka.
+
+> Tip: než odevzdáte výkres, přibližte každý roh. Mezera menší než milimetr na tisku vynikne.
+
+## Zadání: kříž přesně ve středu
+*Cvičení*
+
+- Nakreslete obdélník 80 × 60 z bodu 0,0.
+- Svislou osu veďte z úchopu Střed dolní hrany do úchopu Střed horní hrany.
+- Vodorovnou osu veďte ze Středu levé hrany do Středu pravé.
+- Osy se mají protnout v bodě 40,30. Ověřte to `_ID`.
+
+> Tip: `_ID` vypíše souřadnice úchopu. Když průsečík není 40,30, jedna osa minula střed hrany.
+
+
+# Kvíz
+
+## Která klávesa zapíná úchopy?
+- F8
+- [x] F3
+- F10
+- Esc
+> F3 jsou úchopy, F8 je orto, F10 je polární trasování.
+
+## Co dělá orto?
+- Chytá konce čar
+- [x] Drží kreslení ve vodorovném a svislém směru
+- Nastaví milimetry
+- Maže poslední úsečku
+> Se zapnutým ortem kurzor neuhne do šikma. Délku pak stačí napsat číslem.
+
+## Proč dvě čáry, které se na obrazovce dotýkají, nejdou vyšrafovat?
+- Šrafa vyžaduje kóty
+- [x] Mezi nimi je mezera, konec minul úchop
+- Hladina OSA to zakazuje
+- Orto je zapnuté
+> Od oka minutý konec nechá škvíru. Přiblížení a úchop Koncový bod ji ukážou.
+
+## Který úchop najde polovinu úsečky?
+- Střed kružnice
+- [x] Střed
+- Koncový bod
+- Kolmice
+> Úchop Střed na úsečce je její polovina. Na kružnici je to jiný úchop, střed kružnice.
diff --git a/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.en.md b/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.en.md
new file mode 100644
index 0000000..0409703
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Move, copy, rotate and mirror
+popis: Change a finished shape instead of drawing it again.
+nazev_cviceni: Quiz: editing
+nadpis_kvizu: Match the command to the task
+---
diff --git a/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.md b/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.md
new file mode 100644
index 0000000..611ca1a
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/07-posun-kopie-otoceni.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-posun-kopie
+nazev: Posun, kopie, otočení a zrcadlení
+popis: Úprava hotového tvaru místo kreslení znovu.
+nazev_cviceni: Kvíz: úpravy
+nadpis_kvizu: Vyberte příkaz podle zadání
+---
+
+# Výklad
+
+## Posun a kopie
+*Z místa na místo*
+
+- `_MOVE` objekt odnese. Na původním místě nic nezůstane.
+- `_COPY` nechá vzor a přidá další. Ptá se na základní bod a pak na nové body.
+- Základní bod vyberte úchopem, třeba roh nebo střed. Od oka se kopie rozjede.
+- Víc kopií za sebou vznikne opakovaným zadáním dalšího bodu, bez nového příkazu.
+
+> Tip: posun o 30 mm doprava je základní bod kdekoli, druhý bod `@30,0`.
+
+## Otočení
+*Kolem bodu*
+
+- `_ROTATE` chce základní bod a úhel. Kladný úhel je proti směru hodin.
+- 90 otočí doleva, -90 nebo 270 doprava.
+- Volba Reference vezme současný úhel hrany a řekne, jaký má být nový.
+- Otáčí se kolem vybraného bodu, ne kolem středu obrazovky.
+
+> Tip: díru v rohu otočíte kolem středu destičky, ne kolem středu díry, jinak odjede z rozteče.
+
+## Zrcadlení
+*Souměrnost*
+
+- `_MIRROR` chce dva body osy zrcadlení. Osa nemusí být nakreslená předem.
+- Svislá osa jsou dva body se stejným X. Vodorovná dva body se stejným Y.
+- Na otázku, zda smazat zdroj, dejte Ne, když má zůstat obě poloviny.
+- Text se při zrcadlení může otočit naruby. Proměnná MIRRTEXT s hodnotou 0 nechá text čitelný.
+
+> Tip: souměrnou součást nakreslete z poloviny a zrcadlete. Druhou polovinu neobkreslujte.
+
+## Zadání: druhá díra zrcadlem
+*Cvičení*
+
+- Destička 100 × 40, levý dolní roh v 0,0.
+- Díru Ø10 dejte středem do bodu 20,20.
+- Zrcadlete ji přes svislou osu destičky. Osa prochází body 50,0 a 50,40.
+- Zdroj nemažte. Druhá díra má střed v 80,20.
+
+> Tip: 100 minus 20 je 80. Zrcadlo kolem x = 50 převede 20 na 80 a výšku 20 nechá.
+
+
+# Kvíz
+
+## Čím odnesete objekt, aby na původním místě nezůstal?
+- `_COPY`
+- [x] `_MOVE`
+- `_MIRROR`
+- `_ROTATE`
+> Kopie zdroj nechá. Posun ho přestěhuje.
+
+## Jaký úhel otočí tvar o 90 stupňů ve směru hodin?
+- 90
+- [x] -90
+- 180
+- 45
+> Kladný úhel jde proti hodinám. Po směru hodin je záporný, tedy -90.
+
+## Přes co se zrcadlí díra se středem 20,20 na destičce široké 100?
+- Přes bod 0,0
+- [x] Přes svislou osu x = 50
+- Přes vodorovnou osu y = 20
+- Přes hranu x = 100
+> Osa souměrnosti destičky je uprostřed šířky. 20 a 80 jsou od ní stejně daleko.
+
+## Co má být základní bod posunu?
+- Libovolné místo vedle objektu, od oka
+- [x] Úchop, třeba roh nebo střed
+- Vždy počátek 0,0
+- Střed obrazovky
+> Úchop podrží rozměr. Kliknutí vedle rohu posune tvar o neznámý kousek navíc.
diff --git a/content/technicka-grafika/1-rocnik/08-zadani-desticka.en.md b/content/technicka-grafika/1-rocnik/08-zadani-desticka.en.md
new file mode 100644
index 0000000..1920b3e
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/08-zadani-desticka.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a plate
+popis: The whole first-year drawing on one part.
+nazev_cviceni: Quiz: the plate
+nadpis_kvizu: Check the finished drawing
+---
diff --git a/content/technicka-grafika/1-rocnik/08-zadani-desticka.md b/content/technicka-grafika/1-rocnik/08-zadani-desticka.md
new file mode 100644
index 0000000..08464d1
--- /dev/null
+++ b/content/technicka-grafika/1-rocnik/08-zadani-desticka.md
@@ -0,0 +1,80 @@
+---
+id: tg-1-zadani-desticka
+nazev: Zadání: destička
+popis: Celý výkres z prvního ročníku na jedné součásti.
+nazev_cviceni: Kvíz: destička
+nadpis_kvizu: Zkontrolujte hotový výkres
+---
+
+# Výklad
+
+## Co se odevzdává
+*Zadání*
+
+- Destička 120 × 70 mm, tloušťka se nekreslí, jde o jeden pohled.
+- Čtyři díry Ø8. Středy jsou 15 mm od levého i pravého kraje a 15 mm od dolního i horního kraje.
+- Uprostřed je podélná drážka 40 × 16 se zaoblenými konci. Zatím stačí obdélník, zaoblení přijde ve druháku.
+- Výkres je na A4, měřítko 1:1, rámeček a razítko z první lekce.
+
+> Tip: středy děr jsou body 15,15, pak 105,15, pak 15,55 a 105,55. 120 minus 15 je 105, 70 minus 15 je 55.
+
+## Hladiny a čáry
+*Pořádek*
+
+- OBRYS je tlustá plná čára. OSA je tenká čerchovaná. KOTA zatím nechte prázdnou.
+- Každá díra má svislou i vodorovnou osu, která přesahuje obrys o 3 mm.
+- Destička má jednu vodorovnou a jednu svislou osu souměrnosti přes celý tvar.
+- Skryté hrany tu nejsou. Pohled je zepředu a díry jsou průchozí, jejich okraj je vidět.
+
+> Tip: průchozí díra v tomto pohledu není čárkovaná. Čárkovaná by byla hrana schovaná za materiálem.
+
+## Postup
+*Po řadě*
+
+- Nejdřív rámeček a razítko, potom obrys `_RECTANG` 120 × 70.
+- Jednu díru `_CIRCLE` o průměru 8 a další tři `_COPY` nebo dvě `_MIRROR`.
+- Drážku `_RECTANG` 40 × 16 se středem ve středu destičky. Její levý dolní roh je 40,27.
+- Osy dokreslete úchopem Střed. `_DIST` mezi protilehlými dírami ve vodorovném směru má být 90.
+
+> Tip: střed destičky je 60,35. Drážka sahá 20 mm doleva a doprava a 8 mm nahoru a dolů.
+
+## Kontrola před odevzdáním
+*Co musí sedět*
+
+- V razítku je název Destička, měřítko 1:1 a materiál, třeba ocel.
+- Žádná čára obrysu neleží na hladině OSA a žádná osa není plná.
+- Přiblížené rohy nemají mezeru. Kružnice se dotýká svých os ve středu.
+- Soubor se jmenuje desticka-120x70.dwg a je v milimetrech.
+
+> Tip: vypněte hladinu OBRYS. Zůstat mají jen osy a rámeček. Zapněte ji zpět a totéž udělejte s OSA.
+
+
+# Kvíz
+
+## Kde leží střed pravé horní díry?
+- 15,15
+- [x] 105,55
+- 120,70
+- 60,35
+> Od pravého kraje 15 mm je x = 105. Od horního kraje 15 mm je y = 55.
+
+## Jaká je vodorovná rozteč levé a pravé díry?
+- 120
+- 15
+- [x] 90
+- 105
+> 105 minus 15 je 90. Tolik má ukázat `_DIST` mezi středy.
+
+## Kam patří obrys destičky?
+- Na hladinu OSA
+- [x] Na hladinu OBRYS, tlustou plnou čarou
+- Čárkovaně, protože díry jsou průchozí
+- Do razítka
+> Viditelný obrys je tlustá plná čára. Osa je samostatná hladina.
+
+## Proč se průchozí díra v tomto pohledu nekreslí čárkovaně?
+- Čárkovaná čára je zakázaná
+- [x] Její okraj je z pohledu vidět
+- Díra se kreslí jen osou
+- Měřítko je 1:2
+> Čárkovaně se kreslí hrana, kterou materiál zakrývá. Okraj díry v čelním pohledu vidíme.
diff --git a/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.en.md b/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.en.md
new file mode 100644
index 0000000..0b5fb23
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Trim, extend and offset
+popis: Fix lines that overshoot, and keep a constant gap.
+nazev_cviceni: Quiz: trim and offset
+nadpis_kvizu: Pick the command and the distance
+---
diff --git a/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.md b/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.md
new file mode 100644
index 0000000..d132ae3
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/01-orez-a-ekvidistanta.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-orez-ekvidistanta
+nazev: Ořez, prodloužení a ekvidistanta
+popis: Úprava čar, které přesahují nebo mají stálý odstup.
+nazev_cviceni: Kvíz: ořez a odstup
+nadpis_kvizu: Vyberte příkaz a vzdálenost
+---
+
+# Výklad
+
+## Ořez a prodloužení
+*Na hranu*
+
+- `_TRIM` uřízne kus objektu po hraně, kterou vyberete jako řeznou.
+- V novějším AutoCADu stačí Enter bez výběru hran a řeže se o všechno, co čára protíná.
+- `_EXTEND` dělá opak: natáhne čáru až k vybrané hraně.
+- Maže se ta část, na kterou kliknete. Klik na špatnou stranu uřízne to, co mělo zůstat.
+
+> Tip: když ořez „nebere“, čáry se ve skutečnosti neprotínají. Přibližte roh a napojte ho úchopem.
+
+## Ekvidistanta
+*Stálý odstup*
+
+- `_OFFSET` vytvoří rovnoběžnou křivku v zadané vzdálenosti.
+- Hodí se na tloušťku stěny, rámeček i osazení. Zadáte vzdálenost a stranu.
+- U kružnice vznikne soustředná kružnice. Průměr se změní o dvojnásobek odstupu.
+- Ekvidistanta obdélníku udělá menší nebo větší obdélník se stejnými rohy, ne se zaoblením.
+
+> Tip: stěna tlustá 8 mm je ekvidistanta obrysu o 8 mm dovnitř. U kružnice Ø40 tak vznikne Ø24.
+
+## Co neřeže
+*Otevřený roh*
+
+- Ořez potřebuje průsečík. Dvě čáry, které se jen míjejí, se neoříznou.
+- Nejdřív je prodlužte nebo je nakreslete znovu s úchopem, teprve pak řežte.
+- Ekvidistanta otevřené křivky zůstane otevřená. Rohy se samy nedovřou.
+- Po ořezu zkontrolujte, že nezůstaly pahýly kratší než milimetr. Ty `_ERASE` smaže.
+
+> Tip: pahýl po ořezu je pořád objekt. Na tisku udělá tečku, která do výkresu nepatří.
+
+## Zadání: rámeček stěny
+*Cvičení*
+
+- Vnější obdélník 100 × 60. Ekvidistantou o 8 mm dovnitř udělejte vnitřní obrys.
+- Vnitřní obdélník má tedy 84 × 44.
+- Čáry, které po dalších úpravách přesáhnou vnější roh, ořízněte.
+- Oba obrysy nechte na hladině OBRYS. Kóty zatím nepřidávejte.
+
+> Tip: 100 minus dvakrát 8 je 84. 60 minus 16 je 44. Když vnitřek vyjde jinak, odstup šel ven, ne dovnitř.
+
+
+# Kvíz
+
+## Který příkaz uřízne přesahující kus čáry?
+- `_OFFSET`
+- [x] `_TRIM`
+- `_EXTEND`
+- `_MOVE`
+> Ořez bere kus za řeznou hranou. Prodloužení čáru naopak natahuje.
+
+## Ekvidistanta kružnice Ø40 o 8 mm dovnitř má průměr?
+- 32
+- [x] 24
+- 48
+- 8
+> Poloměr klesne o 8, průměr o 16. 40 minus 16 je 24.
+
+## Proč ořez na dvou čarách nic neudělá?
+- Orto je zapnuté
+- [x] Čáry se neprotínají
+- Chybí razítko
+- Objekt je kružnice
+> Bez průsečíku není kde řezat. Nejdřív čáry napojte, potom řežte.
+
+## Vnitřní obrys obdélníku 100 × 60 po ekvidistantě 8 mm dovnitř je?
+- 100 × 60
+- 92 × 52
+- [x] 84 × 44
+- 108 × 68
+> Z šířky i výšky se ubere 8 mm z každé strany, tedy 16 mm. 84 × 44.
diff --git a/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.en.md b/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.en.md
new file mode 100644
index 0000000..30afc38
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Fillets and chamfers
+popis: A corner by a radius or by two distances.
+nazev_cviceni: Quiz: corners
+nadpis_kvizu: Work out the corner
+---
diff --git a/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.md b/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.md
new file mode 100644
index 0000000..17e40f1
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/02-zaobleni-a-zkoseni.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-zaobleni-zkoseni
+nazev: Zaoblení a zkosení
+popis: Roh poloměrem nebo dvěma vzdálenostmi.
+nazev_cviceni: Kvíz: rohy
+nadpis_kvizu: Dopočítejte roh
+---
+
+# Výklad
+
+## Zaoblení
+*Poloměr*
+
+- `_FILLET` spojí dvě úsečky obloukem. Volba R nastaví poloměr.
+- Poloměr 8 znamená oblouk o poloměru 8 mm, ne průměr.
+- R0 ostrý roh jen napojí, oblouk nevznikne. Hodí se na nedotažené čáry.
+- Příkaz si poloměr pamatuje. Než zaoblíte jiný roh, R zkontrolujte.
+
+> Tip: u drážky se zaobleným koncem je poloměr polovina šířky drážky. Drážka 16 mm má R8.
+
+## Zkosení
+*Dvě vzdálenosti*
+
+- `_CHAMFER` srazí roh úsečkou. Volba D zadá dvě vzdálenosti od vrcholu.
+- Stejné vzdálenosti 2 a 2 srazí roh pod 45 stupňů, pokud jsou hrany kolmé.
+- Různé vzdálenosti, třeba 2 a 4, dají šikminu, která není 45 stupňů.
+- Zkosení se kótuje délkou a úhlem, nebo oběma délkami. Ne poloměrem.
+
+> Tip: na výkresu je zkosení 2 × 45°. V příkazu jsou to vzdálenosti 2 a 2, ne úhel napsaný místo D.
+
+## Kdy který roh
+*Dílec*
+
+- Zaoblení bývá tam, kde se obrábí frézou nebo kde ostrá hrana vadí.
+- Zkosení bývá na hraně díry a na konci hřídele, aby díl navedl protikus.
+- Na jednom rohu nedávejte zaoblení i zkosení. Zadání říká jen jedno.
+- Po zaoblení se změní délka rovné hrany. Kóta rovné části už není celá šířka.
+
+> Tip: hrana dlouhá 40 mm se dvěma zaobleními R5 na koncích má rovný úsek 30 mm.
+
+## Zadání: drážka a sražení
+*Cvičení*
+
+- Obdélník 80 × 30. Na obou kratších koncích `_FILLET` s R8, na obou dlouhých hranách.
+- Vznikne oválná drážka: rovný střed je dlouhý 64 mm, šířka zůstane 30? Pozor, R8 na výšce 30 nejde, šířka drážky musí být 16.
+- Překreslete tvar jako drážku 64 × 16 a konce zaoblete R8.
+- Jeden vnější roh destičky 80 × 50 mimo drážku srazte `_CHAMFER` 2 a 2.
+
+> Tip: poloměr zaoblení konce drážky se rovná polovině její šířky. 16 / 2 = 8.
+
+
+# Kvíz
+
+## Co nastaví `_FILLET` volbou R8?
+- Zkosení 8 × 8
+- [x] Oblouk o poloměru 8 mm
+- Průměr 8
+- Osazení o 8 mm
+> R je poloměr oblouku. Průměr oblouku by byl 16.
+
+## Jak v příkazu zadáte zkosení 2 × 45° na kolmém rohu?
+- R2
+- [x] Vzdálenosti 2 a 2
+- Úhel 45 místo obou vzdáleností, bez délek
+- Ekvidistantu 2
+> Stejné vzdálenosti na kolmých hranách dají 45 stupňů. Do příkazu patří délky.
+
+## Jaký poloměr má konec drážky široké 16 mm?
+- 16
+- [x] 8
+- 4
+- 32
+> Konec je půlkruh. Jeho poloměr je polovina šířky.
+
+## Co udělá zaoblení R0?
+- Smaže roh
+- [x] Napojí čáry ostrým rohem
+- Udělá kružnici
+- Nastaví měřítko
+> Nulový poloměr je způsob, jak dvě minuté čáry spojit do ostrého rohu.
diff --git a/content/technicka-grafika/2-rocnik/03-hladiny.en.md b/content/technicka-grafika/2-rocnik/03-hladiny.en.md
new file mode 100644
index 0000000..ecdd7b2
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/03-hladiny.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Layers
+popis: Colour, linetype and weight in one place.
+nazev_cviceni: Quiz: layers
+nadpis_kvizu: Decide which layer an object belongs on
+---
diff --git a/content/technicka-grafika/2-rocnik/03-hladiny.md b/content/technicka-grafika/2-rocnik/03-hladiny.md
new file mode 100644
index 0000000..6a93333
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/03-hladiny.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-hladiny
+nazev: Hladiny
+popis: Barva, typ čáry a tloušťka na jednom místě.
+nazev_cviceni: Kvíz: hladiny
+nadpis_kvizu: Rozhodněte, co patří na kterou hladinu
+---
+
+# Výklad
+
+## Proč hladiny
+*Pořádek*
+
+- Hladina nese barvu, typ čáry i tloušťku. Objekt, který na ní vznikne, je zdědí.
+- Obrys, osa, skrytá hrana, šrafa, kóta a rámeček jsou samostatné hladiny.
+- Vypnutá hladina na obrazovce zmizí, ale tisknout se pořád může. Zmrazená se netiskne a nepřepočítává.
+- Zamčená hladina je vidět, ale nejde omylem posunout.
+
+> Tip: kóty nikdy nekreslete na OBRYS. Až budete obrys měnit, kóty by odjely s ním.
+
+## Aktuální hladina
+*Než kliknete*
+
+- Nový objekt padne na aktuální hladinu. Je to ta, která je ve správci vybraná jako současná.
+- `_LAYER` založí hladinu. Poklepáním ji nastavíte jako aktuální.
+- Objekt na špatné hladině přesuňte ve vlastnostech, příkaz `_PROPERTIES` nebo horní lišta.
+- Barvu a typ čáry nechte ByLayer. Barva nastavená na objektu se se hladinou přestane řídit.
+
+> Tip: ByLayer znamená „ber to, co říká hladina“. Je to výchozí a na školním výkresu má tak zůstat.
+
+## Názvy, které obstojí
+*Úmluva*
+
+- OBRYS, OSA, SKRYTE, SRAFA, KOTA, RAM, TEXT. Krátce a bez mezer.
+- OBRYS je bílá nebo černá, tloušťka 0,50. Ostatní tenké, 0,25, každá svou barvou.
+- SKRYTE má typ DASHED, OSA typ CENTER. Ostatní Continuous.
+- Hladinu 0 nemažte a nekreslete na ni součást. Je systémová.
+
+> Tip: hladina 0 je schovaná v blocích. Součást na ní se později špatně barví.
+
+## Zadání: šest hladin
+*Cvičení*
+
+- V souboru destičky založte OBRYS, OSA, SKRYTE, SRAFA, KOTA a RAM.
+- Obrys přesuňte na OBRYS, osy na OSA, rámeček na RAM.
+- OSA musí mít typ CENTER a po `_LTSCALE` 0,5 nebo 1 musí být čerchování vidět.
+- Vypněte OBRYS. Na obrazovce smí zůstat osy a rámeček, nic z tvaru.
+
+> Tip: když osa vypadá plně, typ čáry není načtený, nebo je LTSCALE mimo rozumné měřítko.
+
+
+# Kvíz
+
+## Kam patří kóta?
+- Na hladinu OBRYS
+- [x] Na vlastní hladinu KOTA
+- Na hladinu 0
+- Kamkoli, hladina je jen barva pozadí
+> Kóta má vlastní tenkou čáru a při úpravě obrysu nesmí odjet s ním.
+
+## Co znamená ByLayer?
+- Objekt barvu hladiny ignoruje
+- [x] Objekt si bere barvu a typ čáry z hladiny
+- Hladina je zamčená
+- Objekt se netiskne
+> ByLayer je správné nastavení. Barva „natvrdo“ na objektu se hladinou už neřídí.
+
+## Čím se liší vypnutá a zmrazená hladina?
+- Ničím
+- [x] Zmrazená se navíc netiskne a výkres ji nepočítá
+- Vypnutá se nedá znovu zapnout
+- Zmrazená je vidět červeně
+> Vypnutí je jen obrazovka. Zmrazení hladinu vyřadí i z tisku a z přepočtu.
+
+## Proč nekreslit součást na hladinu 0?
+- Hladina 0 nejde použít
+- [x] Je systémová a v blocích se chová jinak
+- Nepouští tlusté čáry
+- Smí na ni jen razítko
+> Hladina 0 má zvláštní roli uvnitř bloků. Součást patří na pojmenované hladiny.
diff --git a/content/technicka-grafika/2-rocnik/04-srafy.en.md b/content/technicka-grafika/2-rocnik/04-srafy.en.md
new file mode 100644
index 0000000..7619115
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/04-srafy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Hatches
+popis: Sectioned material, hatch scale and a closed boundary.
+nazev_cviceni: Quiz: hatches
+nadpis_kvizu: Decide what gets hatched
+---
diff --git a/content/technicka-grafika/2-rocnik/04-srafy.md b/content/technicka-grafika/2-rocnik/04-srafy.md
new file mode 100644
index 0000000..300909f
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/04-srafy.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-srafy
+nazev: Šrafy
+popis: Řez materiálem, měřítko šrafy a uzavřená oblast.
+nazev_cviceni: Kvíz: šrafy
+nadpis_kvizu: Rozhodněte, co se šrafuje
+---
+
+# Výklad
+
+## Co šrafa říká
+*Materiál*
+
+- Šrafa v řezu znamená materiál, který řezná rovina proťala.
+- Ocel se na školním výkresu šrafuje tenkými čarami pod 45 stupňů.
+- Dva sousední díly mají šrafu na opačnou stranu, nebo jinou rozteč.
+- Díra, drážka a prázdno se nešrafují. Není tam materiál.
+
+> Tip: šrafa pod 45 stupni nesmí být rovnoběžná s obrysem. Když je obrys šikmý, úhel šrafy změňte.
+
+## Příkaz
+*Uzavřená oblast*
+
+- `_HATCH` a vzor ANSI31. Klikněte dovnitř uzavřené oblasti.
+- Oblast musí být dovřená. Mezera v rohu šrafu zastaví nebo ji vylije ven.
+- Měřítko vzoru zvětší rozteč čar. Moc hustá šrafa na tisku zčerná.
+- Šrafa patří na hladinu SRAFA, tenkou čarou. Ne na OBRYS.
+
+> Tip: asociativní šrafa se přizpůsobí, když obrys později natáhnete. Na školní práci ji nechte zapnutou.
+
+## Co se v podélném řezu nešrafuje
+*Výjimky*
+
+- Hřídel, žebro, šroub a pero v podélném řezu zůstávají bez šrafy.
+- V příčném řezu se tatáž hřídel šrafuje. Rozhoduje směr řezu, ne součást.
+- Tenká stěna řezaná podélně se šrafuje. Žebro ne, aby nepůsobilo jako plný kus.
+- Obrys šrafované plochy je pořád tlustá čára. Šrafa ho nenahrazuje.
+
+> Tip: podélný řez hřídele ukáže osazení obrysem. Šrafa by z hřídele udělala zdánlivě plný hranol.
+
+## Zadání: řez destičkou
+*Cvičení*
+
+- Destičku 80 × 20 v řezu A-A vyšrafujte ANSI31, měřítko tak, aby rozteč byla zhruba 2 až 3 mm.
+- Díru Ø10 uprostřed nešrafujte. Šrafa končí na její hraně.
+- Šrafu dejte na hladinu SRAFA. Vypnutím této hladiny musí zmizet jen šrafa, obrys zůstane.
+- Když šrafa vyteče ven, najděte mezeru v obrysu a roh napojte.
+
+> Tip: jedna oblast vlevo od díry a jedna vpravo. Obě mohou být jedna šrafa, pokud příkaz umí víc oblastí.
+
+
+# Kvíz
+
+## Co znamená šrafa v řezu?
+- Skrytou hranu
+- [x] Materiál proťatý řeznou rovinou
+- Závit
+- Měřítko 1:2
+> Šrafuje se jen to, co řez skutečně prochází. Díra je prázdná.
+
+## Jaký vzor se na škole používá pro ocel?
+- Plná výplň
+- [x] ANSI31, tenké čáry pod 45 stupňů
+- Tečkování
+- Čerchovaná osa
+> ANSI31 je běžný strojařský vzor. Úhel změňte, jen když by splynul s obrysem.
+
+## Proč šrafa vyteče mimo součást?
+- Je zapnuté orto
+- [x] Obrys není uzavřený
+- Chybí kóta
+- Hladina je ByLayer
+> I malá mezera v rohu oblast otevře. Napojte roh a šrafu opakujte.
+
+## Šrafuje se hřídel v podélném řezu?
+- Ano, vždy
+- [x] Ne
+- Jen když má drážku
+- Jen ve 3D
+> Hřídel, žebro a šroub se v podélném řezu nechávají prázdné. V příčném řezu se hřídel šrafuje.
diff --git a/content/technicka-grafika/2-rocnik/05-koty-linearni.en.md b/content/technicka-grafika/2-rocnik/05-koty-linearni.en.md
new file mode 100644
index 0000000..46918a3
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/05-koty-linearni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Linear dimensions
+popis: The real size, extension lines and a dimension style.
+nazev_cviceni: Quiz: linear dimensions
+nadpis_kvizu: Read the dimension correctly
+---
diff --git a/content/technicka-grafika/2-rocnik/05-koty-linearni.md b/content/technicka-grafika/2-rocnik/05-koty-linearni.md
new file mode 100644
index 0000000..20556d0
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/05-koty-linearni.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-koty-linearni
+nazev: Lineární kóty
+popis: Skutečný rozměr, pomocné čáry a styl kót.
+nazev_cviceni: Kvíz: lineární kóty
+nadpis_kvizu: Přečtěte kótu správně
+---
+
+# Výklad
+
+## Co kóta říká
+*Skutečný rozměr*
+
+- Kóta je skutečný rozměr součásti, ne délka čáry na papíře.
+- Na výkresu 1:2 je čára poloviční a číslo zůstává celé.
+- `_DIMLINEAR` chce počátek první pomocné čáry, druhé a polohu kótovací čáry.
+- Body chytejte úchopem. Kóta z bodu vedle rohu je o milimetr špatně a tváří se přesně.
+
+> Tip: číslo na kótě nepřepisujte ručně. Když nesedí, je špatně geometrie, ne text.
+
+## Vzhled
+*Čitelné číslo*
+
+- Pomocné čáry odstupují od obrysu asi o 1 mm a přesahují kótovací čáru.
+- Sousední kóty mají stejný odstup, třeba 10 mm od obrysu a 7 mm mezi sebou.
+- Číslo se píše nad kótovací čáru, ne přes obrys. Kóta neprotíná šrafu, když to jde jinak.
+- `_DIMSTYLE` nastaví šipky, písmo a jednotky jednou pro celý výkres.
+
+> Tip: výška písma na A4 bývá 3,5 mm. Menší text se na tisku nedá číst.
+
+## Řetězec a základna
+*Víc rozměrů*
+
+- Řetězec klade kóty za sebou. Součet dílčích kót má dát celkový rozměr.
+- Kóta od základny měří všechno od jedné hrany. Hodí se na rozteče od dorazu.
+- Celkový rozměr a součet dílčích se na jedné hraně neopakují bez důvodu.
+- Kóta patří tam, odkud se rozměr bude vyrábět, ne doprostřed šrafy.
+
+> Tip: 20 + 45 + 15 = 80. Když součet nesedí s celkovou kótou, jedna pomocná čára minula roh.
+
+## Zadání: destička 120 × 70
+*Cvičení*
+
+- Okótujte šířku 120 a výšku 70.
+- Vodorovně přidejte 15 od levého kraje ke středu díry a 90 mezi středy děr.
+- Svisle 15 od dolní hrany ke středu díry.
+- Všechny kóty na hladinu KOTA. `_DIST` mezi rohy musí ukázat stejná čísla jako kóty.
+
+> Tip: kótujte středy děr, ne jejich okraje. Okraj určí až kóta průměru.
+
+
+# Kvíz
+
+## Co se píše do lineární kóty na výkresu 1:2?
+- Polovina skutečného rozměru
+- [x] Skutečný rozměr
+- Dvojnásobek
+- Měřítko 1:2 místo čísla
+> Měřítko mění délku čáry na papíře. Číslo kóty je rozměr součásti.
+
+## Proč nepřepisovat text kóty ručně?
+- AutoCAD to zakazuje vždy
+- [x] Špatné číslo skrývá chybu geometrie
+- Text musí být v palcích
+- Kóta by zmizela z hladiny
+> Správná kóta měří geometrii. Ruční text 120 na čáře dlouhé 119 je lež.
+
+## Od čeho se kótuje poloha díry?
+- Od okraje kružnice
+- [x] Od středu
+- Od osy razítka
+- Od nejbližší šrafy
+> Výroba potřebuje střed. Průměr díry je samostatná kóta.
+
+## Co má platit pro řetězec 20, 45 a 15 na součásti dlouhé 80?
+- Součet má být 100
+- [x] Součet má být 80
+- Celková kóta se vynechá vždy
+- Řetězec se kreslí čerchovaně
+> 20 + 45 + 15 = 80. Nesoulad znamená minutý úchop na některé pomocné čáře.
diff --git a/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.en.md b/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.en.md
new file mode 100644
index 0000000..9b60f02
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Diameter, radius and angle dimensions
+popis: The diameter symbol, a fillet radius and a chamfer angle.
+nazev_cviceni: Quiz: diameter and angle
+nadpis_kvizu: Match the symbol to the size
+---
diff --git a/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.md b/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.md
new file mode 100644
index 0000000..17ce4d0
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/06-koty-prumeru-a-uhlu.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-koty-prumeru
+nazev: Kóty průměru, poloměru a úhlu
+popis: Značka průměru, poloměr zaoblení a úhel zkosení.
+nazev_cviceni: Kvíz: průměr a úhel
+nadpis_kvizu: Doplňte značku ke správnému rozměru
+---
+
+# Výklad
+
+## Průměr a poloměr
+*Díra a oblouk*
+
+- `_DIMDIAMETER` kótuje kružnici. Před číslo patří značka průměru.
+- `_DIMRADIUS` kótuje oblouk. Před číslo patří R.
+- Díra má kótu průměru, i když jste ji kreslili poloměrem. Výroba vrtá průměrem.
+- Zaoblení rohu má kótu poloměru. Jedno R na výkresu platí pro všechny stejné rohy, když to poznámka říká.
+
+> Tip: díru Ø8 nekótujte jako R4. Nástroj se jmenuje podle průměru.
+
+## Úhel
+*Zkosení a klín*
+
+- `_DIMANGULAR` měří úhel mezi dvěma úsečkami nebo obloukem.
+- Zkosení 2 × 45° se často zapíše poznámkou, ne obloukovou kótou v každém rohu.
+- Úhel se měří ve stupních. 45 stupňů je polovina pravého úhlu.
+- Kóta úhlu nesmí překrýt číslo sousední délky. Odsuňte ji dál od vrcholu.
+
+> Tip: pravý úhel se na strojařském výkresu obvykle nekótuje. 90 stupňů se předpokládá.
+
+## Kolik desetin
+*Přesnost*
+
+- Školní kóta celého milimetru nemá zbytečné desetinné nuly, pokud styl neříká jinak.
+- Průměr a poloměr zaokrouhlete stejně jako délky. Nemíchejte 8 a 8,00 bez důvodu.
+- `_DIMSTYLE` nastaví přesnost i značky najednou. Neupravujte každou kótu ručně.
+- Kóta, která ukazuje 7,99 místo 8, minula střed. Geometrii opravte, text ne.
+
+> Tip: 7,99 je skoro jistě úchop vedle středu. `_ID` středu díry to ukáže.
+
+## Zadání: díra a roh
+*Cvičení*
+
+- Na destičce okótujte díru Ø8 příkazem `_DIMDIAMETER`.
+- Zaoblení R8 na drážce okótujte `_DIMRADIUS` jednou. K ostatním stejné poznámkou.
+- Zkosení 2 × 45° zapište k příslušné hraně.
+- Žádná kóta průměru nesmí ukazovat 4 ani R8 u díry, která se vrtá.
+
+> Tip: čtyři stejné díry se kótují jednou, s poznámkou 4× Ø8, plus rozteč středů.
+
+
+# Kvíz
+
+## Jak se kótuje vrtaná díra o průměru 8 mm?
+- R8
+- R4
+- [x] Ø8
+- 8 bez značky, jako délka
+> Vrták se volí podle průměru. Poloměr by výrobce přepočítával a snadno spletl.
+
+## Co znamená R8?
+- Průměr 8
+- [x] Poloměr zaoblení 8 mm
+- Rozteč 8 mm
+- Tloušťka čáry 8
+> R je poloměr oblouku. U díry se R nepoužívá.
+
+## Kóta díry ukazuje 7,99. Co s tím?
+- Přepsat text na 8
+- [x] Opravit geometrii, střed minul úchop
+- Smazat všechny kóty
+- Změnit měřítko na 1:2
+> Text by lež zakryl. Střed díry má ležet přesně tam, kde ho kóty polohy uvádějí.
+
+## Jak jednou kótou popsat čtyři stejné díry Ø8?
+- Čtyřikrát opsat Ø8
+- [x] 4× Ø8 a rozteč středů
+- Jen jednu díru a ostatní nechat bez kóty
+- R4 krát čtyři
+> Počet, průměr a poloha středů stačí. Opakovat stejnou kótu u každé díry výkres zanáší.
diff --git a/content/technicka-grafika/2-rocnik/07-pole.en.md b/content/technicka-grafika/2-rocnik/07-pole.en.md
new file mode 100644
index 0000000..23f02f0
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/07-pole.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Rectangular and polar arrays
+popis: Repeating holes in a row or on a circle.
+nazev_cviceni: Quiz: arrays
+nadpis_kvizu: Work out the spacing and the count
+---
diff --git a/content/technicka-grafika/2-rocnik/07-pole.md b/content/technicka-grafika/2-rocnik/07-pole.md
new file mode 100644
index 0000000..c0f8fd0
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/07-pole.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-pole
+nazev: Obdélníkové a polární pole
+popis: Opakování děr v řadě nebo na kružnici.
+nazev_cviceni: Kvíz: pole
+nadpis_kvizu: Dopočítejte rozteč a počet
+---
+
+# Výklad
+
+## Obdélníkové pole
+*Řady a sloupce*
+
+- `_ARRAYRECT` rozkopíruje objekt do řádků a sloupců.
+- Zadává se počet sloupců, počet řádků a rozteč, ne celkový rozměr.
+- Rozteč 30 při třech sloupcích dá od první osy ke třetí 60 mm.
+- Pole může zůstat svázané. Úprava jedné díry se propíše do ostatních.
+
+> Tip: tři díry v řadě mají dvě mezery. Celková délka je dvojnásobek rozteče, ne trojnásobek.
+
+## Polární pole
+*Na kružnici*
+
+- `_ARRAYPOLAR` chce střed otáčení, počet prvků a úhel vyplnění.
+- Čtyři díry po 360 stupních jsou po 90 stupních.
+- Vzorová díra musí ležet na roztečné kružnici dřív, než pole spustíte.
+- Střed pole je střed příruby, ne střed první díry.
+
+> Tip: roztečná kružnice Ø60 má poloměr 30. Vzorová díra leží třeba v bodě 30,0.
+
+## Roztečná kružnice se nekreslí tlustě
+*Osa*
+
+- Kružnice, na které leží středy děr, je tenká čerchovaná osa.
+- Není to hrana součásti a nepatří na OBRYS.
+- Kótuje se jejím průměrem, často jako Ø60, a počtem děr.
+- Samotné díry mají svou kótu průměru jen jednou.
+
+> Tip: na výkresu čtete 4× Ø8 na Ø60. Osm je díra, 60 je kružnice středů.
+
+## Zadání: čtyři díry na kružnici
+*Cvičení*
+
+- Příruba je kružnice Ø80, střed 0,0.
+- Vzorovou díru Ø8 dejte středem do 30,0.
+- `_ARRAYPOLAR` kolem 0,0, čtyři prvky, 360 stupňů.
+- Roztečnou kružnici Ø60 dejte na hladinu OSA. Ověřte `_DIST` mezi sousedními středy.
+
+> Tip: sousední středy svírají 90 stupňů na poloměru 30. Vzdálenost je 30 × √2, asi 42,4 mm.
+
+
+# Kvíz
+
+## Tři díry v řadě mají rozteč 30. Jak daleko je první osa od třetí?
+- 30
+- 90
+- [x] 60
+- 120
+> Mezi třemi dírami jsou dvě mezery. 2 × 30 = 60.
+
+## Kam se umísťuje vzorová díra pro pole na Ø60?
+- Do středu příruby
+- [x] Na poloměr 30 od středu
+- 60 mm od středu
+- Kamkoli, pole si poloměr dopočítá
+> Ø60 znamená poloměr 30. Střed vzorové díry musí na této kružnici už ležet.
+
+## Čím je nakreslená kružnice středů děr?
+- Tlustým obrysem
+- [x] Tenkou čerchovanou osou
+- Šrafou
+- Kótovací čarou
+> Není to hrana materiálu. Je to osa rozteče a patří na hladinu OSA.
+
+## Co znamená zápis 4× Ø8 na Ø60?
+- Čtyři díry o průměru 60
+- [x] Čtyři díry Ø8, jejich středy leží na Ø60
+- Osazení 4 mm
+- Zkosení 8 × 60
+> První průměr je díra, druhý je roztečná kružnice.
diff --git a/content/technicka-grafika/2-rocnik/08-zadani-priruba.en.md b/content/technicka-grafika/2-rocnik/08-zadani-priruba.en.md
new file mode 100644
index 0000000..1bb0ae0
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/08-zadani-priruba.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a flange
+popis: A drawing with an offset, a hole pattern, a hatch and dimensions.
+nazev_cviceni: Quiz: the flange
+nadpis_kvizu: Check the flange before you hand it in
+---
diff --git a/content/technicka-grafika/2-rocnik/08-zadani-priruba.md b/content/technicka-grafika/2-rocnik/08-zadani-priruba.md
new file mode 100644
index 0000000..6c75f73
--- /dev/null
+++ b/content/technicka-grafika/2-rocnik/08-zadani-priruba.md
@@ -0,0 +1,80 @@
+---
+id: tg-2-zadani-priruba
+nazev: Zadání: příruba
+popis: Výkres s ekvidistantou, polem děr, šrafou a kótami.
+nazev_cviceni: Kvíz: příruba
+nadpis_kvizu: Zkontrolujte přírubu před odevzdáním
+---
+
+# Výklad
+
+## Tvar
+*Rozměry*
+
+- Příruba Ø100, střed 0,0. Středová díra Ø40.
+- Čtyři díry Ø9 na roztečné kružnici Ø70, po 90 stupních.
+- Vnější hrana má zkosení 1 × 45°, které v tomto pohledu vidíte jako tenkou kružnici Ø98.
+- Tloušťka příruby 12 mm se okótuje v druhém pohledu nebo v řezu. V čelním pohledu je kružnice.
+
+> Tip: rozteč Ø70 znamená vzorovou díru ve vzdálenosti 35 mm od středu.
+
+## Řez
+*Aby byla vidět tloušťka*
+
+- Druhý pohled je řez přes osu. Ukáže tloušťku 12 a průchod díry Ø40.
+- V řezu se šrafuje materiál příruby. Díra Ø40 zůstane prázdná.
+- Díry Ø9 v tomto řezu nemusí vyjít všechny. Řezná rovina bere dvě protilehlé, nebo žádnou, podle úhlu.
+- Zvolte řez rovinou, která prochází dvěma protilehlými dírami Ø9, ať je v řezu vidět i jejich průchod.
+
+> Tip: rovina pod úhlem 0 stupňů vezme díry na ose X. Jejich průchod je Ø9 skrz tloušťku 12.
+
+## Kóty a hladiny
+*Jednou*
+
+- Kóty: Ø100, Ø40, 4× Ø9 na Ø70, tloušťka 12, zkosení 1 × 45°.
+- OBRYS, OSA, SRAFA, KOTA, RAM. Typy čar jako ve druháku.
+- Razítko: Příruba, 1:1, ocel.
+- Formát A4 na šířku nebo A3, když se řez vedle čela nevejde čitelně.
+
+> Tip: kóta 4× Ø9 se neopakuje u každé díry. Roztečná kružnice má vlastní kótu Ø70.
+
+## Kontrola
+*Před tiskem*
+
+- `_DIST` od středu k ose díry Ø9 je 35.
+- Šrafa nezasahuje do díry Ø40 ani do děr Ø9.
+- Vypnutí hladiny KOTA nechá čistý tvar. Čísla nezmizí z obrysu, protože na něm nemají být.
+- Soubor priruba.dwg, jednotky milimetry, žádná kóta přepsaná ručně.
+
+> Tip: když kóta Ø100 ukazuje 99,9, vnější kružnice nemá průměr 100. Opravte kružnici, ne text.
+
+
+# Kvíz
+
+## Jak daleko od středu leží osa díry na roztečné kružnici Ø70?
+- 70
+- [x] 35
+- 9
+- 100
+> Rozteč se kótuje průměrem. Poloměr, tedy vzdálenost středu díry, je 35.
+
+## Jak jednou kótou popsat čtyři malé díry?
+- Ø9
+- Ø70
+- [x] 4× Ø9
+- R4,5
+> Počet krát průměr. Poloha je až kóta roztečné kružnice.
+
+## Co se v řezu příruby nešrafuje?
+- Těleso příruby
+- [x] Průchod díry Ø40
+- Obrys
+- Razítko se šrafuje vždy
+> Šrafuje se materiál. Díra je prázdná, šrafa končí na její hraně.
+
+## Kóta vnějšího průměru ukazuje 99,9. Co uděláte?
+- Přepíšete ji na 100
+- [x] Opravíte kružnici na Ø100
+- Změníte měřítko na 1:2
+- Smažete řez
+> Kóta měří geometrii. Ruční text by zakryl, že kružnice je špatně.
diff --git a/content/technicka-grafika/3-rocnik/01-pohledy.en.md b/content/technicka-grafika/3-rocnik/01-pohledy.en.md
new file mode 100644
index 0000000..a25979f
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/01-pohledy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Views and first-angle projection
+popis: Front, top and side views the way they are drawn here.
+nazev_cviceni: Quiz: views
+nadpis_kvizu: Put the view in the right place
+---
diff --git a/content/technicka-grafika/3-rocnik/01-pohledy.md b/content/technicka-grafika/3-rocnik/01-pohledy.md
new file mode 100644
index 0000000..f247128
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/01-pohledy.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-pohledy
+nazev: Pohledy a evropské promítání
+popis: Nárys, půdorys a bokorys tak, jak se kreslí u nás.
+nazev_cviceni: Kvíz: pohledy
+nadpis_kvizu: Umístěte pohled na správné místo
+---
+
+# Výklad
+
+## Tři základní pohledy
+*Nárys*
+
+- Nárys je pohled zepředu. Vybírá se tak, aby ukázal tvar co nejlíp.
+- Půdorys je pohled shora. Bokorys je pohled zleva.
+- V evropském promítání leží půdorys pod nárysem, ne nad ním.
+- Bokorys zleva leží vpravo od nárysu. Předmět si představte překlopený k vám.
+
+> Tip: americké promítání má půdorys nahoře. Na českém školním výkresu se nekreslí.
+
+## Co se v pohledu opakuje
+*Stejná hrana*
+
+- Výška nárysu a výška bokorysu je stejné číslo. Šířka nárysu a šířka půdorysu taky.
+- Hloubka půdorysu se rovná šířce bokorysu. Pomůže si 45stupňová pomocná čára.
+- Skrytá hrana se v pohledu, kde není vidět, kreslí tenkou čárkovanou čarou.
+- Hrana, která v daném pohledu splývá s obrysem, se podruhé nekreslí.
+
+> Tip: když je nárys vysoký 40, bokorys nemůže být vysoký 36. Někde chybí hrana nebo měřítko.
+
+## Zarovnání
+*Jedna osa*
+
+- Pohledy se zarovnají. Svislé hrany nárysu a půdorysu leží na stejných svislicích.
+- Vodorovné hrany nárysu a bokorysu leží na stejných vodorovných přímkách.
+- Mezi pohledy nechte mezeru na kóty, třeba 30 až 40 mm. Pohledy se nesmí překrývat.
+- Osa souměrnosti prochází všemi pohledy, ve kterých je součást souměrná.
+
+> Tip: `_XLINE` pod 45 stupňů přenese hloubku z půdorysu do bokorysu. Po dokreslení ji smažte nebo dejte na pomocnou hladinu.
+
+## Zadání: kvádr s osazením
+*Cvičení*
+
+- Kvádr 60 × 40 × 30. Zepředu je na něm osazení: pravá část je o 10 mm nižší, v délce 20 mm.
+- Nárys je 60 široký a 30 vysoký. Půdorys pod ním je 60 × 40.
+- Bokorys vpravo je 40 široký a 30 vysoký. Osazení v něm vyjde jako skrytá nebo viditelná hrana podle směru.
+- Pohledy zarovnejte. Výška 30 musí sedět v nárysu i v bokorysu.
+
+> Tip: osazení dlouhé 20 a nižší o 10 znamená, že v nárysu je horní hrana 40 mm v plné výšce a 20 mm ve výšce 20.
+
+
+# Kvíz
+
+## Kam se v evropském promítání kreslí půdorys?
+- Nad nárys
+- [x] Pod nárys
+- Vlevo od nárysu
+- Místo bokorysu
+> Evropské promítání klade pohled shora pod pohled zepředu. Nahoře by byl americký zvyk.
+
+## Co musí mít nárys a bokorys společné?
+- Šířku
+- [x] Výšku
+- Hloubku jako jediné číslo bez převodu
+- Razítko
+> Výška předmětu je v obou pohledech stejná. Hloubka se přenáší mezi půdorysem a bokorysem.
+
+## Jak se kreslí hrana, kterou v daném pohledu není vidět?
+- Tlustou plnou
+- [x] Tenkou čárkovanou
+- Čerchovanou osou
+- Nezobrazuje se nikdy
+> Skrytá hrana je čárkovaná. Vynechat ji smíte jen tam, kde by výkres zbytečně zanesla a norma to dovolí.
+
+## Kvádr je vysoký 30. Jak vysoký je jeho bokorys?
+- 40
+- [x] 30
+- 60
+- Podle měřítka jinak
+> Výška se mezi nárysem a bokorysem nemění. Měřítko je pro celý výkres jedno.
diff --git a/content/technicka-grafika/3-rocnik/02-rezy.en.md b/content/technicka-grafika/3-rocnik/02-rezy.en.md
new file mode 100644
index 0000000..3b30d5d
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/02-rezy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Sections and cross-sections
+popis: The cutting plane, the arrows and what gets hatched.
+nazev_cviceni: Quiz: sections
+nadpis_kvizu: Read the cutting plane
+---
diff --git a/content/technicka-grafika/3-rocnik/02-rezy.md b/content/technicka-grafika/3-rocnik/02-rezy.md
new file mode 100644
index 0000000..0845683
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/02-rezy.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-rezy
+nazev: Řezy a průřezy
+popis: Řezná rovina, šipky a co se šrafuje.
+nazev_cviceni: Kvíz: řezy
+nadpis_kvizu: Přečtěte řeznou rovinu
+---
+
+# Výklad
+
+## Úplný řez
+*Rovina A-A*
+
+- Řez ukáže vnitřek, který by jinak byl samá čárkovaná čára.
+- Řezná rovina se v pohledu označí tlustou čerchovanou čarou se šipkami a písmeny, třeba A.
+- Obraz řezu se popíše A-A. Šipky říkají, kterým směrem se díváme.
+- Materiál proťatý rovinou se šrafuje. Prázdné díry ne.
+
+> Tip: šipky řezu míří na plochu, kterou uvidíte. Obrácené šipky otočí celý obraz.
+
+## Poloviční řez
+*Souměrná součást*
+
+- Souměrná součást smí mít půl pohledu a půl řezu v jednom obrazu.
+- Dělí je osa. Pohled bývá nad osou nebo vlevo, řez pod osou nebo vpravo.
+- Na ose mezi polovinou pohledu a řezu se nekreslí tlustý obrys.
+- Skryté hrany v neřezané polovině se často vynechají, protože řez už vnitřek ukázal.
+
+> Tip: hřídel s dírou na ose je typický poloviční řez. Nahoře obrys, dole díra a šrafa.
+
+## Co řezem není
+*Průřez*
+
+- Průřez ukáže jen tvar v řezné rovině, bez toho, co leží za ní.
+- Řez ukáže i hrany za rovinou. Proto je na řezu víc čar než na průřezu.
+- Tenký průřez se smí nakreslit přímo do pohledu, pootočený na místě.
+- Žebro řezané podélně se nešrafuje ani v úplném řezu.
+
+> Tip: když za rovinou vidíte další osazení, je to řez. Když je tam jen vyšrafovaný profil, je to průřez.
+
+## Zadání: pouzdro
+*Cvičení*
+
+- Pouzdro je válec Ø50, díra Ø30, délka 40. Nakreslete nárys jako úplný řez A-A osou.
+- V řezu vyšrafujte mezikruží. Díra Ø30 zůstane prázdná po celé délce 40.
+- Šipky A umístěte do půdorysu nebo do místa, odkud je rovina jasná.
+- Kóty Ø50, Ø30 a 40. Hřídel to není, šrafa tu být má.
+
+> Tip: tloušťka stěny je (50 − 30) / 2 = 10 mm. V řezu je vidět vlevo i vpravo od díry.
+
+
+# Kvíz
+
+## Co říkají šipky u řezné roviny?
+- Tloušťku čáry
+- [x] Směr pohledu na řez
+- Měřítko šrafy
+- Počet děr
+> Obraz řezu je to, co bychom viděli ve směru šipek. Obrácené šipky obraz otočí.
+
+## Šrafuje se díra Ø30 v řezu pouzdra?
+- Ano, je uprostřed
+- [x] Ne, není tam materiál
+- Jen v měřítku 1:2
+- Jen když je pouzdro z oceli
+> Šrafuje se stěna. Průchozí díra zůstane bílá.
+
+## Čím se liší průřez od řezu?
+- Průřez se nešrafuje
+- [x] Průřez neukazuje hrany za řeznou rovinou
+- Řez nemá písmena
+- Průřez je vždy v měřítku 2:1
+> Řez dokreslí i to, co leží za rovinou. Průřez je jen proťatý tvar.
+
+## Kam se u souměrného polovičního řezu kreslí řez?
+- Přes celý obraz
+- [x] Jen na jednu stranu od osy
+- Mimo pohled, bez označení
+- Do razítka
+> Druhá polovina zůstane pohledem. Dělí je osa, ne tlustá hrana.
diff --git a/content/technicka-grafika/3-rocnik/03-bloky.en.md b/content/technicka-grafika/3-rocnik/03-bloky.en.md
new file mode 100644
index 0000000..f9b3ccc
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/03-bloky.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Blocks
+popis: A repeated part as one object with a base point.
+nazev_cviceni: Quiz: blocks
+nadpis_kvizu: Insert the block correctly
+---
diff --git a/content/technicka-grafika/3-rocnik/03-bloky.md b/content/technicka-grafika/3-rocnik/03-bloky.md
new file mode 100644
index 0000000..933c73f
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/03-bloky.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-bloky
+nazev: Bloky
+popis: Opakovaný díl jako jeden objekt se základním bodem.
+nazev_cviceni: Kvíz: bloky
+nadpis_kvizu: Vložte blok správně
+---
+
+# Výklad
+
+## K čemu blok je
+*Jeden díl*
+
+- Blok je pojmenovaná skupina objektů, která se vkládá jako jeden kus.
+- Šroub, značka nebo razítko se nekreslí pokaždé znovu.
+- Úprava definice bloku se propíše do všech vložení. Kopie by zůstaly staré.
+- Blok patří do výkresu. `_WBLOCK` ho navíc uloží do vlastního souboru.
+
+> Tip: čtyři stejné šrouby jsou čtyři vložení jednoho bloku, ne čtyři překreslené šrouby.
+
+## Základní bod
+*Kam se chytá*
+
+- `_BLOCK` se zeptá na jméno, základní bod a objekty.
+- Základní bod je místo, za které budete blok vkládat. U šroubu třeba průsečík osy a dosedací plochy.
+- Špatný základní bod posune každé vložení o stejnou chybu.
+- Objekty bloku nedávejte na hladinu 0, pokud nemají přebírat barvu místa vložení. Na škole nechte jejich vlastní hladiny.
+
+> Tip: základní bod chytejte úchopem. Bod „někde u šroubu“ se při vložení mine o několik milimetrů.
+
+## Vložení
+*Místo, měřítko, úhel*
+
+- `_INSERT` vybere jméno, bod vložení, měřítko a otočení.
+- Měřítko 1 a úhel 0 vloží blok tak, jak byl nakreslen.
+- Měřítko 2 ho zvětší. Na výkresu 1:1 se měřítko bloku nemění, mění se až měřítko výřezu.
+- Rozbití bloku příkazem `_EXPLODE` z něj zase udělá obyčejné čáry. Dělejte to jen když máte měnit jediné vložení.
+
+> Tip: rozbitý blok se už neaktualizuje s definicí. Rozbijte ho, jen když opravdu chcete výjimku.
+
+## Zadání: šroub M8 jako blok
+*Cvičení*
+
+- V nárysu nakreslete zjednodušený šroub: dřík Ø8 dlouhý 20 a hlava široká 13 vysoká 5,5.
+- Základní bod dejte na osu, do dosedací plochy pod hlavou.
+- Blok pojmenujte SROUB-M8 a vložte ho dvakrát, po 40 mm.
+- Změňte délku dříku v definici na 25. Obě vložení se musí prodloužit.
+
+> Tip: dosedací plocha je rozhraní hlavy a dříku. Od ní se měří, jak hluboko šroub sedí.
+
+
+# Kvíz
+
+## Čím se liší blok od obyčejné kopie?
+- Ničím
+- [x] Úprava definice se propíše do všech vložení
+- Blok nejde otočit
+- Kopie zabere méně místa
+> Kopie jsou nezávislé. Vložení bloku sdílejí jednu definici.
+
+## Co je základní bod bloku?
+- Střed razítka
+- [x] Bod, za který se blok vkládá
+- Vždy 0,0 výkresu
+- Pravý horní roh objektů
+> Špatný základní bod posune každé vložení. Chytejte ho úchopem na osu nebo na dosedací plochu.
+
+## Kdy blok rozbít příkazem `_EXPLODE`?
+- Vždy po vložení
+- [x] Jen když jedno vložení má být výjimka
+- Aby se zmenšil soubor
+- Před každým tiskem
+> Rozbitý blok se s definicí už neaktualizuje. Ostatní vložení nechte celé.
+
+## Měřítko vložení 1 na výkresu 1:1 znamená?
+- Blok se kreslí poloviční
+- [x] Blok se vloží ve velikosti, v jaké byl nakreslen
+- Blok se převrátí
+- Měřítko razítka se změní na 1:2
+> Měřítko výkresu řeší až výřez na papíře. Blok součásti nechte v měřítku 1.
diff --git a/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.en.md b/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.en.md
new file mode 100644
index 0000000..b19bfc1
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Attributes and the parts list
+popis: Text in a block that changes with every insert.
+nazev_cviceni: Quiz: attributes
+nadpis_kvizu: Read the parts list
+---
diff --git a/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.md b/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.md
new file mode 100644
index 0000000..03df34e
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/04-atributy-a-kusovnik.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-atributy
+nazev: Atributy a kusovník
+popis: Text v bloku, který se mění s každým vložením.
+nazev_cviceni: Kvíz: atributy
+nadpis_kvizu: Přečtěte kusovník
+---
+
+# Výklad
+
+## Atribut je popisek bloku
+*Značka*
+
+- Atribut je text uložený v bloku. Při každém vložení se zeptá znovu.
+- `_ATTDEF` založí značku, výzvu a výchozí hodnotu. Značka je třeba OZN.
+- Do bloku se atribut vloží spolu s geometrií. Samotný atribut na výkresu nic neopakuje.
+- Hodnotu už vloženého bloku změníte `_ATTEDIT` nebo dvojklikem, ne přepsáním čáry.
+
+> Tip: značka OZN není text, který se tiskne. Tiskne se hodnota, třeba 1 nebo M8×20.
+
+## Kusovník
+*Co je na výkresu*
+
+- Kusovník je tabulka nad razítkem. Řádek je jedna položka sestavy.
+- Sloupce bývají pozice, název, počet, materiál nebo norma a rozměr.
+- Pozice v kolečku na výkresu musí souhlasit s řádkem tabulky.
+- Počet se počítá z vložení, ne od oka. Dva stejné šrouby jsou počet 2, ne dva řádky.
+
+> Tip: tabulku `_TABLE` dejte na hladinu TEXT nebo RAM. Čáry tabulky nejsou obrys součásti.
+
+## Pořadí
+*Nejdřív definice*
+
+- Nejdřív nakreslete díl, přidejte atribut, teprve pak z toho udělejte blok.
+- Atribut přidaný až po vytvoření bloku se do starých vložení sám nedostane.
+- `_BATTMAN` nebo nové definování bloku vložení srovná. Na škole je jistější blok založit znovu.
+- Výchozí hodnota atributu je nápověda. U šroubu třeba M8×20, a při vložení ji smíte změnit.
+
+> Tip: prázdná hodnota v kusovníku znamená, že jste při vložení výzvu odklepli bez textu.
+
+## Zadání: tři položky
+*Cvičení*
+
+- Sestava má přírubu (pozice 1, 1 kus, ocel), šroub M8×20 (pozice 2, 4 kusy) a matici M8 (pozice 3, 4 kusy).
+- Šroub je blok s atributem OZN. Čtyři vložení mají stejnou hodnotu.
+- Kusovník nad razítkem má tři řádky. Pozice 2 má počet 4, ne 1.
+- Odkazové čáry z koleček 1, 2 a 3 míří na správný díl a nekříží se bez důvodu.
+
+> Tip: čtyři matice jsou pořád jedna položka. Do tabulky nepište čtyři řádky se stejným šroubem.
+
+
+# Kvíz
+
+## Co se při vložení bloku s atributem ptá znovu?
+- Barva hladiny
+- [x] Hodnota atributu, třeba označení
+- Základní bod definice
+- Měřítko výkresu
+> Geometrie bloku zůstává. Mění se text, který jste založili jako atribut.
+
+## Čtyři stejné šrouby v kusovníku jsou?
+- Čtyři řádky
+- [x] Jeden řádek s počtem 4
+- Pozice 1 až 4
+- Atribut bez bloku
+> Stejný díl je jedna položka. Počet říká, kolikrát je vložený.
+
+## Kdy se atribut musí založit?
+- Až po tisku
+- [x] Před vytvořením bloku, spolu s geometrií
+- Jen v razítku
+- Atribut nejde uložit do bloku
+> Atribut přidaný později se do existující definice sám nepřidá.
+
+## Kam patří kusovník?
+- Přes nárys
+- [x] Nad razítko
+- Na hladinu OBRYS
+- Mimo rámeček
+> Čte se spolu s razítkem. Čáry tabulky nejsou hrany součásti.
diff --git a/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.en.md b/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.en.md
new file mode 100644
index 0000000..14a29ca
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Layouts and viewports
+popis: The sheet of paper that looks into the model.
+nazev_cviceni: Quiz: viewports
+nadpis_kvizu: Set the viewport scale
+---
diff --git a/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.md b/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.md
new file mode 100644
index 0000000..0f26aa9
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/05-rozvrzeni-a-vyrezy.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-rozvrzeni
+nazev: Rozvržení a výřezy
+popis: Papír, na kterém leží pohled do modelu.
+nazev_cviceni: Kvíz: výřezy
+nadpis_kvizu: Nastavte měřítko výřezu
+---
+
+# Výklad
+
+## Model a papír
+*Dva prostory*
+
+- V modelu je součást v milimetrech 1:1. Papír je rozvržení, záložka nad příkazovým řádkem.
+- Na papír přijde rámeček, razítko a výřez. Výřez je okno do modelu.
+- Dvojklik dovnitř výřezu přepne do modelu. Dvojklik ven vrátí papír.
+- Kóty součásti nechte v modelu. Razítko kreslete v rozvržení, ať má na papíře stálou velikost.
+
+> Tip: rámeček nakreslený v modelu se při změně měřítka výřezu zmenší spolu se součástí. Proto patří na papír.
+
+## Měřítko výřezu
+*1:2 na papíře*
+
+- Výřez má vlastní měřítko. Součást v modelu zůstává 1:1.
+- Měřítko 1:2 znamená, že 1 mm na papíře je 2 mm na součásti.
+- Ve stavovém řádku výřezu se měřítko vybírá ze seznamu. Starší zápis je `_ZOOM` a `1/2xp`.
+- Výřez zamkněte, ať kolečko myši měřítko omylem nerozhodí.
+
+> Tip: nezamčený výřez je nejčastější důvod, proč ráno výkres není 1:1, i když včera byl.
+
+## Co je vidět
+*Hladiny ve výřezu*
+
+- Hladinu lze ve výřezu zmrazit a v modelu ji nechat. Pomocné čáry tak na papír nejdou.
+- Rámeček výřezu dejte na hladinu VYREZ a tu před tiskem vypněte. Na papíře zůstane jen obsah.
+- Jeden výkres smí mít víc výřezů: čelo, řez, detail v jiném měřítku.
+- Detail 2:1 je druhý výřez. V razítku se pak píše měřítko hlavní, detail má vlastní popisku.
+
+> Tip: detail zvětšený 2:1 nekreslete znovu. Je to stejný model v druhém výřezu.
+
+## Zadání: A3 a měřítko 1:2
+*Cvičení*
+
+- Rozvržení nastavte na A3 na šířku. Rámeček a razítko nakreslete v rozvržení.
+- Výřez ukazuje přírubu Ø100 v měřítku 1:2. Na papíře má mít průměr 50 mm.
+- Výřez zamkněte. Hladinu jeho rámečku vypněte.
+- `_DIST` na papíře přes průměr příruby má ukázat 50, ne 100.
+
+> Tip: měření v rozvržení, mimo výřez, čte milimetry papíru. 100 mm součásti v měřítku 1:2 je na papíře 50 mm.
+
+
+# Kvíz
+
+## Kde se kreslí tvar součásti v měřítku 1:1?
+- V rozvržení
+- [x] V modelu
+- V razítku
+- V hladině VYREZ
+> Model zůstává skutečný. Měřítko pro papír nastavuje až výřez.
+
+## Součást Ø100 ve výřezu 1:2 má na papíře průměr?
+- 100 mm
+- [x] 50 mm
+- 200 mm
+- 1 mm
+> 1:2 zmenšuje na polovinu. Kóta ve modelu pořád říká 100.
+
+## Proč výřez zamknout?
+- Aby nešel tisknout
+- [x] Aby kolečko myši nezměnilo měřítko
+- Zamknutí smaže model
+- Aby šel rámeček smazat
+> Nezamčený výřez se při zoomu rozhodí a výkres už není v měřítku z razítka.
+
+## Kam patří rámeček a razítko?
+- Do modelu, na hladinu OBRYS
+- [x] Do rozvržení
+- Do definice bloku šroubu
+- Mimo papír
+> V rozvržení mají stálou velikost bez ohledu na měřítko výřezu.
diff --git a/content/technicka-grafika/3-rocnik/06-tisk.en.md b/content/technicka-grafika/3-rocnik/06-tisk.en.md
new file mode 100644
index 0000000..2111b75
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/06-tisk.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Plotting and PDF
+popis: The sheet, the plot area and a black pen.
+nazev_cviceni: Quiz: plotting
+nadpis_kvizu: Choose the plotter settings
+---
diff --git a/content/technicka-grafika/3-rocnik/06-tisk.md b/content/technicka-grafika/3-rocnik/06-tisk.md
new file mode 100644
index 0000000..9294a6f
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/06-tisk.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-tisk
+nazev: Tisk a PDF
+popis: Papír, oblast tisku a černobílé pero.
+nazev_cviceni: Kvíz: tisk
+nadpis_kvizu: Vyberte nastavení plotru
+---
+
+# Výklad
+
+## Plotr
+*Na papír nebo do PDF*
+
+- `_PLOT` tiskne aktuální rozvržení. Oblast tisku nechte Layout, ne Extents.
+- Tiskárna DWG To PDF uloží výkres, který jde odevzdat bez posunu měřítka.
+- Formát papíru musí sedět s rozvržením. A3 v tisku a A4 v rozvržení ořízne razítko.
+- Měřítko tisku z rozvržení je 1:1. Měřítko součásti už drží výřez.
+
+> Tip: tisk 1:2 z rozvržení, které už má výřez 1:2, zmenší výkres podruhé. Na papíře bude 1:4.
+
+## Pero
+*Černá čára*
+
+- Tabulka per monochrome.ctb tiskne vše černě a tloušťku bere z hladin.
+- Barevný tisk ve škole nechte jen na náhled. Odevzdává se černobílý výkres.
+- Tloušťka obrysu 0,5 mm a tenká čára 0,25 mm musí být v tabulce nebo na hladině vidět.
+- Náhled tisku ukáže, jestli razítko neleze z papíru. Bez náhledu netiskněte.
+
+> Tip: když je na PDF všechno vlásečnicové, tloušťky hladin se netisknou. V `_PLOT` je zapněte.
+
+## Co do PDF nepatří
+*Čistý výkres*
+
+- Rámeček výřezu je vypnutý. Pomocné konstrukce taky.
+- Kóty jsou čitelné a nejsou přes šrafu, pokud to šlo jinak.
+- PDF pojmenujte jako výkres, třeba priruba-A3.pdf, ne Plot1.pdf.
+- Vytiskněte i kontrolní kópii na papír, když učitel odevzdání na papíře chce.
+
+> Tip: otevřete PDF a změřte rámeček A3. Má mít 420 × 297 mm. Jinak je špatně formát nebo měřítko tisku.
+
+## Zadání: PDF příruby
+*Cvičení*
+
+- Rozvržení A3, výřez 1:1 nebo 1:2 podle toho, co se vejde i s kótami.
+- `_PLOT`, tiskárna DWG To PDF, oblast Layout, měřítko 1:1, pero monochrome.ctb.
+- Náhled zkontroluje, že razítko je celé a šrafa není černá plocha.
+- Soubor uložte jako priruba-A3.pdf vedle dwg.
+
+> Tip: hustá šrafa v náhledu znamená velké měřítko vzoru nebo malé. Vraťte se do `_HATCH` a rozteč zvětšete.
+
+
+# Kvíz
+
+## Jaké měřítko tisku se dává při tisku rozvržení?
+- Stejné jako měřítko výřezu, znovu
+- [x] 1:1
+- Fit to paper
+- 2:1, ať je výkres větší
+> Měřítko součásti už má výřez. Tisk papíru je 1:1, jinak se měřítko zdvojí.
+
+## K čemu je monochrome.ctb?
+- Zvětší písmo
+- [x] Tiskne černě a dodrží tloušťky
+- Zamkne výřez
+- Smaže hladinu OSA
+> Barevné čáry na obrazovce jsou pro práci. Odevzdává se černý výkres s tlustým obrysem.
+
+## Proč nenechat oblast tisku na Extents?
+- Extents je rychlejší
+- [x] Vzalo by i objekty mimo papír a rozhodilo by měřítko
+- Extents netiskne kóty
+- Extents vyžaduje 3D
+> Layout tiskne papír tak, jak jste ho složili. Extents hledá vše nakreslené a měřítko si dopočítá samo.
+
+## Co má mít rámeček A3 v hotovém PDF?
+- 210 × 297 mm
+- [x] 420 × 297 mm
+- 100 × 70 mm
+- Libovolný rozměr, rozhoduje obrazovka
+> A3 je 420 × 297 mm. Když PDF vyjde menší, je špatně formát papíru nebo měřítko tisku.
diff --git a/content/technicka-grafika/3-rocnik/07-izometrie.en.md b/content/technicka-grafika/3-rocnik/07-izometrie.en.md
new file mode 100644
index 0000000..609e526
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/07-izometrie.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Isometric drawing
+popis: A pictorial view that does not shrink edges the way perspective does.
+nazev_cviceni: Quiz: isometrics
+nadpis_kvizu: Pick the axis and the ellipse
+---
diff --git a/content/technicka-grafika/3-rocnik/07-izometrie.md b/content/technicka-grafika/3-rocnik/07-izometrie.md
new file mode 100644
index 0000000..3dda985
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/07-izometrie.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-izometrie
+nazev: Izometrie
+popis: Názorný obraz, ve kterém se rozměry nezkreslují stejně jako v perspektivě.
+nazev_cviceni: Kvíz: izometrie
+nadpis_kvizu: Vyberte osu a elipsu
+---
+
+# Výklad
+
+## Tři osy
+*120 stupňů*
+
+- Izometrie má tři osy po 120 stupních. Svislá zůstává svislá.
+- Délky se nanášejí ve skutečné velikosti. Není to perspektiva, hrany se do dálky nezkracují.
+- `_ISODRAFT` přepne kreslicí rovinu. F5 cyklí mezi levou, pravou a horní rovinou.
+- Orto v izometrii drží čáry podél aktuálních os, ne podél obyčejného X a Y.
+
+> Tip: než nakreslíte hranu, podívejte se, která izorovina svítí. Čára v špatné rovině neleží na stěně.
+
+## Kružnice je elipsa
+*Isokružnice*
+
+- Kružnice v izometrii se kreslí jako elipsa. `_ELLIPSE` a volba Isocircle.
+- Nejdřív F5 na rovinu, ve které kružnice leží. Horní díra chce horní rovinu.
+- Průměr se zadává skutečný. Elipsa ho zobrazí zkoseně, kóta zůstává Ø.
+- Obyčejná kružnice `_CIRCLE` v izometrii je chyba. Na papíře by byla pořád kulatá a neladila by s osami.
+
+> Tip: díra na horní stěně a díra na přední stěně jsou dvě různě natočené elipsy. F5 mezi nimi přepněte.
+
+## K čemu izometrie je
+*Názornost*
+
+- Izometrie nenahrazuje nárys a řez. Výrobu řídí okótované pohledy.
+- Na školní práci bývá izometrie v rohu, bez kót nebo jen s hlavními.
+- Skryté hrany se v izometrii často vynechají, ať obraz zůstane čitelný.
+- Tlustý obrys nechte na viditelných hranách. Osy děr smíte naznačit tenčeji.
+
+> Tip: když se izometrie neshoduje s nárysem, platí nárys. Izometrii překreslete.
+
+## Zadání: kvádr s dírou
+*Cvičení*
+
+- V izometrii nakreslete kvádr 60 × 40 × 30.
+- Na horní stěně je díra Ø16 uprostřed. Použijte isokružnici v horní rovině.
+- Soustředně naznačte tenčí elipsu, jen pokud by šlo o zahloubení. Tady zahloubení není.
+- Porovnejte rozměry s nárysem téhož kvádru. 60, 40 a 30 musí sedět na obou.
+
+> Tip: střed horní stěny najdete v polovině šířky a hloubky. Elipsa se středem v rohu je špatně.
+
+
+# Kvíz
+
+## Čím se v izometrii kreslí kružnice?
+- `_CIRCLE`
+- [x] `_ELLIPSE` s volbou Isocircle
+- `_ARC` o 360 stupňů
+- `_POLYGON`
+> Obyčejná kružnice zůstane kulatá a s izometrickými osami neladí.
+
+## Co přepíná klávesa F5 při zapnutém `_ISODRAFT`?
+- Hladiny
+- [x] Izorovina: levá, pravá, horní
+- Měřítko tisku
+- Orto
+> Každá stěna má vlastní natočení elipsy. F5 vybere stěnu, na kterou kreslíte.
+
+## Zkracují se v izometrii hrany směrem dozadu?
+- Ano, jako v perspektivě
+- [x] Ne, délky se nanášejí skutečné
+- Jen svislé
+- Jen při měřítku 1:2
+> Izometrie není perspektiva. Hrana dlouhá 60 mm se nanese jako 60 mm.
+
+## Co platí, když izometrie nesedí s nárysem?
+- Platí izometrie, je názornější
+- [x] Platí okótovaný nárys
+- Oba se nechají a učitel si vybere
+- Smaže se razítko
+> Výrobu určují pohledy a kóty. Izometrie je doplněk a musí se jim přizpůsobit.
diff --git a/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.en.md b/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.en.md
new file mode 100644
index 0000000..64e0b7f
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Assignment: a sleeve
+popis: A sectioned front view, a second view, dimensions and an A3 plot.
+nazev_cviceni: Quiz: the sleeve
+nadpis_kvizu: Check the sleeve drawing
+---
diff --git a/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.md b/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.md
new file mode 100644
index 0000000..f6d3a4f
--- /dev/null
+++ b/content/technicka-grafika/3-rocnik/08-zadani-pouzdro.md
@@ -0,0 +1,80 @@
+---
+id: tg-3-zadani-pouzdro
+nazev: Zadání: pouzdro
+popis: Nárys v řezu, půdorys, kóty a tisk na A3.
+nazev_cviceni: Kvíz: pouzdro
+nadpis_kvizu: Zkontrolujte výkres pouzdra
+---
+
+# Výklad
+
+## Tvar
+*Rozměry*
+
+- Pouzdro je dutý válec. Vnější Ø60, díra Ø36, délka 80.
+- Na pravém konci je osazení: vnější Ø48 v délce 20. Díra Ø36 jde skrz.
+- Na Ø60 jsou čtyři díry M6 do hloubky 12, na roztečné kružnici Ø48. Nejsou průchozí.
+- Závit M6 se v nárysu kreslí zjednodušeně: velký průměr tenkou čarou, malý tlustou, v délce 12 mm.
+
+> Tip: rozteč děr M6 je Ø48, tedy 24 mm od osy. Hloubka 12 není délka pouzdra.
+
+## Pohledy
+*Řez a bok*
+
+- Nárys je úplný řez osou. Šrafuje se stěna, díra Ø36 zůstane prázdná.
+- Půdorys nebo bokorys ukáže čtyři díry M6 na Ø48. Jednu okótujte 4× M6 a hloubku 12.
+- Evropské promítání: druhý pohled pod nárysem nebo vpravo, zarovnaný.
+- Izometrii přidejte vpravo dole, bez kót, ať je tvar jasný.
+
+> Tip: v řezu osou jsou vidět dvě protilehlé díry M6, pokud rovina A-A jimi prochází. Hloubka 12 se kótuje tady.
+
+## Výkres
+*A3*
+
+- Model 1:1. Rozvržení A3, měřítko výřezu 1:1, tisk 1:1, monochrome.ctb.
+- Hladiny OBRYS, OSA, SKRYTE, SRAFA, KOTA, ZAVIT, RAM.
+- Razítko: Pouzdro, ocel, 1:1. Kóty Ø60, Ø36, Ø48, délka 80, osazení 20 a Ø48, 4× M6 hloubka 12.
+- Závitová hladina je tenká. Není to obrys válce.
+
+> Tip: malý průměr závitu M6 je zhruba 5 mm. Tenká čára velkého průměru je 6 mm. Neměňte je za Ø36.
+
+## Kontrola
+*Odevzdání*
+
+- Stěna v řezu má tloušťku (60 − 36) / 2 = 12 mm.
+- Osazení dlouhé 20 nešrafujte jinak než zbytek stěny. Je to pořád jeden kus.
+- PDF priruba nahraďte souborem pouzdro-A3.pdf. Razítko je celé na papíře.
+- Žádná kóta není přepsaná. `_DIST` přes vnější průměr dá 60.
+
+> Tip: dva kusy by měly opačný sklon šrafy. Tady je kus jeden, šrafa má jeden směr.
+
+
+# Kvíz
+
+## Jaká je tloušťka stěny pouzdra Ø60 s dírou Ø36?
+- 24 mm
+- [x] 12 mm
+- 36 mm
+- 6 mm
+> Rozdíl průměrů je 24 mm materiálu na obě strany. Na jednu stěnu připadá 12 mm.
+
+## Jak se zjednodušeně kreslí závit M6?
+- Obě čáry tlusté
+- [x] Velký průměr tenkou čarou, malý tlustou
+- Jen osou
+- Šrafou ANSI31
+> Tenká čára je velký průměr 6 mm. Tlustá je malý průměr, u M6 zhruba 5 mm.
+
+## Kam patří čtyři díry M6?
+- Průchozí skrz Ø36
+- [x] Do hloubky 12 mm na roztečné kružnici Ø48
+- Na osazení Ø48 jako drážka
+- Do razítka
+> Nejsou to průchozí díry hlavní dutiny. Hloubka 12 a rozteč Ø48 jsou v zadání.
+
+## Proč má šrafa pouzdra jeden směr?
+- Dva směry jsou zakázané vždy
+- [x] Je to jeden kus
+- Řez je poloviční
+- Měřítko je 1:2
+> Opačný sklon by říkal, že se dotýkají dva díly. Osazení je pořád totéž pouzdro.
diff --git a/content/technicka-grafika/4-rocnik/01-prostor-3d.en.md b/content/technicka-grafika/4-rocnik/01-prostor-3d.en.md
new file mode 100644
index 0000000..b7db361
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/01-prostor-3d.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Model space in 3D
+popis: Axes, the user coordinate system and orbiting the model.
+nazev_cviceni: Quiz: space
+nadpis_kvizu: Find the axis and the AutoCAD edition
+---
diff --git a/content/technicka-grafika/4-rocnik/01-prostor-3d.md b/content/technicka-grafika/4-rocnik/01-prostor-3d.md
new file mode 100644
index 0000000..2c94303
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/01-prostor-3d.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-prostor
+nazev: Modelový prostor ve 3D
+popis: Osy, uživatelský souřadný systém a obíhání kolem modelu.
+nazev_cviceni: Kvíz: prostor
+nadpis_kvizu: Najděte osu a verzi AutoCADu
+---
+
+# Výklad
+
+## Tři osy
+*X, Y, Z*
+
+- K ose X a Y přibude Z. Na obrazovce při startu míří Z k vám.
+- Pravidlo pravé ruky: prsty od X k Y, palec ukazuje Z.
+- `_BOX` a další tělesa potřebují plný AutoCAD. Verze LT tělesa nevytvoří.
+- 2D výkres z dřívějška zůstává. 3D model je druhý soubor, nebo stejný model v jiném pohledu.
+
+> Tip: když příkaz `_BOX` AutoCAD nezná, máte LT. Tělesa v ní nejdou.
+
+## Pohled
+*Orbit*
+
+- ViewCube v rohu nebo `_ORBIT` otáčí pohled. Model se neotáčí, otáčí se kamera.
+- Přední, horní a levý pohled odpovídají nárysu, půdorysu a bokorysu.
+- `_UCS` posune a natočí souřadnice. Nová tělesa vznikají vůči aktuálnímu UCS.
+- `_UCS` a volba World vrátí původní osy. Ztracený UCS je nejčastější zmatek ve 3D.
+
+> Tip: než vysunete profil, zkontrolujte ikonu os. Z má trčet z plochy, kterou vysouváte.
+
+## Drát a stínování
+*Ať něco vidíte*
+
+- Drátový model ukáže všechny hrany, i zadní. Na začátku se v něm líp trefuje.
+- Stínovaný pohled schová zadní hrany. Přepíná se `_VSCURRENT` nebo nabídkou pohledu.
+- Zoom kolečkem pořád platí. Výřez na papír přijde až v lekci o výkresu z modelu.
+- Jednotky zůstávají milimetry. Těleso 60 × 40 × 30 je v tomtéž souboru jako 2D profil.
+
+> Tip: ztracený model najdete `_ZOOM` Extents. Často leží daleko od počátku, protože UCS byl posunutý.
+
+## Zadání: tři osy a kvádr
+*Cvičení*
+
+- V novém souboru v milimetrech nakreslete `_BOX` se základnou 60 × 40 a výškou 30, roh v 0,0,0.
+- Přepněte na přední, horní a levý pohled. V každém má být vidět jiná dvojice rozměrů.
+- `_UCS` World a `_DIST` mezi dvěma rohy podél X má dát 60.
+- Uložte jako kvadr-60x40x30.dwg. LT na tento soubor nestačí.
+
+> Tip: přední pohled ukáže 60 × 30, horní 60 × 40, levý 40 × 30.
+
+
+# Kvíz
+
+## Který AutoCAD umí tělesa `_BOX`?
+- Každá verze včetně LT
+- [x] Plný AutoCAD, ne LT
+- Jen verze na Mac bez příkazů
+- Žádný, tělesa jsou v jiném programu
+> LT je 2D. Příkaz `_BOX` v ní není.
+
+## Co dělá `_ORBIT`?
+- Otočí těleso v souřadnicích
+- [x] Otočí pohled kamery
+- Nastaví měřítko tisku
+- Smaže osu Z
+> Model zůstane. Změní se, odkud se na něj díváte.
+
+## Co vrátí souřadnice do původního směru?
+- `_ZOOM` Extents
+- [x] `_UCS` a volba World
+- Vypnutí hladiny 0
+- `_PLOT`
+> Posunutý UCS nechá nová tělesa „ležet na boku“. World je výchozí osy výkresu.
+
+## Co ukáže přední pohled na kvádr 60 × 40 × 30, když 30 je výška?
+- 60 × 40
+- [x] 60 × 30
+- 40 × 30
+- Jen osu Z
+> Přední pohled je šířka a výška. Hloubka 40 je v půdorysu a v bokorysu.
diff --git a/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.en.md b/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.en.md
new file mode 100644
index 0000000..8684db3
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Solids and extrude
+popis: A box, a cylinder and a profile pulled up by a height.
+nazev_cviceni: Quiz: extrude
+nadpis_kvizu: Pick the profile and the height
+---
diff --git a/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.md b/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.md
new file mode 100644
index 0000000..d115dcf
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/02-telesa-a-vysunuti.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-vysunuti
+nazev: Tělesa a vysunutí
+popis: Kvádr, válec a profil tažený o výšku.
+nazev_cviceni: Kvíz: vysunutí
+nadpis_kvizu: Vyberte profil a výšku
+---
+
+# Výklad
+
+## Hotová tělesa
+*Kvádr a válec*
+
+- `_BOX` chce roh nebo střed základny, půdorys a výšku.
+- `_CYLINDER` chce střed, poloměr nebo průměr a výšku.
+- Výška se měří ve směru osy Z aktuálního UCS.
+- Tato tělesa jsou plná. Nejsou to stěny z ploch.
+
+> Tip: válec Ø40 a výška 25 má v příkazu buď poloměr 20, nebo volbu průměru 40.
+
+## Vysunutí profilu
+*Z 2D do výšky*
+
+- `_EXTRUDE` vezme uzavřenou křivku nebo oblast a táhne ji o výšku.
+- Profil musí být zavřený. Čtyři nenapojené úsečky vysunout nejdou.
+- Kladná výška jde ve směru Z. Záporná na opačnou stranu.
+- `_PRESSPULL` umí přitáhnout uzavřenou oblast, na kterou kliknete, bez předchozího výběru.
+
+> Tip: obdélník 80 × 50 vysunutý o 12 je destička z druháku, teď jako těleso.
+
+## Profil nejdřív ve 2D
+*Plocha*
+
+- Profil nakreslete v rovině, ze které chcete táhnout. Nejčastěji půdorys, Z ven z obrazovky.
+- `_REGION` z uzavřené křivky udělá jednu plochu. Vysouvá se spolehlivěji než volné čáry.
+- Po vysunutí profil smíte smazat, nebo ho nechte na hladině PROFIL a tu zmrazte.
+- Jeden profil, jedna výška. Osazení vznikne až druhým tělesem a booleanem v další lekci.
+
+> Tip: otevřená křivka se vysune na plochu, ne na těleso. Pro součást potřebujete těleso.
+
+## Zadání: destička jako těleso
+*Cvičení*
+
+- Profil 120 × 70, `_EXTRUDE` o 10 mm. To je tloušťka destičky.
+- Díru zatím nekreslete. Ověřte `_MASSPROP`: objem má být 120 × 70 × 10 = 84 000 mm³.
+- Přední pohled ukáže 120 × 10. Horní pohled 120 × 70.
+- Uložte jako desticka-3d.dwg.
+
+> Tip: když objem vyjde dvojnásobek, výška je 20 místo 10. Kóta textu to nespraví, `_MASSPROP` čte model.
+
+
+# Kvíz
+
+## Jaký poloměr má `_CYLINDER` o průměru 40?
+- 40
+- [x] 20
+- 80
+- 10
+> Příkaz bez přepnutí bere poloměr. Průměr je volba, nebo napište polovinu.
+
+## Co `_EXTRUDE` potřebuje, aby vzniklo těleso?
+- Otevřenou úsečku
+- [x] Uzavřený profil
+- Razítko
+- Už hotový válec
+> Neuzavřený profil vytvoří jen plochu. Těleso má mít objem.
+
+## Kam míří kladná výška vysunutí?
+- Vždy doprava na obrazovce
+- [x] Ve směru osy Z aktuálního UCS
+- Do záporného X
+- Do razítka
+> Špatně natočený UCS vysune destičku „na bok“. Před příkazem zkontrolujte osy.
+
+## Objem destičky 120 × 70 × 10 mm je?
+- 840 mm³
+- [x] 84 000 mm³
+- 200 mm³
+- 12 000 mm³
+> 120 × 70 = 8 400, krát 10 je 84 000. `_MASSPROP` má ukázat totéž.
diff --git a/content/technicka-grafika/4-rocnik/03-rotace.en.md b/content/technicka-grafika/4-rocnik/03-rotace.en.md
new file mode 100644
index 0000000..4f4c6b4
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/03-rotace.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Revolving a profile
+popis: A part symmetric about an axis is the section spun around.
+nazev_cviceni: Quiz: revolve
+nadpis_kvizu: Pick the profile and the axis
+---
diff --git a/content/technicka-grafika/4-rocnik/03-rotace.md b/content/technicka-grafika/4-rocnik/03-rotace.md
new file mode 100644
index 0000000..9a77666
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/03-rotace.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-rotace
+nazev: Rotace profilu
+popis: Součást souměrná podle osy vznikne otočením řezu.
+nazev_cviceni: Kvíz: rotace
+nadpis_kvizu: Vyberte profil a osu
+---
+
+# Výklad
+
+## Kdy rotovat
+*Místo skládání válců*
+
+- `_REVOLVE` otočí uzavřený profil kolem osy o zadaný úhel.
+- 360 stupňů udělá plné rotační těleso. 180 udělá polovinu.
+- Hodí se na hřídel, pouzdro, přírubu a každou součást souměrnou podle osy.
+- Profil je polovina řezu. Osa rotace je osa součásti a profil ji smí jen lemovat, ne křížit.
+
+> Tip: pouzdro z třetího ročníku je jeden profil stěny otočený o 360 stupňů, ne válec minus válec. Oba postupy jsou správně, rotace je u čistě rotačního dílu kratší.
+
+## Profil
+*Polovina*
+
+- Nakreslete jen polovinu řezu, vpravo od osy. Uzavřete ho.
+- U pouzdra Ø60 / Ø36 dlouhého 80 je profil obdélník 12 × 80. Dvanáct je tloušťka stěny.
+- Osa je svislá hrana díry. Profil se osy dotýká jen vnější? Ne: vnitřní hrana profilu je o poloměr díry od osy.
+- Vnitřní hrana leží 18 mm od osy, vnější 30 mm. Šířka profilu je 12 mm.
+
+> Tip: 36 / 2 = 18 a 60 / 2 = 30. Profil je pás mezi těmito poloměry.
+
+## Osa a úhel
+*360*
+
+- Osu vyberte dva body, nebo jednu úsečku nakreslenou na hladině OSA.
+- Úhel 360. Menší úhel nechá výseč a na školní hřídeli bývá omyl.
+- Profil nesmí osu křížit. Křížení AutoCAD odmítne, nebo udělá těleso, které se protíná samo.
+- Po rotaci profil zmrazte. V řezu ho zase použijete jako kontrolu.
+
+> Tip: když vznikne plný válec bez díry, profil začínal na ose. Vnitřní hrana musí být 18 mm od osy, ne 0.
+
+## Zadání: pouzdro rotací
+*Cvičení*
+
+- Osa je úsečka z 0,0 do 0,80.
+- Profil: obdélník s rohy 18,0 a 30,80.
+- `_REVOLVE` o 360 stupňů kolem této osy.
+- `_MASSPROP` objem mezikruží: π × (30² − 18²) × 80. Má vyjít zhruba 144 764 mm³.
+
+> Tip: 900 − 324 = 576, krát π je asi 1 810, krát 80 je asi 144 800. Řád tisíců, ne desítek.
+
+
+# Kvíz
+
+## Jaký úhel udělá plné rotační těleso?
+- 90
+- 180
+- [x] 360
+- 45
+> Polovina otáčky je výseč. Uzavřené pouzdro potřebuje celých 360 stupňů.
+
+## Kde leží vnitřní hrana profilu pouzdra Ø36?
+- Na ose
+- [x] 18 mm od osy
+- 36 mm od osy
+- 60 mm od osy
+> Poloměr díry je polovina průměru. Profil díru ohraničuje, nesmí začínat na ose, jinak díra zmizí.
+
+## Co se stane, když profil osu kříží?
+- Vznikne správné pouzdro
+- [x] AutoCAD rotaci odmítne, nebo těleso protne samo sebe
+- Profil se jen posune
+- Vznikne šrafa
+> Osa smí být hranou profilu, ne smí jím procházet skrz.
+
+## Proč rotovat pouzdro místo skládání dvou válců?
+- Jinak to nejde
+- [x] U rotačního dílu stačí jeden profil
+- Rotace umí i hranaté osazení bez profilu
+- `_REVOLVE` funguje i v LT
+> Oba postupy jsou správně. Rotace je kratší, když je součást souměrná podle osy. LT ji neumí.
diff --git a/content/technicka-grafika/4-rocnik/04-boolean.en.md b/content/technicka-grafika/4-rocnik/04-boolean.en.md
new file mode 100644
index 0000000..e6fc00f
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/04-boolean.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Union, subtract and intersect
+popis: Solids add and subtract like sets.
+nazev_cviceni: Quiz: booleans
+nadpis_kvizu: Pick the order of the solids
+---
diff --git a/content/technicka-grafika/4-rocnik/04-boolean.md b/content/technicka-grafika/4-rocnik/04-boolean.md
new file mode 100644
index 0000000..b833fe4
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/04-boolean.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-boolean
+nazev: Sjednocení, rozdíl a průnik
+popis: Tělesa se sčítají a odečítají jako množiny.
+nazev_cviceni: Kvíz: boolean
+nadpis_kvizu: Vyberte pořadí těles
+---
+
+# Výklad
+
+## Sjednocení
+*Jeden kus*
+
+- `_UNION` slepí tělesa, která se dotýkají nebo pronikají, v jeden objekt.
+- Osazení na hřídeli jsou dva válce. Po sjednocení je to jedna součást.
+- Tělesa, která se nedotýkají, sjednocení také spojí do jedné skupiny, ale výrobek to není.
+- Než sjednotíte, zkontrolujte přesah. Mezera 0,01 mm nechá dva kusy.
+
+> Tip: `_INTERFERE` ukáže, jestli se dvě tělesa opravdu pronikají, nebo se jen zdají dotýkat.
+
+## Rozdíl
+*Díra*
+
+- `_SUBTRACT` nejdřív vyberete těleso, které má zůstat, potvrdíte, a teprve pak těleso, které se odečte.
+- Obrácené pořadí smaže součást a nechá díru jako váleček. To je nejčastější chyba.
+- Díra Ø10 je válec Ø10 delší, než je tloušťka stěny, ať rozdíl prořízne celou stěnu.
+- Válec díry po rozdílu zmizí. Nemusíte ho mazat ručně.
+
+> Tip: válec díry nechte o milimetr přečuhovat z obou stran. Jinak na stěně zůstane blanka.
+
+## Průnik
+*Společný objem*
+
+- `_INTERSECT` nechá jen objem, který mají tělesa společný. Zbytek zmizí.
+- Hodí se, když potřebujete tvar daný dvěma podmínkami, třeba válec seříznutý kvádrem.
+- Na běžnou díru průnik není. Díra je rozdíl.
+- Po průniku zůstanou obě původní tělesa pryč. Když je budete potřebovat, předem je zkopírujte.
+
+> Tip: sjednocení přidá, rozdíl ubere, průnik nechá jen překryv. U díry je to vždy rozdíl.
+
+## Zadání: destička s dírou
+*Cvičení*
+
+- Destička `_BOX` 120 × 70 × 10.
+- Válec Ø16, střed v 60,35, výška od -1 do 11, ať prochází skrz.
+- `_SUBTRACT`: nejdřív destička, potom válec.
+- `_MASSPROP` objem: 84 000 minus objem válce v tloušťce 10. Válec Ø16 a výška 10 má objem π × 8² × 10 ≈ 2 011. Výsledek je asi 81 989 mm³.
+
+> Tip: když zmizela destička a zbyl váleček, pořadí rozdílu bylo obrácené. Vraťte krok zpět.
+
+
+# Kvíz
+
+## Jakým pořadím vznikne díra?
+- Nejdřív válec, potom destička
+- [x] Nejdřív destička, potom válec
+- `_UNION`
+- `_INTERSECT`
+> První výběr zůstává. Druhý se odečte. Obrácené pořadí nechá jen váleček.
+
+## Proč má válec díry přečnívat z destičky?
+- Aby šel okótovat
+- [x] Aby ve stěně nezůstala blanka
+- Přečnívat nesmí, rozdíl by selhal
+- Aby vznikl závit
+> Válec zarovnaný přesně s plochou někdy nechá tenkou slupku. Milimetr přesahu ji odstraní.
+
+## Který příkaz slepí dva dotýkající se válce hřídele?
+- `_SUBTRACT`
+- [x] `_UNION`
+- `_INTERSECT`
+- `_REVOLVE`
+> Osazení je jeden kus. Sjednocení z dvou válců udělá jedno těleso.
+
+## Co nechá `_INTERSECT`?
+- Součet objemů
+- [x] Jen společný překryv
+- Díru
+- Původní tělesa vedle výsledku
+> Zbytek obou těles zmizí. Před průnikem si je zkopírujte, pokud je ještě budete potřebovat.
diff --git a/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.en.md b/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.en.md
new file mode 100644
index 0000000..cd46853
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Sectioning a solid and Flatshot
+popis: From the model back to lines for the drawing.
+nazev_cviceni: Quiz: the model section
+nadpis_kvizu: Decide what lands on the sheet
+---
diff --git a/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.md b/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.md
new file mode 100644
index 0000000..c8f2f53
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/05-rez-a-flatshot.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-rez-modelu
+nazev: Řez tělesem a plochý snímek
+popis: Z modelu zpátky čáry pro výkres.
+nazev_cviceni: Kvíz: řez modelu
+nadpis_kvizu: Vyberte, co vznikne na papíře
+---
+
+# Výklad
+
+## Řezná rovina
+*Uvnitř tělesa*
+
+- `_SECTIONPLANE` položí rovinu řezu. Živé zobrazení ukáže řez, aniž by těleso rozřízlo natrvalo.
+- `_SLICE` těleso rozřízne doopravdy. Polovinu můžete nechat, nebo smazat.
+- Na školní výkres řežte živě nebo si před `_SLICE` těleso zkopírujte. Původní model budete ještě potřebovat.
+- Řez osou pouzdra má ukázat stěnu 12 mm a díru Ø36, stejně jako ruční řez ve třetím ročníku.
+
+> Tip: rovina, která mine osu, ukáže jinou tloušťku stěny. U pouzdra veďte řez osou.
+
+## Plochý snímek
+*Čáry z pohledu*
+
+- `_FLATSHOT` promítne aktuální pohled do čar v rovině XY.
+- Viditelné hrany dá na jednu hladinu, skryté na druhou. Skryté pak přepněte na čárkované.
+- Než příkaz spustíte, nastavte pohled: přední pro nárys, horní pro půdorys.
+- Snímek je 2D. Dá se kótovat jako výkres z druháku. Model vedle něj nechte nedotčený.
+
+> Tip: plochý snímek pořízený v šikmém pohledu není nárys. Nejdřív srovnejte pohled, potom foťte.
+
+## Co snímek neudělá
+*Kóty a šrafa*
+
+- Snímek neumí šrafu řezu ani kóty. Ty doplníte vy, na hladiny SRAFA a KOTA.
+- Neumí ani razítko. To patří do rozvržení.
+- Čáry snímku bývají na nových hladinách. Přesuňte je na OBRYS a SKRYTE, ať platí školní tloušťky.
+- Když model potom změníte, snímek se sám neopraví. Po úpravě modelu ho vytvořte znovu.
+
+> Tip: výkresové pohledy z pozdější lekce se na rozdíl od snímku umí s modelem aktualizovat. Snímek je rychlá kontrola.
+
+## Zadání: nárys z tělesa
+*Cvičení*
+
+- Otevřete pouzdro z rotace. Přední pohled, `_FLATSHOT`, viditelné hrany na OBRYS.
+- Do řezu osou doplňte šrafu stěny ručně, nebo použijte živý řez a z něj čáry.
+- Okótujte Ø60, Ø36 a délku 80. Čísla musí sedět s `_MASSPROP` a s poloměry profilu.
+- Snímek nesmí zůstat šikmo. Nárys je v rovině, díra je obdélník 36 × 80 uvnitř obdélníku 60 × 80.
+
+> Tip: v nárysu řezu jsou dvě vodorovné čáry díry. Jejich vzdálenost je 36.
+
+
+# Kvíz
+
+## Čím rozříznete těleso natrvalo?
+- `_FLATSHOT`
+- [x] `_SLICE`
+- `_ORBIT`
+- `_PLOT`
+> Živá řezná rovina model nechá celý. `_SLICE` ho rozdělí. Předtím si nechte kopii.
+
+## Co musí předcházet plochému snímku nárysu?
+- Tisk
+- [x] Srovnaný přední pohled
+- Rozbití tělesa
+- Šrafa
+> Snímek promítne to, co právě vidíte. Šikmý orbit není nárys.
+
+## Doplní `_FLATSHOT` kóty?
+- Ano, z hladiny KOTA
+- [x] Ne
+- Jen průměry
+- Jen když je měřítko 1:1
+> Snímek jsou čáry. Kóty, šrafu a razítko dodáte vy.
+
+## Jak daleko jsou v nárysu řezu pouzdra od sebe čáry díry Ø36?
+- 18
+- 60
+- [x] 36
+- 12
+> V řezu je díra obdélník. Jeho výška je průměr, ne poloměr.
diff --git a/content/technicka-grafika/4-rocnik/06-zobrazeni.en.md b/content/technicka-grafika/4-rocnik/06-zobrazeni.en.md
new file mode 100644
index 0000000..3a7cfc8
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/06-zobrazeni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Visual styles
+popis: Wireframe, hidden lines and shading for checking the shape.
+nazev_cviceni: Quiz: display
+nadpis_kvizu: Pick a style for the task
+---
diff --git a/content/technicka-grafika/4-rocnik/06-zobrazeni.md b/content/technicka-grafika/4-rocnik/06-zobrazeni.md
new file mode 100644
index 0000000..687c0a4
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/06-zobrazeni.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-zobrazeni
+nazev: Vizuální styly
+popis: Drát, skryté hrany a stínování pro kontrolu tvaru.
+nazev_cviceni: Kvíz: zobrazení
+nadpis_kvizu: Vyberte styl podle úkolu
+---
+
+# Výklad
+
+## Drát a skryté hrany
+*Kontrola*
+
+- Drátový model ukáže každou hranu. Hodí se, když saháte na zadní vrchol.
+- Styl skrytých hran schová, co je za materiálem. Tvar se čte jako na fotografii bez stínu.
+- `_VSCURRENT` styly přepíná. Na pásu karet je stejná nabídka u pohledu.
+- Styl nemění těleso. Špatně odečtená díra zůstane špatně v každém stylu.
+
+> Tip: nejdřív drát, ať trefíte hranu, potom skryté hrany, ať zkontrolujete, že díra opravdu prochází.
+
+## Stínování
+*Objem*
+
+- Konceptuální nebo stínovaný styl ukáže, jestli je díra průchozí, nebo je to jen namalovaná kružnice.
+- Barva tělesa se bere z hladiny. OBRYS a těleso smí mít vlastní hladinu MODEL.
+- Materiál a render na škole stačí náznak. Odevzdává se výkres, ne obrázek.
+- Stínování zpomalí otáčení. Na slabším počítači se vraťte do drátu, než budete modelovat dál.
+
+> Tip: když v řezu vidíte dno díry, válec byl krátký. Rozdíl stěnu neprošel.
+
+## Řezy bez rozbití
+*Pohled*
+
+- Živý řez v kombinaci se stínováním je nejrychlejší kontrola tloušťky stěny.
+- Pohled uložte `_VIEW`, ať se k němu vrátíte. Název třeba REZ-AA.
+- Izometrický stínovaný pohled smí přijít na výkres jako názorný doplněk, ne jako jediný obraz.
+- Barvy na obrazovce pořád tiskněte černě. Styl zobrazení není tabulka per.
+
+> Tip: uložený pohled nese i styl. Když REZ-AA otevřete a je z něj drát, někdo styl přepsal.
+
+## Zadání: tři kontroly destičky
+*Cvičení*
+
+- Destičku s dírou Ø16 zobrazte jako drát, jako skryté hrany a stínovaně.
+- Ve stínovaném horním pohledu musí být skrz díru vidět. Když není, opakujte rozdíl.
+- Uložte pohled HORNI a pohled PREDNI.
+- Do výkresu tyto pohledy zatím nevkládejte. Jde o kontrolu modelu.
+
+> Tip: díra, která ve stínu má dno stejné barvy jako destička, je slepá. Výška válce byla menší než 10 mm.
+
+
+# Kvíz
+
+## Který styl nechá vidět i zadní hrany?
+- Stínovaný
+- [x] Drátový
+- Skryté hrany
+- Monochrome.ctb
+> Drát nekryje nic. Skryté hrany a stín zadní geometrii schovají.
+
+## Změní vizuální styl objem tělesa?
+- Ano, stín ubere materiál
+- [x] Ne
+- Jen u rozdílu
+- Jen při tisku
+> Styl je způsob kresby na obrazovce. `_MASSPROP` zůstane stejný.
+
+## Co máte vidět skrz průchozí díru ve stínovaném pohledu?
+- Dno stejné barvy
+- [x] Prostor za destičkou
+- Šrafu ANSI31
+- Razítko
+> Dno znamená, že válec díry byl krátký a rozdíl stěnu neprošel.
+
+## Čím se na papír dostane černá čára, když je model na obrazovce modrý?
+- Vizuálním stylem
+- [x] Tabulkou per při tisku
+- Smazáním hladiny
+- `_ORBIT`
+> Barva na monitoru je pracovní. monochrome.ctb z ní na PDF udělá černou.
diff --git a/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.en.md b/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.en.md
new file mode 100644
index 0000000..4ad88b7
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: A drawing from the 3D model
+popis: A front view and a section that update with the model.
+nazev_cviceni: Quiz: model views
+nadpis_kvizu: Tie the sheet to the solid
+---
diff --git a/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.md b/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.md
new file mode 100644
index 0000000..00b0c55
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/07-vykres-z-modelu.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-vykres-z-modelu
+nazev: Výkres z 3D modelu
+popis: Nárys a řez, které se s modelem aktualizují.
+nazev_cviceni: Kvíz: pohledy z modelu
+nadpis_kvizu: Propojte výkres s tělesem
+---
+
+# Výklad
+
+## Základní pohled
+*Z tělesa*
+
+- V rozvržení příkaz základního pohledu vezme těleso a položí na papír nárys, půdorys nebo izometrii.
+- Měřítko pohledu nastavíte jako u výřezu, třeba 1:1 nebo 1:2.
+- Odvozené pohledy se položí nad, pod nebo vedle. Drží evropské promítání, když ho tak nastavíte.
+- Pohled je svázaný s modelem. Změna tělesa a aktualizace pohledu přepíše čáry.
+
+> Tip: plochý snímek se sám neopraví. Pohled z modelu ano. Na maturitní výkres použijte pohled.
+
+## Řez pohledu
+*A-A*
+
+- Z základního pohledu uděláte řez tažením řezné čáry. Písmena A doplní AutoCAD, nebo je přepište na A.
+- Šrafu řezu nastavte na ANSI31 a školní měřítko. Hřídel v podélném řezu pořád nešrafujte, i když to AutoCAD nabídne.
+- Skryté čáry v pohledu vypněte, pokud řez vnitřek ukázal. Výkres zůstane čistý.
+- Kóty klikejte na pohled, ne do modelu naslepo. Berou rozměr z tělesa.
+
+> Tip: kóta napojená na pohled se po aktualizaci přepočítá. Kóta nakreslená vedle jako obyčejná čára ne.
+
+## Papír
+*A3*
+
+- Rámeček a razítko jsou v rozvržení, měřítko 1:1 vůči papíru.
+- Tisk je Layout, 1:1, monochrome.ctb, jako ve třetím ročníku.
+- Izometrický pohled bez kót smí být třetí obraz. Nesmí být jediný.
+- Než odevzdáte, aktualizujte pohledy. Starý pohled lže, když jste model mezitím změnili.
+
+> Tip: ve stromu nebo přes aktualizaci pohledu uvidíte, jestli je obraz aktuální. Nevěřte čáře, která přežila smazanou díru.
+
+## Zadání: pouzdro na A3
+*Cvičení*
+
+- Z 3D pouzdra vytvořte nárys jako řez A-A a půdorys nebo bokorys s roztečí, pokud na modelu díry jsou.
+- Měřítko 1:1, formát A3. Kóty Ø60, Ø36 a 80.
+- Změňte v modelu délku na 90, aktualizujte pohled a přesvědčte se, že kóta délky ukáže 90.
+- Vraťte délku na 80, ať odevzdáte zadání, ne zkoušku. PDF pojmenujte pouzdro-z-modelu.pdf.
+
+> Tip: když po změně modelu kóta zůstane 80, není napojená na pohled. Smažte ji a okótujte znovu.
+
+
+# Kvíz
+
+## Čím se pohled z modelu liší od `_FLATSHOT`?
+- Ničím
+- [x] Po změně tělesa se dá aktualizovat
+- Umí jen izometrii
+- Funguje v LT
+> Snímek je jednorázová kopie čar. Pohled zůstává svázaný s tělesem.
+
+## Kam patří rámeček výkresu z modelu?
+- Dovnitř tělesa
+- [x] Do rozvržení
+- Na hladinu OBRYS modelu
+- Do definice bloku
+> Papír má vlastní měřítko. Rámeček v modelu by se zmenšil s pohledem.
+
+## Co uděláte po změně délky pouzdra v modelu?
+- Nic, kóty se nemění nikdy
+- [x] Aktualizujete pohled
+- Smažete razítko
+- Přepnete na LT
+> Neaktualizovaný pohled ukazuje starý tvar. Kóta napojená na něj se přepočítá spolu s ním.
+
+## Jaké měřítko má tisk tohoto rozvržení?
+- 1:2, protože model je velký
+- [x] 1:1
+- Podle `_MASSPROP`
+- Fit
+> Měřítko obrazu drží pohled. Tisk papíru je 1:1, jinak se měřítko zdvojí.
diff --git a/content/technicka-grafika/4-rocnik/08-maturitni-zadani.en.md b/content/technicka-grafika/4-rocnik/08-maturitni-zadani.en.md
new file mode 100644
index 0000000..5bfcb04
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/08-maturitni-zadani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Exam assignment
+popis: From a sketch through the model to a PDF that stands on its own.
+nazev_cviceni: Quiz: the exam piece
+nadpis_kvizu: Walk the whole drawing
+---
diff --git a/content/technicka-grafika/4-rocnik/08-maturitni-zadani.md b/content/technicka-grafika/4-rocnik/08-maturitni-zadani.md
new file mode 100644
index 0000000..19d47ea
--- /dev/null
+++ b/content/technicka-grafika/4-rocnik/08-maturitni-zadani.md
@@ -0,0 +1,80 @@
+---
+id: tg-4-maturitni-zadani
+nazev: Maturitní zadání
+popis: Od náčrtu přes model po PDF, které obstojí samo.
+nazev_cviceni: Kvíz: maturita
+nadpis_kvizu: Projdete celý výkres
+---
+
+# Výklad
+
+## Součást
+*Přírubové pouzdro*
+
+- Dutý válec Ø70 / Ø40, délka 60. Na jednom konci příruba Ø110 tlustá 12.
+- Čtyři díry Ø11 na roztečné kružnici Ø90 v přírubě. Jsou průchozí skrz přírubu, ne skrz celé pouzdro.
+- Vnitřní hrana díry Ø40 má zkosení 1 × 45° na straně příruby.
+- Materiál ocel. Jednotky milimetry. Model 1:1.
+
+> Tip: tloušťka stěny válce je (70 − 40) / 2 = 15 mm. Příruba přesahuje válec o (110 − 70) / 2 = 20 mm.
+
+## Model
+*Pořadí*
+
+- Profil řezu rotujte, nebo složte válec Ø70, válec příruby Ø110 výšky 12 a odečtěte díru Ø40.
+- Čtyři díry Ø11 vzniknou polárním polem válce na poloměru 45 a rozdílem od příruby.
+- Válec díry Ø11 musí být delší než 12 mm, ať je díra průchozí. Do dutiny Ø40 díra Ø11 nezasahuje, leží v materiálu příruby.
+- `_MASSPROP` spočítejte orientačně a porovnejte s ručním odhadem. Řád objemu je statisíce mm³, ne tisíce.
+
+> Tip: rozteč Ø90 je poloměr 45. Díra se středem na poloměru 55 by už ležela mimo přírubu Ø110.
+
+## Výkres
+*A3, 1:1*
+
+- Nárys je řez A-A osou. Půdorys ukáže čtyři díry. Izometrie bez kót je doplněk.
+- Kóty: Ø110, Ø70, Ø40, tloušťka příruby 12, délka 60, 4× Ø11 na Ø90, zkosení 1 × 45°.
+- Hladiny OBRYS, OSA, SRAFA, KOTA, RAM. Šrafa jen v řezu, jeden směr, díry prázdné.
+- Razítko: Přírubové pouzdro, ocel, 1:1, jméno. Tisk A3, Layout, 1:1, monochrome.ctb.
+
+> Tip: evropské promítání. Půdorys pod nárysem. Čtyři díry se kótují jednou.
+
+## Než odevzdáte
+*Kontrola*
+
+- Každá kóta se dá z modelu ověřit. Žádný text není přepsaný.
+- Vypnutá hladina KOTA nechá čistý tvar. Vypnutá SRAFA nechá obrys řezu.
+- PDF se jmenuje prirubove-pouzdro-A3.pdf a rámeček má 420 × 297 mm.
+- Soubor dwg odevzdejte spolu s PDF. Bez modelu výkres nejde opravit.
+
+> Tip: otevřete PDF na celou stránku. Když je razítko useknuté nebo šrafa černá, tisk opakujte. Na obrazovce AutoCADu to vidět nebylo.
+
+
+# Kvíz
+
+## Jaká je tloušťka stěny válce Ø70 s dírou Ø40?
+- 30 mm
+- [x] 15 mm
+- 40 mm
+- 12 mm
+> Rozdíl průměrů je 30 mm na obě strany. Jedna stěna má 15 mm. 12 mm je tloušťka příruby, ne stěny.
+
+## Kam patří čtyři díry Ø11?
+- Skrz celou délku 60
+- [x] Skrz přírubu tlustou 12, na Ø90
+- Do díry Ø40
+- Na osu jako závit M11
+> Leží v materiálu příruby mezi Ø70 a Ø110. Rozteč 90, poloměr 45.
+
+## Jaké měřítko má model i tisk tohoto výkresu?
+- Model 1:2, tisk 1:2
+- [x] Model 1:1, tisk rozvržení 1:1
+- Model 2:1, tisk Fit
+- Měřítko se do razítka nepíše
+> Součást i papír jsou ve skutečných milimetrech. Měřítko pohledu je 1:1, protože se A3 vejde.
+
+## Co odevzdáváte spolu s PDF?
+- Jen izometrii
+- [x] I soubor dwg
+- Rozbitý blok razítka
+- Výkres bez kót, kóty jsou v PDF
+> Z PDF už model neopravíte. DwG je práce, PDF je odevzdaný tisk.
diff --git a/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.en.md b/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.en.md
new file mode 100644
index 0000000..46524d9
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.en.md
@@ -0,0 +1,12 @@
+---
+nazev: Computer architecture
+popis: 3 slides about von Neumann and the model, then a quiz.
+nazev_cviceni: Quiz: computer architecture
+nadpis_kvizu: Quiz on computer architecture
+---
+
+# Překlady
+
+| Česky | Anglicky |
+|---|---|
+| ALU = aritmeticko-logická jednotka | ALU = arithmetic-logic unit |
diff --git a/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.md b/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.md
new file mode 100644
index 0000000..1affea6
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/01-architektura-pocitace.md
@@ -0,0 +1,81 @@
+---
+id: hardware-1-architektura-pocitace
+puvodni: hw.1
+nazev: Architektura počítače
+popis: 3 snímky o Von Neumannovi a schématu a pak kvíz.
+nazev_cviceni: Kvíz: architektura počítače
+nadpis_kvizu: Kvíz k architektuře počítače
+---
+
+# Výklad
+
+## Architektura počítače
+*Úvod*
+
+- Architektura počítače je základní model, podle kterého jsou počítače navrhovány.
+- Nejznámější model je Von Neumannovo schéma.
+- Používá se dodnes u většiny běžných počítačů.
+
+> Architektura popisuje, z jakých částí se počítač skládá a jak spolu spolupracují.
+
+## John von Neumann
+*Osobnost*
+
+- John von Neumann byl americký matematik.
+- Pracoval na poli digitálních počítačů a operační teorie kvantové mechaniky.
+- Je autorem Von Neumannovy algebry.
+- Byl tvůrcem teorie her.
+- Podílel se na vývoji atomové a vodíkové bomby.
+
+> Tip: zapamatuj si jméno – podle něj se jmenuje celé schéma.
+
+## Von Neumannovo schéma
+*Schéma*
+
+- Von Neumannovo schéma má pět hlavních částí:
+- ALU – aritmeticko-logická jednotka (výpočty a logika)
+- Řadič – řídí činnost ostatních částí
+- Operační paměť – uchovává program i data
+- Vstupní jednotka – přivádí data do počítače
+- Výstupní jednotka – předává výsledky ven
+
+> ALU = aritmeticko-logická jednotka
+
+
+# Kvíz
+
+## Kdo je autorem Von Neumannova schématu?
+- Alan Turing
+- [x] John von Neumann
+- Bill Gates
+- Steve Jobs
+
+## Čím byl John von Neumann?
+- [x] Americký matematik
+- Německý fyzik
+- Anglický chemik
+- Francouzský lékař
+
+## Co znamená zkratka ALU?
+- Automatická logická síť
+- [x] Aritmeticko-logická jednotka
+- Analogová linková sestava
+- Adresová listová sekce
+
+## Která část Von Neumannova schématu řídí ostatní části?
+- Operační paměť
+- Vstupní jednotka
+- [x] Řadič
+- Výstupní jednotka
+
+## Kde se podle Von Neumannova schématu uchovává program i data?
+- Jen na pevném disku
+- [x] V operační paměti
+- Jen ve vstupní jednotce
+- Jen v ALU
+
+## Čím se John von Neumann také zabýval?
+- Jen malířstvím
+- [x] Teorií her
+- Jen botanikou
+- Jen hudbou
diff --git a/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.en.md b/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.en.md
new file mode 100644
index 0000000..6ec9ca6
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: History of computers
+popis: 6 slides about computer generations, then a quiz.
+nazev_cviceni: Quiz: history of computers
+nadpis_kvizu: Quiz on the history of computers
+---
diff --git a/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.md b/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.md
new file mode 100644
index 0000000..24cc5c1
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/02-historie-pocitacu.md
@@ -0,0 +1,133 @@
+---
+id: hardware-1-historie-pocitacu
+puvodni: hw.2
+nazev: Historie počítačů
+popis: 6 snímků o generacích počítačů a pak kvíz.
+nazev_cviceni: Kvíz: historie počítačů
+nadpis_kvizu: Kvíz k historii počítačů
+---
+
+# Výklad
+
+## Historie počítačů
+*Přehled*
+
+- Předchůdci do 19. století jsou mechanické pomůcky. Pascaline sčítala ozubenými koly, Babbage navrhl analytický stroj a Ada Lovelace k němu popsala postup výpočtu. Elektronika ještě chyběla.
+- Nultá generace, zhruba 1938–1944, spíná elektromagnetická relé. Stroje jsou kusové a často vojenské.
+- První generace, 1944–1955, bere elektronku. Počítač zabere sál a program se dává děrnými štítky.
+- Druhá generace, 1955–1964, nahrazuje elektronku tranzistorem. Objevují se vyšší programovací jazyky.
+- Třetí generace, 1964–1971, staví na integrovaném obvodu. Vznikají rodiny počítačů a operační systémy.
+- Čtvrtá generace od roku 1971 staví na mikroprocesoru. Počítač se vejde na stůl a později do kapsy.
+
+> Generace se dělí podle součástky, která v té době počítala: relé, elektronka, tranzistor, integrovaný obvod a nakonec mikroprocesor.
+
+## Počítače 0. generace (1938–1944)
+*0. generace*
+
+- Relé je elektromagnetický spínač. Je pomalejší a hlučnější než pozdější elektronka, ale umí už skládat logické obvody.
+- Konrad Zuse dokončil v roce 1941 Z3: programovatelný binární stroj s plovoucí řádovou čárkou.
+- Harvard Mark I (1944) byl elektromechanický a počítal balistické tabulky pro americkou armádu.
+- Každý stroj se stavěl na zakázku. Obsluhoval ho tým, ne jeden člověk u stolu.
+- Program býval na děrné pásce nebo na přepínačích, ne v dnešní operační paměti.
+- Typická práce byla vojenská: šifry a tabulky střelby, ne kancelář nebo domácnost.
+
+> Tip: počítačů byly jednotky kusů a často sloužily armádě. Základem je relé, ne elektronka.
+
+## Počítače 1. generace (1944–1955)
+*1. generace*
+
+- ENIAC (1945) měl kolem 18 tisíc elektronek a počítal hlavně balistiku.
+- Von Neumannova architektura ukládá program i data do stejné paměti. Na tom stojí i dnešní počítače.
+- UNIVAC I (1951) byl první sériově prodávaný komerční počítač v USA.
+- Program a data se děrovaly do štítků. Operátor vložil balík a čekal na výsledek.
+- Operační systém ani vyšší jazyk ještě nebyl. Psalo se ve strojovém kódu.
+- Sál potřeboval klimatizaci a stálý dohled. Do domácnosti se takový stroj nevešel.
+
+> Klíčový objev je elektronka. Spíná rychleji než relé, ale hřeje, často se pálí a počítač kvůli ní zabere celý sál.
+
+## Počítače 2. generace (1955–1964)
+*2. generace*
+
+- Tranzistor nahradí elektronku: je menší, spolehlivější a méně se hřeje.
+- Stroj už nemusí zabírat celý sál, pořád je to ale zařízení pro firmu nebo ústav.
+- Vnitřní paměť bývá feritová. Vnější paměť jsou magnetické pásky a bubny.
+- FORTRAN (1957) a COBOL (1959) dovolí psát program blíž lidské řeči než strojový kód.
+- Úlohy se zpracovávají dávkově. Operátor seřadí balíky a počítač je jede postupně.
+- Typické stroje této doby jsou IBM 1401 a IBM 7090.
+
+> Klíčový objev je tranzistor. V Bellových laboratořích vznikl roku 1947 a do počítačů se dostal zhruba od roku 1955.
+
+## Počítače 3. generace (1964–1971)
+*3. generace*
+
+- IBM System/360 (1964) je rodina počítačů se stejným instrukčním souborem. Program z menšího modelu běží i na větším.
+- Operační systém už řídí více úloh a periferie. Objevuje se sdílení času mezi uživateli.
+- Magnetický disk doplňuje pásku a zkracuje čekání na data.
+- Minipočítače jako PDP-8 se vejdou do jedné skříně. Kupují je laboratoře a firmy, ne jen stát.
+- Stroj je menší a levnější než sál první generace, pořád to ale není osobní počítač na stůl.
+- Hlavní vnější pamětí zůstává magnetické médium, ne děrný štítek.
+
+> Klíčový objev je integrovaný obvod: mnoho tranzistorů na jednom křemíkovém čipu. Kilby a Noyce ho představili na konci 50. let.
+
+## Počítače 4. generace
+*4. generace*
+
+- Intel 4004 z roku 1971 je první komerční mikroprocesor. Pozdější čipy z něj udělaly mozek osobního počítače.
+- Na stůl se počítač dostává postupně: Altair 1975, Apple II 1977 a IBM PC 1981.
+- Roku 1972 vychází Pong, první komerčně úspěšná arkádová hra. Lekce ji bere jako první počítačovou hru.
+- Roku 1971 představuje IBM osmipalcovou disketu. Menší formáty přišly až v dalších letech.
+- Roku 1979 Philips a Sony předvádějí kompaktní disk. V obchodech je od roku 1982.
+- Paměti a disky rostou, takže počítač opouští sál a později se vejde i do kapsy.
+
+> Klíčová součástka je programovatelný mikroprocesor: celý procesor na jednom čipu.
+
+
+# Kvíz
+
+## Kdy probíhala nultá generace počítačů?
+- [x] 1938–1944
+- 1944–1955
+- 1955–1964
+- 1964–1971
+
+## Co bylo typické pro počítače 0. generace?
+- Byly v každé domácnosti
+- [x] Jen několik kusů, často vojenský účel
+- Měly mikroprocesory
+- Používaly CD
+
+## Která technologie patří k 1. generaci?
+- Tranzistor
+- [x] Elektronka
+- Integrovaný obvod
+- Mikroprocesor
+
+## Čím se zadávaly data v 1. generaci?
+- USB fleškou
+- [x] Děrnými štítky
+- CD
+- Disketou
+
+## Která technologie patří k 2. generaci?
+- Elektronka
+- [x] Tranzistor
+- Mikroprocesor
+- CD
+
+## Která technologie patří k 3. generaci?
+- Elektronka
+- Tranzistor
+- [x] Integrovaný obvod
+- Disketa
+
+## Co je typické pro 4. generaci?
+- Jen děrné štítky
+- [x] Programovatelné mikroprocesory
+- Jen vojenské použití
+- Žádný software
+
+## Kdy vznikla první počítačová hra (podle lekce)?
+- 1964
+- [x] 1972
+- 1973
+- 1979
diff --git a/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.en.md b/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.en.md
new file mode 100644
index 0000000..07f58d5
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Data representation
+popis: 2 slides about bits and bytes, then a quiz.
+nazev_cviceni: Quiz: data representation
+nadpis_kvizu: Quiz on data representation in computers
+---
diff --git a/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.md b/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.md
new file mode 100644
index 0000000..93a950f
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/03-zobrazovani-dat-v-pocitaci.md
@@ -0,0 +1,55 @@
+---
+id: hardware-1-zobrazovani-dat-v-pocitaci
+puvodni: hw.3
+nazev: Zobrazování dat v počítači
+popis: 2 snímky o bitu a bytu a pak kvíz.
+nazev_cviceni: Kvíz: zobrazování dat
+nadpis_kvizu: Kvíz k zobrazování dat v počítači
+---
+
+# Výklad
+
+## Bit – nejmenší jednotka informace
+*Bit*
+
+- Bit je dvojková číslice – nabývá hodnoty 0 nebo 1.
+- Je to nejmenší zobrazitelná jednotka informace.
+- Z bitů se skládají větší jednotky (např. byte).
+
+> Bit = binary digit (dvojková číslice)
+
+## Byte – adresovatelná jednotka paměti
+*Byte*
+
+- Byte je nejmenší adresovatelná jednotka paměti.
+- Počítač tedy paměť adresuje po bytech, ne po jednotlivých bitech.
+- Jeden byte obvykle obsahuje 8 bitů.
+
+> Tip: 1 B = 8 b
+
+
+# Kvíz
+
+## Co je bit?
+- Největší jednotka paměti
+- [x] Dvojková číslice – nejmenší jednotka informace
+- Jen klávesa na klávesnici
+- Typ procesoru
+
+## Jaké hodnoty může nabývat bit?
+- Jen 0
+- Jen 1
+- [x] 0 nebo 1
+- 0 až 255
+
+## Co je byte?
+- [x] Nejmenší adresovatelná jednotka paměti
+- Nejmenší zobrazitelná jednotka informace
+- Jen síťový kabel
+- Jen grafická karta
+
+## Kolik bitů má obvykle jeden byte?
+- 2
+- 4
+- [x] 8
+- 16
diff --git a/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.en.md b/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.en.md
new file mode 100644
index 0000000..463c275
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Integers and reals
+popis: 2 slides about integer and real, then a quiz.
+nazev_cviceni: Quiz: integer and real
+nadpis_kvizu: Quiz on integers and floating-point numbers
+---
diff --git a/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.md b/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.md
new file mode 100644
index 0000000..b8a7429
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/04-cela-a-desetinna-cisla.md
@@ -0,0 +1,59 @@
+---
+id: hardware-1-cela-a-desetinna-cisla
+puvodni: hw.4
+nazev: Celá a desetinná čísla
+popis: 2 snímky o integer a real a pak kvíz.
+nazev_cviceni: Kvíz: integer a real
+nadpis_kvizu: Kvíz k celým a desetinným číslům
+---
+
+# Výklad
+
+## Proměnná typu integer (celá čísla)
+*Integer*
+
+- Celá čísla se ukládají binárně v pevné řádové čárce.
+- Mají pevný počet bitů – standardně 16 nebo 32 (tedy 2 nebo 4 byty).
+- Při 16bitovém formátu je 2¹⁶ = 65 536 různých hodnot; u nezáporných čísel interval 0 až 2¹⁶ − 1, tedy 0–65 535.
+- Obecně u n bitů je M = 2ⁿ různých hodnot (u 16 bitů je nejvyšší nezáporné číslo 65 535).
+- Ostatní čísla se do tohoto intervalu převádějí transformací (mapováním na přirozená čísla z daného rozsahu).
+
+> Tip: n bitů → 2ⁿ různých hodnot (např. 0 až 2ⁿ − 1)
+
+## Proměnná typu real (desetinná čísla)
+*Real*
+
+- Desetinná čísla se ukládají v pohyblivé řádové čárce ve tvaru mantisa a exponent.
+- Mantisa je normalizovaná tak, že první platná číslice je hned za desetinnou čárkou.
+- Posun řádové čárky vyrovná odpovídající změna exponentu.
+- Standardně zabírá 4 nebo 8 bytů v paměti.
+- Více bytů se používá jen tehdy, když potřebujeme vyšší přesnost výpočtů.
+
+> Tip: tvar = mantisa × základ^exponent
+
+
+# Kvíz
+
+## Jak se ukládají celá čísla (integer)?
+- Jen jako text
+- [x] Binárně v pevné řádové čárce s pevným počtem bitů
+- Jen jako obrázek
+- Jen v pohyblivé řádové čárce bez exponentu
+
+## Kolik různých hodnot má 16bitový nezáporný integer?
+- 256
+- 1024
+- [x] 65 536 (0 až 65 535)
+- 4
+
+## Jak se ukládají desetinná čísla (real)?
+- Jen jako celá čísla bez tečky
+- [x] V pohyblivé řádové čárce jako mantisa a exponent
+- Jen jako 1 bit
+- Jen jako název souboru
+
+## Co znamená normalizace mantisy?
+- Že se číslo smaže
+- [x] Že první platná číslice je hned za desetinnou čárkou a exponent se upraví
+- Že se použije jen 1 bit
+- Že se číslo uloží jako text
diff --git a/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.en.md b/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.en.md
new file mode 100644
index 0000000..10c9f99
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Analog and digital sound
+popis: 2 slides about turning sound into numbers, then a quiz.
+nazev_cviceni: Quiz: analog and digital
+nadpis_kvizu: Quiz on analog and digital sound
+---
diff --git a/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.md b/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.md
new file mode 100644
index 0000000..9598cbb
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/05-analogovy-a-digitalni-zvuk.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-analogovy-a-digitalni-zvuk
+puvodni: hw.5
+nazev: Analogový a digitální zvuk
+popis: 2 snímky o převodu zvuku na čísla a pak kvíz.
+nazev_cviceni: Kvíz: analog a digitál
+nadpis_kvizu: Kvíz k analogovému a digitálnímu zvuku
+---
+
+# Výklad
+
+## Analogový a digitální zvuk
+*Signál*
+
+- Analogový zvuk je spojitý signál – v každém okamžiku má nějakou hodnotu.
+- Digitální zvuk je posloupnost čísel, která ten signál popisují.
+- Z mikrofonu vychází analogové napětí, do reproduktoru musí zase jít spojitý signál.
+- Počítač umí uložit jen čísla, proto se zvuk digitalizuje.
+
+> Tip: analog je spojitý, digitál je posloupnost čísel.
+
+## A/D a D/A převodník
+*Převod*
+
+- A/D převodník (ADC) převede analogový signál na čísla.
+- D/A převodník (DAC) z čísel znovu složí spojitý signál.
+- Digitalizace má dva kroky: vzorkování a kvantování.
+- Vzorkování určí, kdy se hodnota změří.
+- Kvantování určí, na kolik hladin se amplituda zaokrouhlí.
+
+> Tip: ADC dovnitř, DAC ven. Digitalizace = vzorkování + kvantování.
+
+
+# Kvíz
+
+## Co je analogový zvuk?
+- Posloupnost celých čísel
+- [x] Spojitý signál s hodnotou v každém okamžiku
+- Jen název souboru WAV
+- Jen bit v paměti
+
+## Co dělá A/D převodník (ADC)?
+- [x] Převádí analogový signál na čísla
+- Zesiluje jen reproduktor
+- Maže vysoké tóny po uložení
+- Počítá jen velikost souboru
+
+## Jaké jsou dva hlavní kroky digitalizace zvuku?
+- Komprese a tisk
+- [x] Vzorkování a kvantování
+- Šifrování a záloha
+- Násobení a dělení
+
+## Co dělá D/A převodník (DAC)?
+- [x] Z čísel znovu skládá spojitý signál
+- Měří jen vzorkovací frekvenci
+- Ukládá zvuk jako text
+- Zvyšuje počet bitů v souboru
diff --git a/content/technicke-vybaveni/1-rocnik/06-vzorkovani.en.md b/content/technicke-vybaveni/1-rocnik/06-vzorkovani.en.md
new file mode 100644
index 0000000..d5c4f7e
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/06-vzorkovani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Sampling
+popis: 2 slides about sample rate and Nyquist, then a quiz.
+nazev_cviceni: Quiz: sampling
+nadpis_kvizu: Quiz on sampling sound
+---
diff --git a/content/technicke-vybaveni/1-rocnik/06-vzorkovani.md b/content/technicke-vybaveni/1-rocnik/06-vzorkovani.md
new file mode 100644
index 0000000..d6e790e
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/06-vzorkovani.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-vzorkovani
+puvodni: hw.6
+nazev: Vzorkování
+popis: 2 snímky o vzorkovací frekvenci a Nyquistovi a pak kvíz.
+nazev_cviceni: Kvíz: vzorkování
+nadpis_kvizu: Kvíz ke vzorkování zvuku
+---
+
+# Výklad
+
+## Vzorkování
+*Vzorky*
+
+- Vzorkování měří velikost signálu v pravidelných okamžicích.
+- Mezi vzorky se hodnota neukládá.
+- Vzorkovací frekvence fs je počet vzorků za sekundu.
+- Jednotka je hertz (Hz). 1 kHz = 1000 vzorků za sekundu.
+- Vyšší fs zachytí rychlejší změny, tedy vyšší tóny.
+
+> Tip: vzorkovací frekvence fs = počet vzorků za sekundu (Hz).
+
+## Nyquistův–Shannonův teorém
+*Nyquist*
+
+- Pro věrnou rekonstrukci musí být fs vyšší než dvojnásobek nejvyšší frekvence ve signálu.
+- Nyquistova frekvence je fs / 2 – vyšší tón už záznam neunese.
+- Člověk slyší zhruba do 20 kHz, dvojnásobek je 40 kHz.
+- CD proto používá 44,1 kHz: o něco víc než 40 kHz, aby zbyl prostor pro filtr.
+
+> Tip: fs musí být vyšší než 2 × fmax. Nyquistova frekvence je fs / 2.
+
+
+# Kvíz
+
+## Co je vzorkovací frekvence?
+- Počet bitů v jednom vzorku
+- [x] Počet vzorků za sekundu
+- Délka skladby v minutách
+- Počet kanálů (mono nebo stereo)
+
+## V jakých jednotkách se udává vzorkovací frekvence?
+- V bytech
+- [x] V hertzech
+- Ve voltech
+- V pixelech
+
+## Co říká Nyquistův–Shannonův teorém?
+- [x] fs musí být vyšší než dvojnásobek nejvyšší frekvence
+- fs musí být vždy 8 kHz
+- Stačí jeden vzorek na celou skladbu
+- Bitová hloubka musí být 1
+
+## Proč má audio CD vzorkovací frekvenci 44,1 kHz?
+- Protože byte má 8 bitů
+- [x] Sluch sahá asi do 20 kHz a fs musí být vyšší než dvojnásobek
+- Protože stereo má dva kanály
+- Protože minutu tvoří 60 sekund
diff --git a/content/technicke-vybaveni/1-rocnik/07-kvantovani.en.md b/content/technicke-vybaveni/1-rocnik/07-kvantovani.en.md
new file mode 100644
index 0000000..0c7a7db
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/07-kvantovani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Quantization
+popis: 2 slides about bit depth and quantization error, then a quiz.
+nazev_cviceni: Quiz: quantization
+nadpis_kvizu: Quiz on quantizing sound
+---
diff --git a/content/technicke-vybaveni/1-rocnik/07-kvantovani.md b/content/technicke-vybaveni/1-rocnik/07-kvantovani.md
new file mode 100644
index 0000000..1661378
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/07-kvantovani.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-kvantovani
+puvodni: hw.7
+nazev: Kvantování
+popis: 2 snímky o bitové hloubce a kvantizační chybě a pak kvíz.
+nazev_cviceni: Kvíz: kvantování
+nadpis_kvizu: Kvíz ke kvantování zvuku
+---
+
+# Výklad
+
+## Kvantování
+*Hladiny*
+
+- Kvantování zaokrouhlí amplitudu každého vzorku na nejbližší z konečného počtu hladin.
+- Počet hladin je 2ⁿ, kde n je bitová hloubka (počet bitů na vzorek).
+- 8 bitů dává 256 hladin, 16 bitů dává 65 536 hladin.
+- Vzorkování říká jak často, kvantování jak přesně.
+
+> Tip: n bitů → 2ⁿ hladin. 8 bitů = 256, 16 bitů = 65 536.
+
+## Kvantizační chyba
+*Chyba*
+
+- Kvantizační chyba je rozdíl mezi skutečnou hodnotou a zvolenou hladinou.
+- V záznamu se projeví jako kvantizační šum.
+- Větší bitová hloubka hladiny zjemní a šum zmenší.
+- Audio CD používá 16 bitů na vzorek, studio často 24 bitů.
+- Vyšší hloubka také zvětší datový tok.
+
+> Tip: víc bitů = jemnější hladiny, menší šum a větší soubor.
+
+
+# Kvíz
+
+## Co je kvantování?
+- Měření signálu v pravidelných okamžicích
+- [x] Zaokrouhlení amplitudy na konečný počet hladin
+- Převod čísel zpět na analog
+- Komprese souboru MP3
+
+## Kolik hladin má 8bitové kvantování?
+- 8
+- 16
+- [x] 256
+- 65 536
+
+## Kolik hladin má 16bitové kvantování?
+- 16
+- 256
+- 1 024
+- [x] 65 536
+
+## Co je kvantizační chyba?
+- [x] Rozdíl mezi skutečnou hodnotou a zvolenou hladinou
+- Příliš nízká vzorkovací frekvence
+- Počet kanálů ve stereu
+- Délka skladby v sekundách
diff --git a/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.en.md b/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.en.md
new file mode 100644
index 0000000..80400cb
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.en.md
@@ -0,0 +1,6 @@
+---
+nazev: PCM and data rate
+popis: 2 slides about PCM and recording size, then a quiz.
+nazev_cviceni: Quiz: PCM and data rate
+nadpis_kvizu: Quiz on PCM and data rate
+---
diff --git a/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.md b/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.md
new file mode 100644
index 0000000..ad1af28
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/08-pcm-a-datovy-tok.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-pcm-a-datovy-tok
+puvodni: hw.8
+nazev: PCM a datový tok
+popis: 2 snímky o PCM a velikosti záznamu a pak kvíz.
+nazev_cviceni: Kvíz: PCM a datový tok
+nadpis_kvizu: Kvíz k PCM a datovému toku
+---
+
+# Výklad
+
+## Pulzně kódová modulace
+*PCM*
+
+- PCM uloží každý vzorek jako binární číslo.
+- Je to základní nekomprimovaný záznam (WAV, audio CD).
+- Datový tok = vzorkovací frekvence × bitová hloubka × počet kanálů.
+- Mono má jeden kanál, stereo dva (levý a pravý).
+
+> Tip: tok = fs × bity na vzorek × počet kanálů.
+
+## Kolik dat zabere CD
+*Velikost*
+
+- CD stereo: 44 100 Hz × 16 bit × 2 = 1 411 200 bit/s.
+- To je 176 400 B/s.
+- Za minutu je to 10 584 000 B, tedy přes 10 MB.
+- Delší záznam, vyšší fs, více bitů nebo více kanálů soubor zvětší.
+- MP3 a AAC soubor zmenší za cenu ztráty části informace.
+
+> Tip: komprese (MP3, AAC) soubor zmenší, ale část informace zahodí.
+
+
+# Kvíz
+
+## Co je PCM?
+- [x] Každý vzorek uložený jako binární číslo
+- Jen komprimovaný formát MP3
+- Filtr před mikrofonem
+- Jednotka hlasitosti
+
+## Jak se spočítá datový tok nekomprimovaného zvuku?
+- fs + bity + kanály
+- [x] fs × bitová hloubka × počet kanálů
+- jen délka skladby v minutách
+- počet hladin děleno dvěma
+
+## Jaký je datový tok audio CD ve stereu?
+- 44 100 bit/s
+- 256 bit/s
+- [x] 1 411 200 bit/s
+- 8 bit/s
+
+## Co udělá komprese MP3 se záznamem?
+- Zvětší soubor a přidá vzorky
+- [x] Zmenší soubor a část informace zahodí
+- Změní jen název souboru
+- Převede digitál zpět na analog
diff --git a/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.en.md b/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.en.md
new file mode 100644
index 0000000..3799bdc
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Aliasing and formats
+popis: 2 slides about aliasing and common formats, then a quiz.
+nazev_cviceni: Quiz: aliasing and formats
+nadpis_kvizu: Quiz on aliasing and recording formats
+---
diff --git a/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.md b/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.md
new file mode 100644
index 0000000..3bd9364
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/09-aliasing-a-parametry.md
@@ -0,0 +1,57 @@
+---
+id: hardware-1-aliasing-a-parametry
+puvodni: hw.9
+nazev: Aliasing a parametry
+popis: 2 snímky o aliasingu a běžných formátech a pak kvíz.
+nazev_cviceni: Kvíz: aliasing a parametry
+nadpis_kvizu: Kvíz k aliasingu a parametrům záznamu
+---
+
+# Výklad
+
+## Když je vzorkování moc řídké
+*Aliasing*
+
+- Aliasing vznikne, když je fs příliš nízká a poruší se Nyquistovo pravidlo.
+- Vysoká frekvence se v záznamu jeví jako nižší tón.
+- Příklad: tón 6 kHz vzorkovaný 8 kHz se jeví jako 2 kHz.
+- Antialiasingový filtr před převodníkem vysoké frekvence ořízne.
+
+> Tip: aliasing vznikne před uložením a z hotových vzorků už nejde spolehlivě odstranit.
+
+## Běžné parametry záznamu
+*Formáty*
+
+- Audio CD: 44,1 kHz, 16 bitů, stereo.
+- Klasický telefonní hovor: 8 kHz a 8 bitů – řeč se vejde do pásma do 4 kHz.
+- Studio často používá 48 nebo 96 kHz a 24 bitů.
+- Parametry se volí podle toho, co má záznam unést a jak velký smí být.
+
+> Tip: vyšší fs a víc bitů znamená věrnější záznam a větší tok.
+
+
+# Kvíz
+
+## Kdy vzniká aliasing?
+- [x] Když je vzorkovací frekvence příliš nízká
+- Když má záznam 24 bitů
+- Když je soubor ve stereu
+- Když se použije D/A převodník
+
+## K čemu je antialiasingový filtr?
+- Zvětší bitovou hloubku po uložení
+- [x] Před vzorkováním ořízne frekvence nad Nyquistovu
+- Spočítá datový tok
+- Převede mono na stereo
+
+## Jaké parametry má audio CD?
+- 8 kHz, 8 bitů, mono
+- [x] 44,1 kHz, 16 bitů, stereo
+- 96 kHz, 8 bitů, mono
+- 1 kHz, 1 bit, stereo
+
+## Proč telefonnímu hovoru stačí vzorkování 8 kHz?
+- [x] Řeč se vejde do pásma zhruba do 4 kHz
+- Telefon nahrává na audio CD
+- 8 kHz je totéž co 44,1 kHz
+- Řeč nemá žádnou frekvenci
diff --git a/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.en.md b/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.en.md
new file mode 100644
index 0000000..7ae9d76
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Computer case
+popis: 2 slides about the case, airflow and types, then a quiz.
+nazev_cviceni: Quiz: computer case
+nadpis_kvizu: Quiz on the computer case
+---
diff --git a/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.md b/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.md
new file mode 100644
index 0000000..916dd1c
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/10-pocitacova-skrin.md
@@ -0,0 +1,59 @@
+---
+id: hardware-1-pocitacova-skrin
+puvodni: hw.10
+nazev: Počítačová skříň
+popis: 2 snímky o skříni, airflow a typech a pak kvíz.
+nazev_cviceni: Kvíz: počítačová skříň
+nadpis_kvizu: Kvíz k počítačové skříni
+---
+
+# Výklad
+
+## Počítačová skříň
+*Účel*
+
+- Skříň zastřešuje a drží ostatní komponenty.
+- Součásti se propojují kabely nebo přímo do základní desky.
+- Při provozu vzniká teplo a skříň ho musí odvést.
+- Uzavřená skříň s mřížkami udržuje správné proudění vzduchu (airflow).
+
+> Tip: skříň má být uzavřená, ale s větracími mřížkami.
+
+## Přední a zadní stěna, desktop a tower
+*Stěny*
+
+- Přední stěna: zapnutí, restart, zelená kontrolka chodu a červená kontrolka disku.
+- Vpředu bývají i USB porty a audio vstupy a výstupy.
+- Zadní stěna zpřístupňuje porty desky a přídavných karet.
+- Volné otvory kryjí záslepky, aby se dovnitř neprášilo.
+- Desktop je skříň naležato, monitor se na ni pokládá.
+- Tower stojí nastojato vedle monitoru nebo pod stolem.
+
+> Tip: desktop leží, tower stojí.
+
+
+# Kvíz
+
+## K čemu slouží počítačová skříň?
+- Jen jako ozdoba monitoru
+- [x] Zastřešuje a uchycuje ostatní komponenty
+- Převádí 230 V na 12 V
+- Ukládá operační systém
+
+## Proč má být skříň uzavřená a mít větrací mřížky?
+- Aby se dovnitř vešel jen jeden disk
+- [x] Aby uvnitř správně proudil vzduch a odvádělo se teplo
+- Aby nešel zapnout restart
+- Aby odpadly kabely
+
+## Co typicky najdeme na přední stěně?
+- [x] Tlačítka, kontrolky, USB a audio
+- Jen patici procesoru
+- Jen sloty ISA
+- Jen plotny pevného disku
+
+## Jak se liší desktop a tower?
+- Desktop je bez zdroje, tower bez desky
+- Desktop je jen pro notebooky
+- [x] Desktop leží, tower stojí
+- Liší se jen barvou kontrolek
diff --git a/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.en.md b/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.en.md
new file mode 100644
index 0000000..b294df1
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Power supply
+popis: 2 slides about the ATX supply and power draw, then a quiz.
+nazev_cviceni: Quiz: power supply
+nadpis_kvizu: Quiz on the power supply
+---
diff --git a/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.md b/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.md
new file mode 100644
index 0000000..1351169
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/11-napajeci-zdroj.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-napajeci-zdroj
+puvodni: hw.11
+nazev: Napájecí zdroj
+popis: 2 snímky o zdroji ATX a spotřebě a pak kvíz.
+nazev_cviceni: Kvíz: napájecí zdroj
+nadpis_kvizu: Kvíz k napájecímu zdroji
+---
+
+# Výklad
+
+## Napájecí zdroj
+*Převod*
+
+- Zdroj zásobuje všechny části počítače elektřinou.
+- Převádí střídavé napětí 230 V, 50 Hz na stejnosměrné 3,3 V, 5 V a 12 V.
+- Nejrozšířenější je standard ATX, včetně variant TFX a SFX.
+- ATX umí počítač vypnout softwarově přes základní desku.
+- Výkon se vyrábí v řadě hodnot, třeba od 350 W po 700 W i víc.
+
+> Tip: ze sítě jde střídavých 230 V / 50 Hz.
+
+## Co se napájí přímo a kolik to bere
+*Přívody*
+
+- Přímo ze zdroje: deska, pevné disky, mechaniky, aktivní chladiče a grafické karty.
+- Přes desku: rozšiřující karty, některé ventilátory, procesor, porty a LED kontrolky.
+- V klidu bere sestava zhruba 100 W.
+- V plné zátěži může spotřeba dosáhnout 500–600 W.
+
+> Tip: spotřeba není stálá.
+
+
+# Kvíz
+
+## Co zdroj udělá se síťovým napětím?
+- Nechá 230 V střídavých všude v počítači
+- [x] Převede 230 V střídavých na stejnosměrné 3,3 V, 5 V a 12 V
+- Převede všechno na 230 V stejnosměrných
+- Vyrábí jen 1,5 V pro USB
+
+## Co umožňuje standard ATX?
+- [x] Softwarové vypnutí počítače přes základní desku
+- Jen napájení disketové mechaniky
+- Zrušení větracích mřížek
+- Uložení BIOSu na pásku
+
+## Co se ze zdroje napájí přímo?
+- Jen LED na klávesnici
+- Jen porty na zadní stěně
+- [x] Deska, disky, mechaniky, aktivní chladiče a grafické karty
+- Jen operační paměť
+
+## Jak se mění spotřeba počítače?
+- Je pořád přesně 350 W
+- [x] V klidu asi 100 W, v plné zátěži 500–600 W
+- V klidu 500 W a v zátěži 100 W
+- Nezávisí na tom, co počítač dělá
diff --git a/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.en.md b/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.en.md
new file mode 100644
index 0000000..8001167
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Motherboard
+popis: 2 slides about the board, chipset and BIOS, then a quiz.
+nazev_cviceni: Quiz: motherboard
+nadpis_kvizu: Quiz on the motherboard
+---
diff --git a/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.md b/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.md
new file mode 100644
index 0000000..5971c4b
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/12-zakladni-deska.md
@@ -0,0 +1,57 @@
+---
+id: hardware-1-zakladni-deska
+puvodni: hw.12
+nazev: Základní deska
+popis: 2 snímky o desce, čipsetu a BIOSu a pak kvíz.
+nazev_cviceni: Kvíz: základní deska
+nadpis_kvizu: Kvíz k základní desce
+---
+
+# Výklad
+
+## Základní deska
+*Páteř*
+
+- Je to vícevrstvý plošný spoj, fundament a páteř počítače.
+- Propojuje procesor se všemi ostatními částmi.
+- Čipset tvoří dva čipy: severní a jižní most.
+- Sběrnice jsou měděné cestičky pro data a signály.
+
+> Tip: deska rozhoduje o kompatibilitě a o tom, co půjde upgradovat.
+
+## Patice, mosty a BIOS
+*Čipy*
+
+- Patice (socket) mechanicky drží procesor a elektricky ho propojí.
+- Severní most je systémový řadič blízko CPU a řeší rychlé přesuny: FSB, AGP a paměťovou sběrnici.
+- Jižní most připojuje pomalejší periferie, BIOS, disky, USB a porty.
+- BIOS je mezistupeň mezi hardwarem a softwarem a nese instrukce pro zavedení operačního systému.
+
+> Tip: nastavení BIOSu drží čip CMOS na baterii.
+
+
+# Kvíz
+
+## Co je základní deska?
+- [x] Vícevrstvý plošný spoj, páteř počítače
+- Jen kovový kryt zdroje
+- Jen ventilátor na procesoru
+- Externí disk
+
+## Z čeho se skládá čipset?
+- Ze tří grafických karet
+- [x] Ze severního a jižního mostu
+- Jen z baterie CMOS
+- Jen ze slotu ISA
+
+## Co řeší severní most?
+- Jen pomalé USB porty
+- Jen zvukový výstup
+- [x] Rychlé přesuny dat: FSB, AGP a paměťovou sběrnici
+- Jen tlačítko reset
+
+## Kde je uložené nastavení BIOSu?
+- [x] V čipu CMOS napájeném baterií
+- Jen na papírovém štítku skříně
+- V mechanice CD
+- V červeném vodiči zdroje
diff --git a/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.en.md b/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.en.md
new file mode 100644
index 0000000..4d83851
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Connectors and slots
+popis: 2 slides about power, SATA and slots, then a quiz.
+nazev_cviceni: Quiz: connectors and slots
+nadpis_kvizu: Quiz on connectors and slots
+---
diff --git a/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.md b/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.md
new file mode 100644
index 0000000..70248ec
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/13-konektory-a-sloty.md
@@ -0,0 +1,57 @@
+---
+id: hardware-1-konektory-a-sloty
+puvodni: hw.13
+nazev: Konektory a sloty
+popis: 2 snímky o napájení, SATA a slotech a pak kvíz.
+nazev_cviceni: Kvíz: konektory a sloty
+nadpis_kvizu: Kvíz ke konektorům a slotům
+---
+
+# Výklad
+
+## Napájení a datové kabely
+*Konektory*
+
+- Deska bere 24pinový napájecí konektor a procesor zvlášť 4pinový nebo 8pinový.
+- Disky a mechaniky se připojují přes SATA.
+- Starší rozhraní jsou IDE a FDD.
+- F_PANEL připojuje Power, Reset, LED diody a PC speaker.
+
+> Tip: F_PANEL je svazek od předního panelu skříně.
+
+## ISA, PCI, AGP a PCIe
+*Sloty*
+
+- ISA je zastaralý 16bitový slot.
+- PCI je nezávislé na CPU, běží na 33 MHz a má propustnost 264 MB/s.
+- AGP vzniklo pro grafické karty a má přímé spojení s RAM.
+- PCI Express je sériové, vysokorychlostní a umí Hot Plug.
+
+> Tip: dnešní rychlé rozhraní je PCI Express.
+
+
+# Kvíz
+
+## Jakými konektory se deska napájí?
+- Jen jedním USB
+- [x] 24 piny pro desku a 4 nebo 8 piny pro CPU
+- Jen konektorem FDD
+- Jen slotem ISA
+
+## Co se připojuje na F_PANEL?
+- [x] Power, Reset, LED a PC speaker
+- Jen pevný disk
+- Jen monitor
+- Jen anténa Wi-Fi
+
+## Jaké má parametry sběrnice PCI?
+- Sériová, jen pro tiskárny
+- 16bitová a dnes jediná používaná
+- [x] Nezávislá na CPU, 33 MHz, 264 MB/s
+- Jen pro procesor
+
+## K čemu vzniklo AGP?
+- Pro disketové mechaniky
+- [x] Speciálně pro grafické karty, s přímým spojením k RAM
+- Jako náhrada napájecího zdroje
+- Pro pomalé tiskárny
diff --git a/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.en.md b/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.en.md
new file mode 100644
index 0000000..9226cc6
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Memory
+popis: 2 slides about RAM, SRAM and DRAM, then a quiz.
+nazev_cviceni: Quiz: memory
+nadpis_kvizu: Quiz on memory
+---
diff --git a/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.md b/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.md
new file mode 100644
index 0000000..4dfd967
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/14-operacni-pameti.md
@@ -0,0 +1,57 @@
+---
+id: hardware-1-operacni-pameti
+puvodni: hw.14
+nazev: Operační paměti
+popis: 2 snímky o RAM, SRAM a DRAM a pak kvíz.
+nazev_cviceni: Kvíz: paměti
+nadpis_kvizu: Kvíz k operačním pamětím
+---
+
+# Výklad
+
+## Vnitřní a vnější paměť
+*Dělení*
+
+- Vnitřní paměť je operační RAM: rychlá, pro rozpracovaná data, bez napájení se smaže.
+- Vnější paměť jsou disky, USB a optická média.
+- Vnější paměť data udrží i bez napájení.
+- Je ale mnohem pomalejší než RAM.
+
+> Tip: RAM je rychlá, ale bez napájení data ztratí.
+
+## SRAM a DRAM
+*Typy*
+
+- SRAM (statická) je velmi rychlá a drahá.
+- Používá se jako cache v procesoru.
+- DRAM (dynamická) je levnější a data se musí neustále obnovovat.
+- Patří sem SDR a řada DDR až po DDR3.
+
+> Tip: SRAM je cache v procesoru, DRAM se musí obnovovat.
+
+
+# Kvíz
+
+## K čemu slouží operační RAM?
+- [x] Pro rozpracovaná data; je rychlá a potřebuje napájení
+- Pro trvalé uložení filmů bez elektřiny
+- Jen jako náhrada zdroje
+- Jen pro větrání skříně
+
+## Čím se vnější paměť liší od RAM?
+- Je rychlejší a maže se při vypnutí
+- [x] Drží data i bez napájení, ale je pomalejší
+- Je vždy jen v procesoru
+- Nepotřebuje žádný řadič
+
+## Kde se používá SRAM?
+- Jako jediný pevný disk
+- Jen v optické mechanice
+- [x] Jako rychlá a drahá cache v procesoru
+- Jen na předním panelu
+
+## Co platí o DRAM?
+- [x] Je levnější a data se musí obnovovat (SDR, DDR až DDR3)
+- Nikdy se neobnovuje a je dražší než SRAM
+- Slouží jen jako baterie CMOS
+- Je to jiný název pro zdroj ATX
diff --git a/content/technicke-vybaveni/1-rocnik/15-procesor.en.md b/content/technicke-vybaveni/1-rocnik/15-procesor.en.md
new file mode 100644
index 0000000..053237a
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/15-procesor.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Processor
+popis: 3 slides about the CPU, cores and cooling, then a quiz.
+nazev_cviceni: Quiz: processor
+nadpis_kvizu: Quiz on the processor
+---
diff --git a/content/technicke-vybaveni/1-rocnik/15-procesor.md b/content/technicke-vybaveni/1-rocnik/15-procesor.md
new file mode 100644
index 0000000..d7ad482
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/15-procesor.md
@@ -0,0 +1,68 @@
+---
+id: hardware-1-procesor
+puvodni: hw.15
+nazev: Procesor
+popis: 3 snímky o CPU, jádrech a chlazení a pak kvíz.
+nazev_cviceni: Kvíz: procesor
+nadpis_kvizu: Kvíz k procesoru
+---
+
+# Výklad
+
+## Procesor
+*Mozek*
+
+- CPU je hlavní výpočetní jednotka a řídí ostatní části.
+- Je to křemíkový čip z miliard tranzistorů, které pracují jako spínače.
+- Frekvence je počet cyklů za sekundu (Hz, v praxi GHz).
+- Takt vznikne jako součin base clock (100 MHz) a násobiče.
+
+> Tip: takt = základní hodiny 100 MHz × násobič.
+
+## Více jader, vlákna a cache
+*Jádra*
+
+- Více jader jsou samostatné jednotky pro souběžnou práci.
+- Hyper-Threading / SMT nechá jedno jádro zpracovat dvě vlákna najednou.
+- Cache je vyrovnávací paměť mezi rychlým jádrem a pomalejšími součástmi.
+- Bývá ve třech úrovních: L1, L2 a L3.
+
+> Tip: cache L1, L2 a L3 zmenšují čekání na pomalejší paměť.
+
+## Chlazení procesoru
+*Teplo*
+
+- Chladič odvádí teplo z čipu.
+- Vzduchové chlazení je pasivní blok a ventilátor.
+- Je levné a spolehlivé.
+- Vodní all-in-one vede teplo kapalinou do radiátoru.
+- U procesoru tak zabere méně místa.
+
+> Tip: chlazení prodlužuje životnost a dovolí přetaktování.
+
+
+# Kvíz
+
+## Jak vzniká takt procesoru?
+- Jen z napětí 12 V
+- [x] Jako součin base clock 100 MHz a násobiče
+- Jen z počtu USB portů
+- Z rychlosti ventilátoru ve skříni
+
+## Co umí Hyper-Threading / SMT?
+- [x] Jedno jádro zpracuje dvě vlákna současně
+- Vypne chlazení
+- Zdvojnásobí napětí zdroje
+- Nahradí operační paměť diskem
+
+## K čemu je cache L1, L2 a L3?
+- K napájení grafické karty
+- K uložení BIOSu na baterii
+- [x] Vyrovnává rychlostní rozdíl mezi procesorem a ostatními částmi
+- K větrání zadní stěny
+
+## Čím se vyznačuje vzduchové chlazení CPU?
+- Jen kapalinou bez ventilátoru
+- [x] Pasivním blokem a ventilátorem; je levné a spolehlivé
+- Tím, že procesor nehřeje
+- Tím, že se montuje jen na disk
diff --git a/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.en.md b/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.en.md
new file mode 100644
index 0000000..153f921
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Expansion cards
+popis: 2 slides about cards and PCIe slots, then a quiz.
+nazev_cviceni: Quiz: expansion cards
+nadpis_kvizu: Quiz on expansion cards
+---
diff --git a/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.md b/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.md
new file mode 100644
index 0000000..c5d6264
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/16-pridavne-karty.md
@@ -0,0 +1,55 @@
+---
+id: hardware-1-pridavne-karty
+puvodni: hw.16
+nazev: Přídavné karty
+popis: 2 snímky o kartách a slotech PCIe a pak kvíz.
+nazev_cviceni: Kvíz: přídavné karty
+nadpis_kvizu: Kvíz k přídavným kartám
+---
+
+# Výklad
+
+## Přídavné karty
+*Karty*
+
+- Rozšiřující karta vylepší nebo rozšíří to, co počítač umí.
+- Typické jsou grafické, zvukové, síťové a televizní karty.
+- Sloty se vyvíjely, proto karta obvykle nejde zasunout do libovolného slotu.
+
+> Tip: karta je plošný spoj, který počítači přidá schopnost.
+
+## Kam se karta dává
+*PCIe*
+
+- Grafické karty patří do PCI Express ×16.
+- Ostatní karty se dnes dávají do PCI Express ×1 až ×4.
+- Číslo za × říká, kolik linek slot kartě dá.
+
+> Tip: grafika chce nejširší slot.
+
+
+# Kvíz
+
+## K čemu je přídavná karta?
+- [x] Rozšíří nebo vylepší schopnosti počítače
+- Nahradí napájecí zdroj
+- Jen uzavře skříň
+- Ukládá BIOS
+
+## Do jakého slotu patří grafická karta?
+- Do ISA
+- [x] Do PCI Express ×16
+- Do FDD
+- Do F_PANEL
+
+## Kam se dnes dávají ostatní karty?
+- Jen do AGP
+- Jen na přední stěnu skříně
+- [x] Do PCI Express ×1 až ×4
+- Přímo do patice procesoru
+
+## Proč nejde karta zasunout do každého slotu?
+- [x] Sloty se liší a karta sedí jen do odpovídajícího
+- Protože všechny sloty jsou stejné
+- Protože karty nemají kontakty
+- Protože to zakazuje airflow
diff --git a/content/technicke-vybaveni/1-rocnik/17-graficka-karta.en.md b/content/technicke-vybaveni/1-rocnik/17-graficka-karta.en.md
new file mode 100644
index 0000000..f4d6ea3
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/17-graficka-karta.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Graphics card
+popis: 3 slides about the image, card types and the image path, then a quiz.
+nazev_cviceni: Quiz: graphics card
+nadpis_kvizu: Quiz on the graphics card
+---
diff --git a/content/technicke-vybaveni/1-rocnik/17-graficka-karta.md b/content/technicke-vybaveni/1-rocnik/17-graficka-karta.md
new file mode 100644
index 0000000..eded28a
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/17-graficka-karta.md
@@ -0,0 +1,72 @@
+---
+id: hardware-1-graficka-karta
+puvodni: hw.17
+nazev: Grafická karta
+popis: 3 snímky o obrazu, typech karet a cestě obrazu a pak kvíz.
+nazev_cviceni: Kvíz: grafická karta
+nadpis_kvizu: Kvíz ke grafické kartě
+---
+
+# Výklad
+
+## Z čeho se skládá obraz
+*Obraz*
+
+- Digitalizace obrazu je vzorkování, kvantování a kódování.
+- Jas může mít třeba 256 úrovní, nebo jen 2.
+- Pixel je bod v obraze.
+- Polygon je 2D mnohoúhelník; ve hrách často trojúhelník, protože ho GPU zpracuje nejjednodušeji.
+- Vertex je vrchol, edge je hrana.
+- Textura je bitmapa namapovaná na 3D model.
+- Ray tracing je metoda sledování paprsku.
+
+> Tip: hry skládají scénu hlavně z trojúhelníků.
+
+## Integrovaná a samostatná grafika
+*Typy*
+
+- Integrovaná grafika je čip přímo na základní desce.
+- Bývá v levnějších a kancelářských sestavách i v noteboocích.
+- Má nižší výkon a nižší cenu a může brzdit zbytek systému.
+- Samostatná karta se dává do rozšiřujícího slotu.
+- Míří na hráče a náročnější uživatele.
+
+> Tip: integrovaný čip šetří peníze, samostatná karta přidá výkon.
+
+## Co je na kartě a kam jde obraz
+*Cesta*
+
+- Na kartě je GPU, RAMDAC, paměti, sběrnice, porty a často konektor přídavného napájení (Molex).
+- GPU mívá aktivní chladič, okolo něj sedí paměťové čipy.
+- Datový tok začíná v procesoru počítače a jde do grafické paměti.
+- GPU data zpracuje a pošle je do převodníku signálu.
+- Odtud obraz pokračuje do monitoru (framebuffer).
+
+> Tip: obraz jde z CPU do grafické paměti, pak do GPU a dál na monitor.
+
+
+# Kvíz
+
+## Co je pixel?
+- [x] Bod v obraze
+- Hrana polygonu
+- Napájecí konektor
+- Typ pevného disku
+
+## Proč hry často používají trojúhelník?
+- Protože má víc hran než čtverec
+- [x] Pro grafickou kartu je nejjednodušší na zpracování
+- Protože nepotřebuje texturu
+- Protože nahradí monitor
+
+## Čím se vyznačuje integrovaná grafika?
+- Je vždy rychlejší než samostatná karta
+- Sedí jen v mechanice DVD
+- [x] Čip je na desce, bývá levnější a má nižší výkon
+- Nemá vliv na zbytek počítače
+
+## Jakou cestou jde obraz?
+- [x] CPU → grafická paměť → GPU → převodník → monitor
+- Monitor → disk → zdroj → skříň
+- Jen z BIOSu přímo do reproduktoru
+- Z F_PANEL rovnou na tiskárnu
diff --git a/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.en.md b/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.en.md
new file mode 100644
index 0000000..72dfda3
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Shaders and rendering
+popis: 3 slides about shaders, rendering and CUDA, then a quiz.
+nazev_cviceni: Quiz: shaders and rendering
+nadpis_kvizu: Quiz on shaders and rendering
+---
diff --git a/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.md b/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.md
new file mode 100644
index 0000000..992d18d
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/18-shadery-a-render.md
@@ -0,0 +1,72 @@
+---
+id: hardware-1-shadery-a-render
+puvodni: hw.18
+nazev: Shadery a render
+popis: 3 snímky o shaderech, renderu a CUDA a pak kvíz.
+nazev_cviceni: Kvíz: shadery a render
+nadpis_kvizu: Kvíz k shaderům a renderování
+---
+
+# Výklad
+
+## Grafický procesor
+*GPU*
+
+- GPU přijímá data od CPU a počítá obraz.
+- Je to složitý čip se stovkami milionů tranzistorů.
+- Sleduje se takt v MHz, kapacita paměti a počet shaderů.
+- Dřív se počítaly vertex a pixel shadery, dnes spíš unifikované, částečně programovatelné jednotky.
+
+> Tip: pro výkon počítače je GPU stejně důležité jako CPU.
+
+## Shader a renderování
+*Programy*
+
+- Shader je program, který řídí části grafického řetězce.
+- Renderování tvoří obraz podle počítačového modelu, nejčastěji 3D.
+- Render je vizualizace: model se přenese do 2D bitmapy.
+- Softwarový render počítá procesor. Je přesnější, ale mnohonásobně pomalejší.
+- Hardwarový render slouží pro náhledy a hry.
+- Vertex shader transformuje vrcholy a nové nevytváří.
+- Pixel shader potom mapuje texturu a přidává efekty, třeba ray tracing nebo bump mapping.
+
+> Tip: vertex shader posouvá vrcholy, ale nové nevytváří.
+
+## CUDA a architektura GPU
+*Výpočty*
+
+- CUDA je paralelní platforma NVIDIA a nechá GPU počítat i jiné úlohy než obraz.
+- Zrychluje třeba lineární algebru, obraz, video a hluboké učení.
+- Obdoba u AMD se jmenuje FireStream.
+- GPU vzniklo pro hry: miliony polygonů a velké textury.
+- Je stavěné na tisíce vláken, hodně počítání a málo podmínek a na sekvenční čtení paměti.
+- Jednoduché skalární procesory jsou sdružené do streaming multiprocesorů.
+
+> Tip: většinu čipu tvoří mnoho jednoduchých procesorů.
+
+
+# Kvíz
+
+## Co je shader?
+- [x] Program řídící části grafického řetězce
+- Napájecí konektor Molex
+- Typ pevného disku
+- Větrací mřížka
+
+## Co vertex shader nedělá?
+- Neposouvá vrcholy
+- [x] Nevytváří nové vertexy
+- Nesahá na grafickou kartu
+- Nepoužívá se ve 3D
+
+## Čím se liší softwarový render?
+- Je rychlejší, protože ho počítá jen GPU
+- Umí jen černobíle
+- [x] Počítá ho CPU, je přesnější a mnohem pomalejší
+- Nevytvoří bitmapu
+
+## Co je CUDA?
+- [x] Platforma NVIDIA pro výpočty na GPU
+- Standard napájecího zdroje
+- Starý slot ISA
+- Formát diskety
diff --git a/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.en.md b/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.en.md
new file mode 100644
index 0000000..2aad1f5
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: GPU memory and bus
+popis: 3 slides about RAMDAC, GDDR and PCIe, then a quiz.
+nazev_cviceni: Quiz: GPU memory and bus
+nadpis_kvizu: Quiz on GPU memory and the bus
+---
diff --git a/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.md b/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.md
new file mode 100644
index 0000000..335dc85
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/19-pamet-a-sbernice-gpu.md
@@ -0,0 +1,70 @@
+---
+id: hardware-1-pamet-a-sbernice-gpu
+puvodni: hw.19
+nazev: Paměť a sběrnice GPU
+popis: 3 snímky o RAMDAC, GDDR a PCIe a pak kvíz.
+nazev_cviceni: Kvíz: paměť a sběrnice GPU
+nadpis_kvizu: Kvíz k paměti a sběrnici GPU
+---
+
+# Výklad
+
+## RAMDAC a grafická paměť
+*Převod*
+
+- RAMDAC převádí digitální data na analogová.
+- U dnešních karet je uvnitř GPU a má přednost při čtení paměti.
+- Okolo GPU jsou grafické paměti, často DDR nebo GDDR5.
+- Hlavní parametry jsou kapacita a frekvence.
+- CAS latency je čekání, než se po zadání adresy sloupce data objeví na pinech.
+- Čím nižší latence, tím lépe. Časy paměti se udávají v nanosekundách.
+
+> Tip: nižší CAS latency znamená kratší čekání.
+
+## Sběrnice grafické karty
+*Spojení*
+
+- Typická sběrnice karty je PCI Express ×16, dřív to bylo AGP.
+- PCIe 4.0 zdvojnásobí propustnost proti předchozí verzi při stejném počtu linek.
+- Rychlost jedné linky stoupla z 1 GB/s na 2 GB/s.
+- Slot ×16 tak teoreticky dává 32 GB/s.
+- M.2 se čtyřmi linkami dává 8 GB/s.
+
+> Tip: PCIe 4.0 má na jedné lince 2 GB/s.
+
+## Porty na kartě
+*Výstupy*
+
+- Běžné výstupy jsou D-Sub, DVI, HDMI a DisplayPort.
+- Starší karty mají i TV výstup, S-Video nebo cinch.
+- Výrobci parametry přikrášlují, proto se karty srovnávají benchmarkem.
+- Známý test je 3DMark. Podíl má i procesor, rozhoduje ale grafická karta.
+
+> Tip: stejný obraz může odejít analogově i digitálně.
+
+
+# Kvíz
+
+## Co dělá RAMDAC?
+- Převádí analogový signál na digitální
+- [x] Převádí digitální data na analogová a dnes je v GPU
+- Počítá jen cache procesoru
+- Formátuje pevný disk
+
+## Co znamená nižší CAS latency?
+- [x] Data se objeví dřív, takže je to lepší
+- Karta má míň portů
+- Sběrnice je pomalejší
+- Monitor má nižší rozlišení
+
+## Jakou teoretickou propustnost má PCIe 4.0 ×16?
+- 2 GB/s
+- 8 GB/s
+- [x] 32 GB/s
+- 264 MB/s
+
+## Co se stalo s rychlostí jedné linky u PCIe 4.0?
+- Klesla z 2 GB/s na 1 GB/s
+- [x] Stoupla z 1 GB/s na 2 GB/s
+- Zůstala 133 MB/s
+- Záleží jen na barvě slotu
diff --git a/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.en.md b/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.en.md
new file mode 100644
index 0000000..be30d2b
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Display resolutions
+popis: 2 slides about ports, resolutions and SLI, then a quiz.
+nazev_cviceni: Quiz: display resolutions
+nadpis_kvizu: Quiz on resolutions and multi-GPU
+---
diff --git a/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.md b/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.md
new file mode 100644
index 0000000..8100d63
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/20-rozliseni-obrazu.md
@@ -0,0 +1,59 @@
+---
+id: hardware-1-rozliseni-obrazu
+puvodni: hw.20
+nazev: Rozlišení obrazu
+popis: 2 snímky o portech, rozlišení a SLI a pak kvíz.
+nazev_cviceni: Kvíz: rozlišení obrazu
+nadpis_kvizu: Kvíz k rozlišení a více kartám
+---
+
+# Výklad
+
+## SLI, CrossFire a režimy
+*Více karet*
+
+- SLI od NVIDIA spojí dvě grafické karty do jednoho celku.
+- Vychází levněji než jedna špičková karta.
+- Obdoba u ATI se jmenuje CrossFire.
+- Režim určuje rozlišení a barevnou hloubku.
+- Dnešní karty zvládnou i 3200×2400 (QUXGA) při 32 bitech.
+
+> Tip: první režim IBM PC byl v roce 1982 MDA.
+
+## 720p, 1080p, 4K a 8K
+*Názvy*
+
+- 720p je 1280×720, tedy HD nebo HD Ready.
+- 1080p je 1920×1080, tedy Full HD.
+- 1440p je 2560×1440, tedy QHD, čtyřnásobek 720p.
+- 4K (2160p) je 3840×2160, čtyřnásobek Full HD.
+- 8K (4320p) je 7680×4320, šestnáctinásobek Full HD.
+
+> Tip: 4K má čtyřikrát víc pixelů než 1080p, 8K šestnáctkrát.
+
+
+# Kvíz
+
+## Co je 1080p?
+- [x] 1920×1080, Full HD
+- 1280×720, HD Ready
+- 7680×4320, 8K
+- 3200×2400, QUXGA
+
+## Co je 4K?
+- 1280×720
+- [x] 3840×2160, čtyřnásobek pixelů Full HD
+- 2560×1440
+- 640×480
+
+## Kolikrát víc pixelů má 8K proti 1080p?
+- Dvakrát
+- Čtyřikrát
+- [x] Šestnáctkrát
+- Stejně
+
+## Co je SLI a co je CrossFire?
+- [x] SLI spojuje karty NVIDIA, CrossFire je obdoba ATI
+- Obojí je typ paměti DDR
+- SLI je port HDMI, CrossFire je D-Sub
+- Jsou to dva názvy stejné firmy
diff --git a/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.en.md b/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.en.md
new file mode 100644
index 0000000..28419a4
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Sound card
+popis: 3 slides about card types, the chip and recording, then a quiz.
+nazev_cviceni: Quiz: sound card
+nadpis_kvizu: Quiz on the sound card
+---
diff --git a/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.md b/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.md
new file mode 100644
index 0000000..6c45806
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/21-zvukova-karta.md
@@ -0,0 +1,69 @@
+---
+id: hardware-1-zvukova-karta
+puvodni: hw.21
+nazev: Zvuková karta
+popis: 3 snímky o typech karet, čipu a nahrávání a pak kvíz.
+nazev_cviceni: Kvíz: zvuková karta
+nadpis_kvizu: Kvíz ke zvukové kartě
+---
+
+# Výklad
+
+## Zvuková karta
+*Výstup*
+
+- Karta zajistí zvukový výstup.
+- Převede digitální signál na analogový, který zahraje reproduktor.
+- Rozlišujeme integrované, interní a externí karty.
+- CD má 44,1 kHz a bitovou hloubku 16 až 24 bitů.
+
+> Tip: digitalizace zvuku je vzorkování, kvantování a kódování.
+
+## Integrovaná, interní, externí a herní
+*Provedení*
+
+- Integrovaný zvuk je na téměř každé desce a na multimédia stačí.
+- Má slabší zesílení a méně konektorů.
+- Interní karta jde do PCIe a nabízí prostorový zvuk a software pro nastavení.
+- Externí karta se hodí, když integrovaná nestačí, i do notebooku.
+- Herní karta, interní nebo externí, drží prostorový zvuk, malou odezvu a přesnou reprodukci.
+
+> Tip: externí karta se připojuje přes USB a sedí mimo skříň.
+
+## DSP a nahrávání
+*Čip*
+
+- Zvukový čip má jádra, D/A a A/D převodníky, sluchátkový zesilovač a vstupy i výstupy.
+- DSP stojí na harvardské architektuře: paměť programu je oddělená od paměti dat, na rozdíl od von Neumannova modelu.
+- Počet vstupů má odpovídat počtu nástrojů nahrávaných najednou.
+- Na zpěv stačí jeden analogový vstup.
+- Některé nástroje, třeba keyboard, chtějí vysokoimpedanční vstup Hi-Z.
+
+> Tip: kondenzátorový mikrofon chce fantomové napájení 48 V.
+
+
+# Kvíz
+
+## Co zvuková karta dělá se signálem?
+- [x] Převede digitální signál na analogový pro reproduktor
+- Převede jen obraz na texturu
+- Nahradí procesor
+- Ukládá data na pásku
+
+## Jak se připojuje interní zvuková karta?
+- Jen přes VGA
+- [x] Přes slot PCIe
+- Jen přes FDD
+- Přes baterii CMOS
+
+## Jak se připojuje externí zvuková karta?
+- Do patice procesoru
+- Do slotu AGP
+- [x] Přes USB, mimo skříň
+- Jen na přední LED
+
+## K čemu je fantomové napájení 48 V?
+- [x] Pro kondenzátorové mikrofony
+- Pro vypnutí zdroje ATX
+- Pro chlazení procesoru
+- Pro slot ISA
diff --git a/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.en.md b/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.en.md
new file mode 100644
index 0000000..1c71188
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Network and TV cards
+popis: 2 slides about network and TV cards, then a quiz.
+nazev_cviceni: Quiz: network and TV cards
+nadpis_kvizu: Quiz on network and TV cards
+---
diff --git a/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.md b/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.md
new file mode 100644
index 0000000..515cd77
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/22-sitova-a-tv-karta.md
@@ -0,0 +1,60 @@
+---
+id: hardware-1-sitova-a-tv-karta
+puvodni: hw.22
+nazev: Síťová a TV karta
+popis: 2 snímky o síťové a televizní kartě a pak kvíz.
+nazev_cviceni: Kvíz: síťová a TV karta
+nadpis_kvizu: Kvíz k síťové a televizní kartě
+---
+
+# Výklad
+
+## Síťová karta
+*Síť*
+
+- Integrovaná karta má ethernetový konektor přímo na desce.
+- Interní karta kombinuje kabel a Wi-Fi a jde do PCIe.
+- Externí karta se strčí do USB.
+- Wake on LAN umí počítač zapnout po datové síti.
+- Externí anténa zvětší dosah.
+- MIMO použije víc antén a zvedne propustnost.
+- WPS zjednoduší nastavení bezdrátového spojení.
+
+> Tip: dnes nemusí jít jen o kabel, umí i Wi-Fi.
+
+## Televizní karta
+*Televize*
+
+- Televizní tuner na desce integrovaný není nikdy.
+- Přidá počítači příjem televizního signálu.
+- Umí signál i zaznamenat.
+- Je to další rozšiřující karta, ne součást čipsetu.
+
+> Tip: tuner na základní desce integrovaný nebývá.
+
+
+# Kvíz
+
+## Kde je integrovaná síťová karta?
+- [x] Ethernetový konektor přímo na desce
+- Jen v optické mechanice
+- Jen jako externí disk
+- V patici procesoru
+
+## Co je Wake on LAN?
+- Vypnutí monitoru při nečinnosti
+- [x] Zapnutí počítače přes datovou síť
+- Zrychlení pevného disku
+- Jiný název pro BIOS
+
+## Co přináší MIMO?
+- Jednu anténu a nižší dosah
+- Jen kabelový konektor
+- [x] Více antén a vyšší propustnost
+- Zrušení Wi-Fi
+
+## Jak je to s televizní kartou na desce?
+- [x] Není integrovaná nikdy
+- Je na každé desce ATX
+- Nahrazuje zvukovou kartu
+- Je součást severního mostu
diff --git a/content/technicke-vybaveni/1-rocnik/23-pevny-disk.en.md b/content/technicke-vybaveni/1-rocnik/23-pevny-disk.en.md
new file mode 100644
index 0000000..d65288b
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/23-pevny-disk.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Hard drive
+popis: 3 slides about HDDs, parameters and data layout, then a quiz.
+nazev_cviceni: Quiz: hard drive
+nadpis_kvizu: Quiz on the hard drive
+---
diff --git a/content/technicke-vybaveni/1-rocnik/23-pevny-disk.md b/content/technicke-vybaveni/1-rocnik/23-pevny-disk.md
new file mode 100644
index 0000000..77ebda7
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/23-pevny-disk.md
@@ -0,0 +1,69 @@
+---
+id: hardware-1-pevny-disk
+puvodni: hw.23
+nazev: Pevný disk
+popis: 3 snímky o HDD, parametrech a struktuře dat a pak kvíz.
+nazev_cviceni: Kvíz: pevný disk
+nadpis_kvizu: Kvíz k pevnému disku
+---
+
+# Výklad
+
+## Vnější paměti a HDD
+*Trvalá data*
+
+- Vnější paměť drží data dlouhodobě, má kapacitu v terabajtech a je energeticky nezávislá.
+- Patří sem HDD, SSD a USB flash disky.
+- Pevný disk zaznamenává data magneticky.
+- Notebookové disky mají 2,5 palce, stolní 3,5 palce.
+
+> Tip: HDD je mechanický, otřes ho může poškodit.
+
+## Parametry pevného disku
+*Čísla*
+
+- Kapacita se udává v terabajtech.
+- Přístupová doba je v milisekundách.
+- Přenosová rychlost je ve stovkách MB/s.
+- Cache bývá třeba 256 MB.
+
+> Tip: běžné otáčky jsou 5400 a 7200 za minutu.
+
+## Zápis, čtení a struktura
+*Stopy*
+
+- Zápis dělá cívka magnetickým polem.
+- Čtení používá elektromagnetickou indukci.
+- Disk je hermeticky uzavřený.
+- Formátování vytvoří stopy a sektory.
+- Cylindr jsou stejné stopy na všech plotnách.
+- Defragmentace složí rozházená data k sobě, aby se četla rychleji.
+
+> Tip: hlavy se ploten nedotýkají, vznášejí se na vzduchu.
+
+
+# Kvíz
+
+## Na jakém principu pracuje HDD?
+- [x] Na magnetickém záznamu a je mechanický
+- Jen na optickém laseru
+- Nemá pohyblivé části
+- Drží data jen při zapnutém napájení
+
+## Jaké rozměry mají pevné disky?
+- Jen 8 palců
+- [x] 2,5" do notebooku a 3,5" do stolního počítače
+- 12 cm jako CD
+- Jen formát microSD
+
+## Jaké otáčky se u HDD běžně uvádějí?
+- 100 a 200 ot/min
+- 1000 ot/min
+- [x] 5400 a 7200 ot/min
+- 48 000 ot/min
+
+## Jak disk zapisuje a čte?
+- [x] Zápis cívkou, čtení elektromagnetickou indukcí
+- Zápis laserem, čtení ventilátorem
+- Obojí jen přes BIOS
+- Zápis do CMOS, čtení z baterie
diff --git a/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.en.md b/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.en.md
new file mode 100644
index 0000000..4ee9748
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.en.md
@@ -0,0 +1,6 @@
+---
+nazev: SSD and flash
+popis: 3 slides about SSD, TRIM, SSHD and USB, then a quiz.
+nazev_cviceni: Quiz: SSD and flash
+nadpis_kvizu: Quiz on SSD and flash
+---
diff --git a/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.md b/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.md
new file mode 100644
index 0000000..c4c5fcd
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/24-ssd-a-flash.md
@@ -0,0 +1,69 @@
+---
+id: hardware-1-ssd-a-flash
+puvodni: hw.24
+nazev: SSD a flash
+popis: 3 snímky o SSD, TRIM, SSHD a USB a pak kvíz.
+nazev_cviceni: Kvíz: SSD a flash
+nadpis_kvizu: Kvíz k SSD a flash paměti
+---
+
+# Výklad
+
+## SSD
+*Bez pohybu*
+
+- SSD je polovodičový disk bez pohyblivých částí.
+- Je odolnější, tiché, úspornější a rychlejší než HDD.
+- Buňky se opotřebovávají, počet zápisů je omezený.
+- Bývá dražší než plotnový disk stejné kapacity.
+
+> Tip: fragmentace u SSD problém není.
+
+## FTL, TRIM a záchrana
+*Buňky*
+
+- FTL rovnoměrně rozkládá zápisy, aby se buňky sjížděly stejně.
+- TRIM řekne disku, které bloky už nikdo nepoužívá, a zápis se tím zrychlí.
+- Poškození může být hardwarové (mechanika, elektronika, oheň, voda) nebo softwarové (smazání, formát, virus).
+- Smazání maže metadata, obsah na HDD často zůstane a dá se obnovit.
+- U SSD je obnova kvůli TRIM obtížnější.
+
+> Tip: smazání smaže metadata. U SSD TRIM záchranu ztěžuje.
+
+## SSHD a USB flash
+*Přenos*
+
+- SSHD je pevný disk s malou SSD částí, zhruba 8 GB.
+- Často používané soubory si přesune na rychlou část.
+- Dává smysl, když chcete kapacitu i rychlost za rozumnou cenu.
+- USB flash je malé médium na přenos dat po sběrnici USB.
+- Životnost buněk záleží na typu SLC, MLC nebo TLC a na kvalitě hardwaru.
+
+> Tip: konektor USB flash disku je počítaný asi na 1500 cyklů.
+
+
+# Kvíz
+
+## Co je hlavní výhoda SSD proti HDD?
+- Má plotny a je hlučnější
+- [x] Nemá pohyblivé části, je tiché a rychlejší
+- Data ztratí při vypnutí
+- Nejde ho použít jako systémový disk
+
+## Jaká je nevýhoda SSD?
+- [x] Buňky se opotřebovávají a disk bývá dražší
+- Musí se defragmentovat každý den
+- Nepřežije přesun po stole
+- Nemá TRIM ani FTL
+
+## Co dělá příkaz TRIM?
+- Zvýší otáčky ploten
+- Smaže BIOS
+- [x] Oznámí SSD, které bloky už nejsou použité
+- Zapne fantomové napájení
+
+## Co je SSHD?
+- Jen jiný název pro disketu
+- [x] HDD doplněné o malou SSD část, asi 8 GB
+- Optický disk s modrým laserem
+- Paměťová karta SD
diff --git a/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.en.md b/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.en.md
new file mode 100644
index 0000000..1300daf
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Tapes and floppies
+popis: 3 slides about removable media, tapes and floppies, then a quiz.
+nazev_cviceni: Quiz: tapes and floppies
+nadpis_kvizu: Quiz on tapes and floppies
+---
diff --git a/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.md b/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.md
new file mode 100644
index 0000000..4b25db4
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/25-pasky-a-diskety.md
@@ -0,0 +1,72 @@
+---
+id: hardware-1-pasky-a-diskety
+puvodni: hw.25
+nazev: Pásky a diskety
+popis: 3 snímky o výměnných médiích, páskách a disketách a pak kvíz.
+nazev_cviceni: Kvíz: pásky a diskety
+nadpis_kvizu: Kvíz k páskám a disketám
+---
+
+# Výklad
+
+## Výměnná média
+*Přehled*
+
+- Mechanická média: děrné štítky a děrné pásky.
+- Magnetická: pásky a diskety.
+- Optická: CD, DVD a Blu-ray.
+- Elektrická: paměťové karty a USB flash.
+- K souboru na pásce nejde skočit, páska se na místo přetočí.
+
+> Tip: páska se musí převinout, proto se hodí na archiv, ne na denní práci.
+
+## Magnetická páska
+*Pásky*
+
+- Jedna raná páska unesla data asi deseti tisíc děrných štítků.
+- Kapacita začínala u stovek kilobajtů, třeba 850 kB.
+- Dlouho měla vyšší kapacitu než pevné disky; zlom přišel kolem roku 2010 s terabajtovými disky.
+- Podle IBM ji v USA používá přímo nebo nepřímo až 82 % velkých firem.
+- Odhad je stovky miliard gigabajtů dat na páskách po světě.
+- Sklad CSCS v Curychu má 27 PB na 17 000 páskách LTO-5 po 1500 GB.
+
+> Tip: páska pořád slouží velkým firmám na dlouhodobou zálohu.
+
+## Z pásky na kotouč
+*Diskety*
+
+- Stopa se přesunula na otáčející se mylarový kotouč.
+- Soustředné stopy umožní náhodný přístup, ne jen převíjení.
+- 8" disketa (1971) měla 100 kB a zápis z jedné strany.
+- 5,25" (1976) se dala poslat poštou; kapacita 80 až 1200 kB.
+- 3,5" zavedla Sony v roce 1981.
+- Standard 1990–1995 je 1,44 MB, pevný obal a dvířka, vloží se jen správně.
+
+> Tip: ikona ukládání je 3,5" disketa.
+
+
+# Kvíz
+
+## Proč se páska hodí na archiv a ne na běžnou práci?
+- [x] K souboru se musí převinout, přístup není náhodný
+- Nemá žádnou kapacitu
+- Nejde ji vyjmout z mechaniky
+- Maže se při každém čtení
+
+## Jaká byla raná 8" disketa?
+- Z roku 1981 a měla 1,44 MB
+- [x] Z roku 1971 a měla 100 kB
+- Z roku 2010 a měla 1 TB
+- Byla optická
+
+## Co platí o 3,5" disketě?
+- Zavedla ji IBM v roce 1960 a měla 850 kB
+- Neměla pevný obal
+- [x] Sony, 1981, standardně 1,44 MB
+- Šla vložit oběma směry
+
+## Čím disketa zrychlila přístup proti pásce?
+- [x] Soustředné stopy dovolí náhodný přístup
+- Má modrý laser
+- Nemá magnetickou vrstvu
+- Čte se jen od konce
diff --git a/content/technicke-vybaveni/1-rocnik/26-opticka-media.en.md b/content/technicke-vybaveni/1-rocnik/26-opticka-media.en.md
new file mode 100644
index 0000000..1f08106
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/26-opticka-media.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Optical media
+popis: 3 slides about CD, DVD, Blu-ray and memory cards, then a quiz.
+nazev_cviceni: Quiz: optical media
+nadpis_kvizu: Quiz on optical and flash media
+---
diff --git a/content/technicke-vybaveni/1-rocnik/26-opticka-media.md b/content/technicke-vybaveni/1-rocnik/26-opticka-media.md
new file mode 100644
index 0000000..800b8d5
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/26-opticka-media.md
@@ -0,0 +1,68 @@
+---
+id: hardware-1-opticka-media
+puvodni: hw.26
+nazev: Optická média
+popis: 3 snímky o CD, DVD, Blu-ray a kartách a pak kvíz.
+nazev_cviceni: Kvíz: optická média
+nadpis_kvizu: Kvíz k optickým a flash médiím
+---
+
+# Výklad
+
+## Jak se čte optický disk
+*Laser*
+
+- Kotouč má průměr 12 cm a tloušťku 1,2 mm.
+- Data leží ve spirále od středu ke kraji.
+- U 700MB CD je stopa dlouhá 6,244 km.
+- Spirála střídá pity (prohlubně) a landy (plochy).
+
+> Tip: land odrazí paprsek (1), pit ho rozptýlí (0).
+
+## CD, DVD a Blu-ray
+*Generace*
+
+- CD má 650–870 MB. CD-R vypálí organické barvivo asi při 290 °C a zápis je nevratný.
+- CD-RW jde přepsat tisíckrát až stotisíckrát. Slitina AgInSbTe mění krystaly zahřátím a chlazením.
+- DVD má 1,4 až 15,82 GB, stopu 11,84 km a umí dvě vrstvy i obě strany.
+- Blu-ray má 25–128 GB. Kratší vlnová délka znamená hustší záznam.
+- Optika ustoupila kvůli kapacitě, pomalému zápisu, spolehlivosti a tomu, že obsah je online.
+
+> Tip: Blu-ray používá modrý laser 405 nm, DVD červený 650 nm.
+
+## Flash karty
+*Karty*
+
+- Flash je statická vnější paměť: malá, s velkou kapacitou, odolná vůči vibracím a magnetickému poli.
+- Buňky mají plovoucí hradlo: SLC, MLC, TLC nebo QLC.
+- Karty: SD (i mini a micro), MMC, Memory Stick a CompactFlash.
+- Čtečka na USB mívá víc slotů, aby vzala víc formátů.
+
+> Tip: NAND paměť vytlačila optické mechaniky.
+
+
+# Kvíz
+
+## Co se stane, když laser trefí land a když trefí pit?
+- Obojí je signál 0
+- [x] Land se odrazí jako 1, pit se rozptýlí jako 0
+- Land je 0 a pit je 1
+- Laser se na disku nepoužívá
+
+## Jak se zapisuje CD-R?
+- [x] Vypálením barviva, zápis je nevratný
+- Magnetickou cívkou jako HDD
+- Libovolně mnohokrát jako RAM
+- Jen změnou názvu souboru
+
+## Čím se laser Blu-ray liší od DVD?
+- Má červený laser 650 nm a menší kapacitu
+- Nemá spirálu
+- [x] Má modrý laser 405 nm a kapacitu 25–128 GB
+- Má stejný laser jako CD
+
+## Které karty patří mezi flash média?
+- Jen děrné štítky
+- [x] SD, MMC, Memory Stick a CompactFlash
+- Jen 8" diskety
+- Jen pásky LTO
diff --git a/content/technicke-vybaveni/1-rocnik/27-klavesnice.en.md b/content/technicke-vybaveni/1-rocnik/27-klavesnice.en.md
new file mode 100644
index 0000000..4472bdf
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/27-klavesnice.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Keyboard
+popis: 2 slides about keyboard parts and types, then a quiz.
+nazev_cviceni: Quiz: keyboard
+nadpis_kvizu: Quiz on the keyboard
+---
diff --git a/content/technicke-vybaveni/1-rocnik/27-klavesnice.md b/content/technicke-vybaveni/1-rocnik/27-klavesnice.md
new file mode 100644
index 0000000..0b0d05d
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/27-klavesnice.md
@@ -0,0 +1,58 @@
+---
+id: hardware-1-klavesnice
+puvodni: hw.27
+nazev: Klávesnice
+popis: 2 snímky o částech a typech klávesnic a pak kvíz.
+nazev_cviceni: Kvíz: klávesnice
+nadpis_kvizu: Kvíz ke klávesnici
+---
+
+# Výklad
+
+## Polohovací zařízení a klávesnice
+*Vstup*
+
+- Polohovací zařízení hýbe kurzorem a bývá drátové i bezdrátové.
+- Hlavní písmenková část.
+- Speciální a české znaky.
+- Ovládací tlačítka a posunovací tlačítka.
+- Numerická klávesnice a funkční klávesy.
+
+> Tip: klávesnice má šest částí.
+
+## Membrána, guma, nůžky a laser
+*Typy*
+
+- Membránová je nejčastější u stolních počítačů. Stisk propojí membránu a je velmi tichá.
+- U vodivé gumy se guma prohne ke kontaktu a sepne obvod. Pod ní je izolační destička a pozlacený kontakt.
+- Scissor-switch nemá gumovou membránu. Klávesa se propadne po nůžkovém mechanismu.
+- Laserová klávesnice promítá rozložení na podložku a senzor snímá stisk. Hodí se k mobilu.
+
+> Tip: scissor-switch je notebookový, mělký a tichý.
+
+
+# Kvíz
+
+## Z kolika částí se skládá běžná klávesnice?
+- [x] Ze šesti
+- Ze dvou
+- Z dvanácti
+- Jen z numerického bloku
+
+## Jaká je membránová klávesnice?
+- Nejhlučnější a jen pro notebooky
+- [x] Nejpoužívanější u stolních počítačů a velmi tichá
+- Promítá se laserem
+- Nemá žádný kontakt
+
+## Co je scissor-switch?
+- Gumová membrána ve stolním PC
+- Laser na podložce
+- [x] Notebookový nůžkový mechanismus, mělký a tichý stisk
+- Jen funkční klávesy
+
+## Jak pracuje laserová klávesnice?
+- [x] Promítne rozložení a senzor snímá virtuální stisk
+- Používá vodivou gumu
+- Je jen mechanická s nejhlubším stiskem
+- Čte jen numerický blok
diff --git a/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.en.md b/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.en.md
new file mode 100644
index 0000000..8e6e1c1
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Pointing devices
+popis: 3 slides about the mouse, tablet and touchpad, then a quiz.
+nazev_cviceni: Quiz: pointing devices
+nadpis_kvizu: Quiz on pointing devices
+---
diff --git a/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.md b/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.md
new file mode 100644
index 0000000..de16781
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/28-polohovaci-zarizeni.md
@@ -0,0 +1,68 @@
+---
+id: hardware-1-polohovaci-zarizeni
+puvodni: hw.28
+nazev: Polohovací zařízení
+popis: 3 snímky o myši, tabletu a touchpadu a pak kvíz.
+nazev_cviceni: Kvíz: polohovací zařízení
+nadpis_kvizu: Kvíz k polohovacím zařízením
+---
+
+# Výklad
+
+## Kuličková myš
+*Kulička*
+
+- Posuv otáčí kuličkou.
+- Kulička točí válečky pro osy x a y.
+- Kotoučky na válečcích mají otvory.
+- Infračervené diody otvory prosvěcují.
+- Senzory záblesky převedou na polohu.
+
+> Tip: pohyb se měří prosvícením otvorů v kotoučcích.
+
+## Spaceball, trackball, tablet a digitizér
+*Prostor*
+
+- Spaceball má pohyb a rotaci po třech osách. V CAD a grafice zvedá efektivitu zhruba o 30–50 %.
+- Trackball je jako obrácená kuličková myš. Na rychlý a přesný pohyb se nehodí.
+- Tablet má síť vodičů, která reaguje na pero. Hodí se do CAD a grafiky, kreslit jde i na LCD.
+- Digitizér je speciální myš na přesnou polohu a chce speciální podložku. Měří odpor mezi ukazovátkem a podložkou.
+
+> Tip: tablet pozná tlak, až ve stovkách až 1024 úrovních.
+
+## Trackpoint a touchpad
+*Notebook*
+
+- Trackpoint je malý joystick a šetří místo.
+- Touchpad je nejčastější polohovací zařízení notebooku.
+- Snímá elektrickou kapacitu prstu.
+- Hotspoty na ploše mají zvláštní účel, třeba posuvník.
+
+> Tip: trackpoint sedí mezi klávesami G, H a B.
+
+
+# Kvíz
+
+## Jak kuličková myš pozná pohyb?
+- Laserem na podložce bez kuličky
+- [x] Kulička točí válečky a infračervené diody prosvěcují otvory
+- Jen kapacitou prstu
+- Měřením 48 V
+
+## Kde je trackpoint?
+- [x] Mezi klávesami G, H a B
+- Na zadní stěně skříně
+- V optické mechanice
+- Jen u stolní myši
+
+## Jak touchpad snímá prst?
+- Kuličkou a válečky
+- Magnetickou páskou
+- [x] Elektrickou kapacitou prstu
+- Fantomovým napájením
+
+## Co umí grafický tablet?
+- Jen zapnout počítač
+- [x] Reaguje na pero a tlak, až 1024 úrovní
+- Nahradí napájecí zdroj
+- Čte jen děrné štítky
diff --git a/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.en.md b/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.en.md
new file mode 100644
index 0000000..043747b
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Game controls and touch
+popis: 3 slides about gamepads, Kinect and touch, then a quiz.
+nazev_cviceni: Quiz: game controls and touch
+nadpis_kvizu: Quiz on game controls and touch
+---
diff --git a/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.md b/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.md
new file mode 100644
index 0000000..1c6a4d0
--- /dev/null
+++ b/content/technicke-vybaveni/1-rocnik/29-herni-ovladace-a-dotyk.md
@@ -0,0 +1,69 @@
+---
+id: hardware-1-herni-ovladace-a-dotyk
+puvodni: hw.29
+nazev: Herní ovladače a dotyk
+popis: 3 snímky o gamepadu, Kinectu a dotyku a pak kvíz.
+nazev_cviceni: Kvíz: herní ovladače a dotyk
+nadpis_kvizu: Kvíz k herním ovladačům a dotyku
+---
+
+# Výklad
+
+## Gamepad, joystick a volant
+*Hry*
+
+- Gamepad ovládá hry a mívá programovatelná tlačítka.
+- DualShock přidává vibrace.
+- Joystick je páka na základně. Dřív byl analogový, dnes digitální.
+- Používá se i v letadlech nebo bagrech.
+- Volant napodobuje řízení: pedály, řadící páka a programovatelná tlačítka.
+
+> Tip: force feedback přidá vibrace a nárazy.
+
+## Kinect
+*Tělo*
+
+- Kombinuje RGB kameru, hloubkové čidlo, mikrofony a vlastní procesor.
+- Sleduje pohyb celého těla v prostoru.
+- Reaguje na pokyny a pozná i změnu zabarvení hlasu a emoce.
+
+> Tip: sleduje celé tělo ve 3D, ne jen ovladač v ruce.
+
+## Odporová a kapacitní vrstva
+*Displej*
+
+- Odporová vrstva má tvrzenou a vodivou vrstvu, odporové vrstvy, vymezovací bodovou síť a sklo.
+- Dotyk vrstvy přitlačí k sobě a obvod se sepne.
+- Kapacitní displej má dvě vodivé vrstvy pod napětím, oddělené mezerou.
+- Chovají se jako kondenzátor a kolem sebe mají slabé pole.
+- Změna kapacity prozradí místo dotyku.
+- Funguje to s vodivým předmětem, ne s libovolným plastem.
+
+> Tip: kapacitního displeje se musíte dotknout vodivě, třeba prstem.
+
+
+# Kvíz
+
+## Co přidává DualShock?
+- [x] Vibrace gamepadu
+- Optickou mechaniku
+- Slot PCIe
+- Magnetickou pásku
+
+## Co je force feedback?
+- Jen jiné rozlišení monitoru
+- [x] Vibrace a nárazy u joysticku a volantu
+- Typ paměti DDR3
+- Způsob formátování disku
+
+## Co Kinect sleduje?
+- Jen stisk jedné klávesy
+- Jen otáčky pevného disku
+- [x] Pohyb celého těla ve 3D, pomocí kamery, hloubky a mikrofonů
+- Jen napětí zdroje
+
+## Čím se musíte dotknout kapacitního displeje?
+- [x] Vodivým předmětem, například prstem
+- Jen nevodivým plastem
+- Jen speciálním perem bez elektřiny
+- Děrnou páskou
diff --git a/content/technicke-vybaveni/2-rocnik/01-monitory.en.md b/content/technicke-vybaveni/2-rocnik/01-monitory.en.md
new file mode 100644
index 0000000..49ab9c1
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/01-monitory.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Displays
+popis: The panel, the resolution, and why 60 Hz is not the only number.
+nazev_cviceni: Quiz: Displays
+nadpis_kvizu: Quiz on Displays
+---
diff --git a/content/technicke-vybaveni/2-rocnik/01-monitory.md b/content/technicke-vybaveni/2-rocnik/01-monitory.md
new file mode 100644
index 0000000..94998df
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/01-monitory.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-monitory
+puvodni: hw2.1
+nazev: Monitory
+popis: Panel, rozlišení a proč 60 Hz není jediné číslo.
+nazev_cviceni: Kvíz: Monitory
+nadpis_kvizu: Kvíz k tématu Monitory
+---
+
+# Výklad
+
+## LCD a OLED
+*Panel*
+
+- LCD svítí podsvícením a panel světlo cloní.
+- OLED svítí samotnými body a umí opravdovou černou.
+- Lesklý panel má vyšší kontrast a víc odlesků.
+- Uhlopříčka v palcích není totéž co pracovní plocha v bodech.
+
+> Tip: rozbité podsvícení LCD zhasne obraz, i když panel pořád kreslí.
+
+## Rozlišení a frekvence
+*Obraz*
+
+- Nižší rozlišení interpoluje a obraz měkne.
+- Obnovovací frekvence říká, kolikrát za sekundu se obraz překreslí.
+- Odezva panelu není totéž co frekvence.
+- HDR chce vyšší jas, ne jen zaškrtnutou volbu.
+
+> Tip: nativní rozlišení panelu je to, na které je mřížka bodů.
+
+## Obraz musí stihnout pásmo
+*Kabel*
+
+- DisplayPort a HDMI mají verze s různým datovým tokem.
+- VGA je analogové a na dlouhém kabelu měkne.
+- USB-C obraz poveze jen tehdy, když port umí alternativní režim.
+- Dokovací stanice má vlastní limit, ne limit notebooku.
+
+> Tip: starý kabel nebo slabá verze HDMI oseká frekvenci i rozlišení.
+
+
+# Kvíz
+
+## Čím se OLED liší od běžného LCD?
+- [x] Body samy svítí a černá je opravdu zhasnutá
+- Nemá rozlišení
+- Jede jen po VGA
+- Nepotřebuje grafickou kartu
+> LCD obraz skládá cloněním podsvícení.
+
+## Co je nativní rozlišení?
+- [x] Počet bodů, na který je panel vyroben
+- Nejvyšší číslo v herním menu bez ohledu na panel
+- Rychlost procesoru
+- Počet USB portů
+> Jiné rozlišení se musí přepočítat.
+
+## Proč 4K při 144 Hz někdy nejde?
+- [x] Kabel nebo verze portu nestihne datový tok
+- OLED zakazuje frekvenci nad 60 Hz
+- IPv4 omezuje monitory
+- BIOS maže rozlišení
+> Pásmo portu je strop, ne přání v menu.
+
+## Kdy USB-C nepřenese obraz?
+- [x] Když port umí jen data a nabíjení
+- Když je kabel černý
+- Když má notebook SSD
+- Když je v síti VLAN
+> Konektor stejného tvaru ještě neznamená DisplayPort v něm.
diff --git a/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.en.md b/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.en.md
new file mode 100644
index 0000000..8963385
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Print and scan
+popis: Laser, inkjet, and a network queue.
+nazev_cviceni: Quiz: Print and scan
+nadpis_kvizu: Quiz on Print and scan
+---
diff --git a/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.md b/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.md
new file mode 100644
index 0000000..04b0a7d
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/02-tisk-a-sken.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-tisk-a-sken
+puvodni: hw2.2
+nazev: Tisk a sken
+popis: Laser, inkoust a síťová fronta.
+nazev_cviceni: Kvíz: Tisk a sken
+nadpis_kvizu: Kvíz k tématu Tisk a sken
+---
+
+# Výklad
+
+## Toner a kapky
+*Princip*
+
+- Laser nabije válec a toner se chytí podle obrazu.
+- Inkoust stříká kapky a hodí se na fotky i občasný tisk.
+- Zaschnutá hlava je častější než porucha desky.
+- Termotransfer na štítky není totéž co kancelářský laser.
+
+> Tip: laser netaví toner na papír sám, dělá to zapékací válec.
+
+## Jedna tiskárna pro učebnu
+*Síť*
+
+- Sdílená tiskárna na počítači žije, jen když je ten počítač zapnutý.
+- Síťová tiskárna poslouchá sama.
+- Fronta na serveru drží ovladač na jednom místě.
+- Špatný ovladač tiskne znaky místo stránky.
+
+> Tip: tiskárna s vlastní adresou má rezervaci v DHCP.
+
+## Obraz zpět do počítače
+*Sken*
+
+- Plochý skener i podavač mají jiný limit gramáže.
+- ADF nezvládne sešité listy.
+- Síťový sken může poslat soubor do složky nebo na poštu.
+- Sklo a podavač se mažou, jinak jsou na stránce pruhy.
+
+> Tip: rozlišení 600 dpi na text stačí, plakát chce víc.
+
+
+# Kvíz
+
+## Co v laserové tiskárně připevní toner k papíru?
+- [x] Zapékací válec
+- Inkoustová hlava
+- Ventilátor zdroje
+- VGA kabel
+> Teplo a tlak toner zataví.
+
+## Kdy sdílená tiskárna na učitelském počítači netiskne?
+- [x] Když je ten počítač vypnutý
+- Když má tiskárna vlastní IP a server
+- Když je v síti DHCP
+- Když je papír v zásobníku
+> Sdílení přes počítač z toho počítače dělá server.
+
+## Proč dát tiskárně rezervaci v DHCP?
+- [x] Aby ovladače mířily pořád na stejnou adresu
+- Aby se zvýšilo rozlišení
+- Aby toner vydržel dýl
+- Aby nešla skenovat
+> Nová adresa po restartu rozbije frontu.
+
+## Co podavač skeneru nezvládne?
+- [x] Sešitou sadu listů
+- Jednotlivé běžné listy
+- Černobílý text
+- Odeslání do složky, pokud to umí firmware
+> Sešit patří na sklo, ne do ADF.
diff --git a/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.en.md b/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.en.md
new file mode 100644
index 0000000..35914d7
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Buses and drives
+popis: USB, SATA and NVMe are not interchangeable just because of the plug.
+nazev_cviceni: Quiz: Buses and drives
+nadpis_kvizu: Quiz on Buses and drives
+---
diff --git a/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.md b/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.md
new file mode 100644
index 0000000..07a4a9a
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/03-sbernice-a-disky.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-sbernice-a-disky
+puvodni: hw2.3
+nazev: Sběrnice a disky
+popis: USB, SATA a NVMe nejsou zaměnitelné jen konektorem.
+nazev_cviceni: Kvíz: Sběrnice a disky
+nadpis_kvizu: Kvíz k tématu Sběrnice a disky
+---
+
+# Výklad
+
+## Versie a napájení
+*USB*
+
+- USB 2.0, 3.x a 4 se liší tokem dat.
+- USB-C je tvar, ne záruka rychlosti.
+- Thunderbolt po stejném tvaru umí obraz, síť i disk.
+- Hub bez napájení nestačí pro víc disků.
+
+> Tip: port USB-A 2.0 fyzicky vezme i rychlý flash disk a stáhne ho na svou rychlost.
+
+## Klasické disky
+*SATA*
+
+- HDD i starší SSD sedí na SATA.
+- Rychlost rozhraní je strop, disk může být pomalejší.
+- Kabel bez západky vypadne a disk zmizí z BIOSu.
+- Externí box schová SATA za USB.
+
+> Tip: napájení SATA a datový kabel jsou dva různé vodiče.
+
+## SSD přímo na PCIe
+*NVMe*
+
+- NVMe jede po PCIe a obchází starý řadič SATA.
+- Slot M.2 na desce může umět jen jednu z variant.
+- Dlouhá destička do krátkého slotu nepatří.
+- Bez chlazení rychlé SSD ve špičce zpomalí.
+
+> Tip: M.2 je rozměr destičky. NVMe a SATA na M.2 nejsou totéž.
+
+
+# Kvíz
+
+## Co říká samotný tvar USB-C?
+- [x] Jen tvar konektoru, ne rychlost ani obraz
+- Vždy Thunderbolt
+- Vždy 40 Gb/s
+- Vždy nabíjení 100 W a DisplayPort
+> Schopnosti určuje řadič a kabel, ne obrys.
+
+## Čím se NVMe liší od SATA SSD?
+- [x] Jede po PCIe, ne po starém SATA
+- Nemá paměťové buňky
+- Jde zapojit jen do VGA
+- Nepotřebuje desku
+> Protokol a sběrnice jsou rychlejší cesta.
+
+## Co může umět slot M.2?
+- [x] Jen SATA, jen NVMe, nebo obojí, podle desky
+- Vždy obojí
+- Jen grafickou kartu
+- Jen operační paměť
+> Manuál desky je závaznější než tvar zářezu.
+
+## Proč rychlé SSD ve špičce zpomalí?
+- [x] Přehřeje se a stáhne výkon
+- SATA mu sebere VLAN
+- BIOS maže buňky
+- USB-C ho přepne na 2.0 vždy
+> Řadič hlídá teplotu.
diff --git a/content/technicke-vybaveni/2-rocnik/04-chlazeni.en.md b/content/technicke-vybaveni/2-rocnik/04-chlazeni.en.md
new file mode 100644
index 0000000..a3f19e1
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/04-chlazeni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Cooling
+popis: Air needs a way in and a way out.
+nazev_cviceni: Quiz: Cooling
+nadpis_kvizu: Quiz on Cooling
+---
diff --git a/content/technicke-vybaveni/2-rocnik/04-chlazeni.md b/content/technicke-vybaveni/2-rocnik/04-chlazeni.md
new file mode 100644
index 0000000..7c3203d
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/04-chlazeni.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-chlazeni
+puvodni: hw2.4
+nazev: Chlazení
+popis: Vzduch musí mít kudy dovnitř a ven.
+nazev_cviceni: Kvíz: Chlazení
+nadpis_kvizu: Kvíz k tématu Chlazení
+---
+
+# Výklad
+
+## Podtlak a přetlak
+*Tok*
+
+- Vpředu a dole se vzduch nasává, vzadu a nahoře odchází.
+- Filtr patří na vstup, ne na výfuk.
+- Zavřená přední stěna designové skříně škrtí přívod.
+- Kabel přes ventilátor je ucpaný filtr.
+
+> Tip: všechny ventilátory foukající dovnitř bez odtahu jen víří prach.
+
+## Chladič musí sednout
+*CPU*
+
+- Vzduchový chladič potřebuje místo k bočnici.
+- Voda odvede teplo do radiátoru, který pořád potřebuje vzduch.
+- Špatně dotažený chladič pustí teplotu hned po startu.
+- Limit teploty v BIOSu je pojistka, ne pracovní režim.
+
+> Tip: pasta není vrstva másla. Má vyplnit nerovnosti.
+
+## Tichý vrah zdroje
+*Prach*
+
+- Zdroj s filtrem se čistí, nezalepuje.
+- Notebookový výfuk do měkké deky se udusí.
+- Po roce bez čištění rostou otáčky a teplota.
+- Čištění stlačeným vzduchem nedrží ventilátor v roztočeném stavu naslepo.
+
+> Tip: vrstva prachu na žebru je izolace.
+
+
+# Kvíz
+
+## Kam patří prachový filtr?
+- [x] Na vstup vzduchu
+- Jen na výfuk vzadu
+- Dovnitř zdroje bez mřížky
+- Na displej
+> Filtruje se to, co teprve vstupuje.
+
+## Co dělá příliš silná vrstva pasty?
+- [x] Zhorší přestup tepla
+- Zvýší takt samo
+- Nahradí chladič
+- Opraví špatný socket
+> Pasta vede hůř než kov chladiče.
+
+## Proč vodní chlazení pořád potřebuje vzduch?
+- [x] Radiátor musí teplo předat do místnosti
+- Voda chladí jen při otevřeném okně skříně
+- Čerpadlo jede na VGA
+- Bez ventilátoru se voda vaří vždy do minuty
+> Okruh teplo přesune, radiátor ho odvede.
+
+## Co udělá notebook na dece?
+- [x] Zakryje sání a přehřeje se
+- Zvýší jas panelu
+- Přepne disk na NVMe
+- Opraví ventilátor
+> Sání bývá zespodu.
diff --git a/content/technicke-vybaveni/2-rocnik/05-sestaveni.en.md b/content/technicke-vybaveni/2-rocnik/05-sestaveni.en.md
new file mode 100644
index 0000000..710e54f
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/05-sestaveni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Building a PC
+popis: An order that does not break the board.
+nazev_cviceni: Quiz: Building a PC
+nadpis_kvizu: Quiz on Building a PC
+---
diff --git a/content/technicke-vybaveni/2-rocnik/05-sestaveni.md b/content/technicke-vybaveni/2-rocnik/05-sestaveni.md
new file mode 100644
index 0000000..edca649
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/05-sestaveni.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-sestaveni
+puvodni: hw2.5
+nazev: Sestavení
+popis: Pořadí, které nerozbije desku.
+nazev_cviceni: Kvíz: Sestavení
+nadpis_kvizu: Kvíz k tématu Sestavení
+---
+
+# Výklad
+
+## Nejdřív mimo skříň
+*Příprava*
+
+- Deska, procesor, chladič a jeden modul paměti.
+- Zdroj mimo skříň a obraz na základní grafiku.
+- Až potom disk, karty a zbytek paměti.
+- Antistatická podložka a klidné ruce porazí koberec.
+
+> Tip: sestavu nejdřív oživte na stole. Ve skříni se špatně hledá nedosedlá paměť.
+
+## Procesor nemá kam spadnout
+*Socket*
+
+- Trojúhelník na procesoru sedí se značkou na socketu.
+- Páka se zavírá až když procesor leží rovně.
+- Pasta až po usazení, ne do socketu.
+- Chladič se dotahuje křížem, ne jednou stranou nadoraz.
+
+> Tip: ohnutý pin na desce LGA nevrátíte pinzetou naslepo.
+
+## Kabely jsou součást airflow
+*Skříň*
+
+- Nejdřív zdroj a distanční sloupky, potom deska.
+- Napájení desky, procesoru a karty jsou tři různé konektory.
+- Přední USB a tlačítko mají malé konektory podle manuálu.
+- První start se sleduje: ventilátory, obraz, pípnutí nebo LED.
+
+> Tip: distanční sloupky pod deskou musí sedět s otvory, jinak deska zkratuje.
+
+
+# Kvíz
+
+## Proč oživit sestavu nejdřív mimo skříň?
+- [x] Nedosedlá paměť se ve skříni hledá hůř
+- Ve skříni nejde zapnout zdroj
+- BIOS ve skříni neběží
+- Pasta venku schne
+> Minimum dílů na stole ukáže, jestli jádro žije.
+
+## Co se nesmí stát pinům na socketu LGA?
+- [x] Ohnout je
+- Nechat je v socketu
+- Srovnat procesor podle značky
+- Zavřít páku až po usazení
+> Pin na desce je křehký.
+
+## K čemu jsou distanční sloupky?
+- [x] Drží desku dál od plechu skříně
+- Napájí ventilátor
+- Jsou to šrouby do disku
+- Uzemňují jen přední LED
+> Bez nich nebo se špatnými sloupky deska zkratuje.
+
+## Který konektor nepatří do napájení procesoru?
+- [x] PCIe konektor grafické karty místo EPS
+- Hlavní 24pin na desku
+- EPS konektor k socketu
+- SATA napájení disku
+> Konektory si jsou podobné a sedí špatně záměrně.
diff --git a/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.en.md b/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.en.md
new file mode 100644
index 0000000..cc6bd07
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.en.md
@@ -0,0 +1,6 @@
+---
+nazev: UEFI and boot
+popis: What happens before the operating system starts.
+nazev_cviceni: Quiz: UEFI and boot
+nadpis_kvizu: Quiz on UEFI and boot
+---
diff --git a/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.md b/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.md
new file mode 100644
index 0000000..51f7fd8
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/06-uefi-a-boot.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-uefi-a-boot
+puvodni: hw2.6
+nazev: UEFI a boot
+popis: Co se děje, než naběhne systém.
+nazev_cviceni: Kvíz: UEFI a boot
+nadpis_kvizu: Kvíz k tématu UEFI a boot
+---
+
+# Výklad
+
+## BIOS a UEFI
+*Firmware*
+
+- Firmware otestuje železo a najde disk se systémem.
+- UEFI používá tabulku GPT, starý BIOS často MBR.
+- Secure Boot pustí jen podepsaný zavaděč.
+- Vypnutí Secure Bootu je výjimka, ne výchozí rada.
+
+> Tip: UEFI není jen barevné menu. Umí jiné dělení disku a Secure Boot.
+
+## Z čeho se startuje
+*Pořadí*
+
+- Nabídka bootu umí jednorázově vybrat zařízení.
+- Disk bez zavaděče se v seznamu nechytí.
+- Více systémů potřebuje bootovací nabídku, ne přepisování pořád dokola.
+- Síťový boot PXE hledá obraz na serveru.
+
+> Tip: flash disk v pořadí před systémem nabootuje omylem instalaci.
+
+## Menu není pro celou třídu
+*Heslo*
+
+- Heslo na setup brání přehazování bootu.
+- Heslo na disk je silnější a bez něj jsou data nedostupná.
+- Clear CMOS heslo setupu často smaže a vezme i nastavení.
+- Po změně profilu paměti ověřte, že systém naběhne dvakrát.
+
+> Tip: heslo BIOSu bez záznamu je past, až se bude měnit disk.
+
+
+# Kvíz
+
+## Co Secure Boot hlídá?
+- [x] Aby se spustil jen důvěryhodný zavaděč
+- Aby měl disk aspoň 1 TB
+- Aby ventilátor točil na maximum
+- Aby nešel USB flash disk vůbec zapojit
+> Nepodepsaný zavaděč se nespustí.
+
+## Čím se liší GPT od MBR?
+- [x] GPT je tabulka oddílů pro UEFI a větší disky
+- GPT je typ pasty
+- MBR je jen pro NVMe
+- Jsou to totéž
+> Starý MBR má omezení, na která velký disk naráží.
+
+## Proč nenechat flash disk trvale první v bootu?
+- [x] Omylem z něj naběhne cizí systém
+- Flash disk vypne Secure Boot navždy
+- UEFI flash disky zakazuje
+- Smaže TPM
+> Jednorázová nabídka bootu je bezpečnější.
+
+## Co často udělá vymazání CMOS?
+- [x] Smaže heslo setupu i ostatní volby
+- Smaže jen soubory na ploše
+- Přepne Windows na jiný jazyk
+- Opraví ohnutý pin
+> Je to návrat firmware k výchozímu stavu.
diff --git a/content/technicke-vybaveni/2-rocnik/07-diagnostika.en.md b/content/technicke-vybaveni/2-rocnik/07-diagnostika.en.md
new file mode 100644
index 0000000..2976cca
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/07-diagnostika.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Diagnostics
+popis: POST, beep codes, and the smallest build that still starts.
+nazev_cviceni: Quiz: Diagnostics
+nadpis_kvizu: Quiz on Diagnostics
+---
diff --git a/content/technicke-vybaveni/2-rocnik/07-diagnostika.md b/content/technicke-vybaveni/2-rocnik/07-diagnostika.md
new file mode 100644
index 0000000..62ab16e
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/07-diagnostika.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-diagnostika
+puvodni: hw2.7
+nazev: Diagnostika
+popis: POST, pípání a minimum, které ještě naběhne.
+nazev_cviceni: Kvíz: Diagnostika
+nadpis_kvizu: Kvíz k tématu Diagnostika
+---
+
+# Výklad
+
+## Test hned po zapnutí
+*POST*
+
+- Firmware zkouší procesor, paměť a grafiku.
+- Kódy na displeji desky jsou přesnější než staré pípání.
+- Reproduktor skříně musí být zapojený, jinak ticho nic neřekne.
+- Manuál desky překládá kódy. Nejsou u všech stejné.
+
+> Tip: žádný obraz a žádné pípnutí neznamená automaticky mrtvý procesor.
+
+## Uberte, co může lhát
+*Minimum*
+
+- Ven grafika, pokud má procesor vlastní obraz.
+- Ven disky a karty.
+- Zkuste jiný kabel obrazu a jiný port.
+- Až jádro naběhne, vracejte díly po jednom.
+
+> Tip: jeden modul paměti v doporučeném slotu porazí čtyři najednou.
+
+## Náhodné pády
+*Paměť a disk*
+
+- Test paměti běží déle než jedno pípnutí.
+- Chyby čtení disku se projeví v jeho vlastních statistikách.
+- Přehřátí shodí stroj až při zátěži, ne v menu BIOSu.
+- Vyměněný díl ověřte stejnou zkouškou, která předtím selhala.
+
+> Tip: pád až ve Windows může být paměť, ne jen ovladač.
+
+
+# Kvíz
+
+## Co POST dělá?
+- [x] Po zapnutí zkouší základní díly
+- Instaluje systém
+- Formátuje SSD
+- Stahuje ovladače z internetu
+> Je to první test firmware, ne operační systém.
+
+## Proč začít s jedním modulem paměti?
+- [x] Špatný modul nebo slot se snáz odhalí
+- Deska víc modulů zakazuje
+- BIOS umí jen 4 GB
+- Druhý modul maže UEFI
+> Minimum dílů zužuje chybu.
+
+## Kdy se přehřátí často neprojeví?
+- [x] V menu BIOSu bez zátěže
+- Při dlouhém testu procesoru
+- Ve hře
+- Při kódování videa
+> Teplota roste až s odběrem.
+
+## Co ověří výměnu dílu?
+- [x] Stejná zkouška, která předtím selhala
+- Jen to, že ventilátor se točí
+- Nová tapeta
+- Jiná barva skříně
+> Bez opakování testu nevíte, jestli to byla příčina.
diff --git a/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.en.md b/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.en.md
new file mode 100644
index 0000000..6825093
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Ports and docks
+popis: What a laptop can actually drive.
+nazev_cviceni: Quiz: Ports and docks
+nadpis_kvizu: Quiz on Ports and docks
+---
diff --git a/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.md b/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.md
new file mode 100644
index 0000000..2acd091
--- /dev/null
+++ b/content/technicke-vybaveni/2-rocnik/08-porty-a-dokovani.md
@@ -0,0 +1,71 @@
+---
+id: hardware-2-porty-a-dokovani
+puvodni: hw2.8
+nazev: Porty a dokování
+popis: Co z notebooku opravdu vyleze.
+nazev_cviceni: Kvíz: Porty a dokování
+nadpis_kvizu: Kvíz k tématu Porty a dokování
+---
+
+# Výklad
+
+## Jeden kabel, mnoho omezení
+*Dok*
+
+- Dok může nést obraz, síť i napájení.
+- Limit je nejslabší článek: port, kabel nebo dok.
+- Dva externí monitory chtějí port, který to umí, ne přání uživatele.
+- Napájení přes dok nemusí stačit hernímu notebooku.
+
+> Tip: levný USB hub není dok. Nepřenese obraz, který port neumí.
+
+## Kabel z notebooku
+*Síť*
+
+- USB adaptér je síťová karta mimo šasi.
+- Rychlost adaptéru bývá 1 Gb/s, ne rychlost školní páteře.
+- Dok s vlastní síťovkou má vlastní MAC adresu.
+- Rezervace DHCP na MAC notebooku pak neplatí pro dok.
+
+> Tip: mnoho tenkých notebooků už ethernet nemá a potřebuje adaptér.
+
+## Na co se ptát
+*Výběr*
+
+- Kolik obrazů, jaké rozlišení a jestli se notebook nabíjí.
+- Jestli síť jede z doku, nebo se čeká Wi-Fi.
+- Jestli čtečka karet a USB sedí verzi, kterou škola používá.
+- Manuál portu je důležitější než fotka konektoru.
+
+> Tip: stejný USB-C na dvou noteboocích nemá stejné schopnosti.
+
+
+# Kvíz
+
+## Kdy USB-C dok nepřenese dva monitory?
+- [x] Když port notebooku umí jen data
+- Když je dok černý
+- Když je v učebně VLAN
+- Když má disk NVMe
+> Dok neumí vyrobit schopnost, kterou port nemá.
+
+## Proč DHCP rezervace na notebook neplatí přes dok?
+- [x] Dok má vlastní MAC adresu
+- DHCP doky zakazuje
+- VLAN maže rezervace
+- Ethernet nemá MAC
+> Rezervace sedí na adresu, kterou server vidí.
+
+## Co je nejslabší článek řetězu obrazu?
+- [x] Port, kabel nebo dok, podle toho, co umí nejmíň
+- Vždy monitor
+- Vždy procesor
+- Vždy tapeta
+> Datový tok musí projít všemi.
+
+## Čím se liší USB hub od doku s obrazem?
+- [x] Hub sám o sobě obraz z datového portu nevyrobí
+- Hub je vždy rychlejší
+- Dok neumí napájení
+- Jsou to totéž
+> Obraz chce alternativní režim nebo DisplayPort v kabelu.
diff --git a/content/technicke-vybaveni/3-rocnik/01-raid.en.md b/content/technicke-vybaveni/3-rocnik/01-raid.en.md
new file mode 100644
index 0000000..20e2144
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/01-raid.en.md
@@ -0,0 +1,6 @@
+---
+nazev: RAID
+popis: Several drives as one volume, not as a backup.
+nazev_cviceni: Quiz: RAID
+nadpis_kvizu: Quiz on RAID
+---
diff --git a/content/technicke-vybaveni/3-rocnik/01-raid.md b/content/technicke-vybaveni/3-rocnik/01-raid.md
new file mode 100644
index 0000000..1bd05df
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/01-raid.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-raid
+puvodni: hw3.1
+nazev: RAID
+popis: Více disků jako jeden svazek, ne jako záloha.
+nazev_cviceni: Kvíz: RAID
+nadpis_kvizu: Kvíz k tématu RAID
+---
+
+# Výklad
+
+## RAID 1
+*Zrcadlo*
+
+- Stejná data jsou na dvou discích.
+- Kapacita je jako jeden disk.
+- Čtení může být rychlejší, zápis ne dvojnásobný.
+- Po výměně disku se pole musí obnovit.
+
+> Tip: zrcadlo přežije pád jednoho disku, ne smazání souboru.
+
+## RAID 0 a 5
+*Prokládání*
+
+- RAID 0 skládá rychlost a kapacitu a nemá ochranu.
+- RAID 5 snese pád jednoho disku z aspoň tří.
+- Parita zabere kapacitu jednoho disku.
+- Obnova velkého pole trvá dlouho a druhý pád v té době je zkáza.
+
+> Tip: RAID 0 při pádu jednoho disku ztratí všechno.
+
+## Hardware a software
+*Řadič*
+
+- Hardwarový řadič má vlastní cache a baterii.
+- Softwarové pole řeší systém a je přenosnější.
+- Cache bez ochrany při výpadku proudu rozbije zápis.
+- RAID nenahrazuje zálohu na jiné místo.
+
+> Tip: pole z jiného řadiče v novém serveru nemusí naběhnout.
+
+
+# Kvíz
+
+## Co RAID 1 přežije?
+- [x] Pád jednoho z dvojice disků
+- Smazání souboru uživatelem
+- Požár místnosti
+- Ransomware na obou discích naráz
+> Oba disky mají stejná data, včetně chyby, kterou tam zapíšete.
+
+## Co je RAID 0?
+- [x] Prokládání bez ochrany při pádu disku
+- Zrcadlo
+- Záloha na pásku
+- Šifrování disku
+> Rychlost a kapacita, nulová odolnost.
+
+## Proč RAID není záloha?
+- [x] Neochrání před smazáním, malwarem ani požárem skříně
+- Protože neumí víc než dva disky
+- Protože Windows RAID zakazuje
+- Protože parita maže soubory
+> Záloha je další kopie jinde a v jiném čase.
+
+## Co hrozí velkému poli při obnově?
+- [x] Druhý disk spadne dřív, než se obnova dopočítá
+- Pole se samo přepne na RAID 0
+- BIOS smaže paritu
+- NIC se při obnově nesmí točit
+> Obnova je dlouhá a disky jsou stejně staré.
diff --git a/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.en.md b/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.en.md
new file mode 100644
index 0000000..dc00365
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.en.md
@@ -0,0 +1,6 @@
+---
+nazev: NAS and storage networks
+popis: Drives that are not inside one computer.
+nazev_cviceni: Quiz: NAS and storage networks
+nadpis_kvizu: Quiz on NAS and storage networks
+---
diff --git a/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.md b/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.md
new file mode 100644
index 0000000..d2515bb
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/02-nas-a-site-disku.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-nas-a-site-disku
+puvodni: hw3.2
+nazev: NAS a sítě disků
+popis: Disky, které nejsou uvnitř jednoho počítače.
+nazev_cviceni: Kvíz: NAS a sítě disků
+nadpis_kvizu: Kvíz k tématu NAS a sítě disků
+---
+
+# Výklad
+
+## Souborový server v krabici
+*NAS*
+
+- Nabízí složky po síti.
+- Má vlastní účty nebo se ptá školního adresáře.
+- Dva porty se dají spojit, ale jen když to umí i switch.
+- Hlučný diskový provoz nepatří do tiché učebny.
+
+> Tip: NAS v učebně bez zálohy je jen jeden košík na všechna data.
+
+## Disk jako místní
+*SAN*
+
+- Server vidí disk, i když leží jinde.
+- Síť pro SAN se nemíchá s provozem žáků.
+- Špatné zónování vystaví disk cizímu serveru.
+- Výpadek této sítě shodí systémy, které na disku jedou.
+
+> Tip: SAN nedává složku, dává blokové zařízení.
+
+## Práva a snímky
+*Soubor*
+
+- Práva na složce mají sedět se skupinami, ne s jedním účtem učitele.
+- Snímek umí vrátit soubor smazaný dopoledne.
+- Plný svazek zastaví i zdánlivě nesouvisející službu.
+- Kapacita se hlídá dřív, než svítí červeně.
+
+> Tip: snímek disku není záloha, dokud leží na stejném poli.
+
+
+# Kvíz
+
+## Co NAS obvykle nabízí?
+- [x] Složky po síti
+- Blokový disk celé škole bez oprávnění
+- Náhradu procesoru v noteboocích
+- Obraz monitoru
+> Je to souborová služba.
+
+## Čím se SAN liší?
+- [x] Server dostane disk jako bloky, ne jako složku
+- Je to jen jiné jméno pro USB flash disk
+- Jede jen po Wi-Fi
+- Nemá disky
+> Souborový systém si na těch blocích dělá až server.
+
+## Proč snímek na stejném poli není záloha?
+- [x] Pád pole vezme i snímek
+- Snímek nejde vytvořit
+- Snímek maže RAID
+- SMB snímky zakazuje
+> Záloha musí přežít ztrátu původního úložiště.
+
+## Kam nepatří hlučné diskové pole?
+- [x] Do tiché učebny vedle žáků
+- Do racku s odtahem
+- Do místnosti se zámek
+- Do sítě oddělené od hostů
+> Hluk a přístup jsou součást návrhu.
diff --git a/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.en.md b/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.en.md
new file mode 100644
index 0000000..0542f14
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Servers and racks
+popis: A cabinet that is not a gaming PC on its side.
+nazev_cviceni: Quiz: Servers and racks
+nadpis_kvizu: Quiz on Servers and racks
+---
diff --git a/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.md b/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.md
new file mode 100644
index 0000000..5450904
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/03-servery-a-rack.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-servery-a-rack
+puvodni: hw3.3
+nazev: Servery a rack
+popis: Skříň, do které se nedává herní počítač naležato.
+nazev_cviceni: Kvíz: Servery a rack
+nadpis_kvizu: Kvíz k tématu Servery a rack
+---
+
+# Výklad
+
+## Racková jednotka
+*Tvar*
+
+- Server v racku se měří v jednotkách U.
+- Kolejnice musí unést hloubku skříně.
+- Napájecí lišta není prodlužovačka z kuchyně.
+- Proud a teplo se počítají dopředu, ne až když jistič spadne.
+
+> Tip: 1U je výška, ne výkon.
+
+## Studená ulička
+*Vzduch*
+
+- Studený vzduch jde před servery, teplý za ně.
+- Zaslepení prázdných pozic nutí vzduch přes stroje.
+- Kabelová armáda před sáním je ucpávka.
+- Teplota v místnosti není teplota uvnitř 1U serveru.
+
+> Tip: servery v racku sají vepředu a foukají dozadu. Otočený kus kazí ostatním.
+
+## Když nejste ve škole
+*Vzdáleně*
+
+- IPMI nebo iDRAC žije i když systém ne.
+- Patří do správní sítě, ne do učebny.
+- Konzole v racku je záloha, když síť správy spadne.
+- Štítek na serveru říká, co na něm jede.
+
+> Tip: vzdálená správa má vlastní port a vlastní heslo.
+
+
+# Kvíz
+
+## Co znamená 1U?
+- [x] Výšku zařízení v racku
+- Výkon jednoho jádra
+- Rychlost disku
+- Počet zdrojů
+> Je to rozměr, ne třída výkonu.
+
+## Kudy má server v racku sát vzduch?
+- [x] Vepředu a foukat dozadu
+- Zezadu dopředu
+- Jen shora bez mřížky
+- Z podlahy dírami v desce
+> Otočený server ohřívá sousedům sání.
+
+## K čemu je vzdálená správa serveru?
+- [x] Ovládáte ho, i když systém neběží
+- Nahrazuje zálohu dat
+- Je to Wi-Fi pro žáky
+- Maže RAID
+> Vidíte obraz a umíte stroj zapnout po síti.
+
+## Kam nepatří port vzdálené správy?
+- [x] Do žákovské VLAN
+- Do správní sítě
+- Za firewall
+- Na heslo jiné než admin na lístečku
+> Je to klíče od serveru.
diff --git a/content/technicke-vybaveni/3-rocnik/04-virtualizace.en.md b/content/technicke-vybaveni/3-rocnik/04-virtualizace.en.md
new file mode 100644
index 0000000..44f539c
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/04-virtualizace.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Virtualisation
+popis: Several systems on one piece of hardware.
+nazev_cviceni: Quiz: Virtualisation
+nadpis_kvizu: Quiz on Virtualisation
+---
diff --git a/content/technicke-vybaveni/3-rocnik/04-virtualizace.md b/content/technicke-vybaveni/3-rocnik/04-virtualizace.md
new file mode 100644
index 0000000..35666a2
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/04-virtualizace.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-virtualizace
+puvodni: hw3.4
+nazev: Virtualizace
+popis: Více systémů na jednom železe.
+nazev_cviceni: Kvíz: Virtualizace
+nadpis_kvizu: Kvíz k tématu Virtualizace
+---
+
+# Výklad
+
+## Kdo rozděluje železo
+*Hypervizor*
+
+- Hypervizor pouští více systémů nad jedním hardwarem.
+- Každý systém má vlastní diskový soubor a virtuální síťovku.
+- Paměť, kterou slíbíte navíc, se ve špičce potká.
+- Pád fyzického serveru shodí všechny hosty naráz.
+
+> Tip: virtuální počítač nemá vlastní zdroj. Dělí se o ten fyzický.
+
+## Virtuální switch
+*Síť*
+
+- Virtuální switch propojí hosty mezi sebou.
+- Most je pustí ven fyzickým portem.
+- Izolovaná síť jen mezi virtuály ven nevede.
+- Špatný trunk vystaví správní virtuál do učebny.
+
+> Tip: VLAN ve virtuálu musí sedět s trunkem na fyzické kartě.
+
+## Soubor není kouzlo
+*Disk*
+
+- Tenké provizionování slíbí víc, než je na začátku obsazeno.
+- Snímek před změnou umí vrátit systém.
+- Zapomenuté snímky zpomalí disk a zaplní úložiště.
+- Záloha virtuálu patří mimo ten samý disk.
+
+> Tip: tenký disk roste a může zaplnit svazek, na kterém leží.
+
+
+# Kvíz
+
+## Co se stane, když spadne fyzický server s pěti virtuály?
+- [x] Spadnou všechny, pokud nemají kam utéct
+- Přežije aspoň správní virtuál
+- Hypervizor je přenese na notebook
+- Nic, virtuál zdroj nepotřebuje
+> Sdílejí jedno železo.
+
+## K čemu je most virtuálního switche?
+- [x] Pustí virtuál do fyzické sítě
+- Smaže VLAN
+- Nahradí RAID
+- Zapne Secure Boot hostitele
+> Bez mostu virtuál ven neodejde.
+
+## Čím je tenký disk nebezpečný?
+- [x] Doroste a zaplní úložiště pod sebou
+- Nejde zálohovat
+- Nemá souborový systém
+- Vypne hypervizor
+> Slíbená kapacita není volné místo.
+
+## Co udělá zapomenutý snímek?
+- [x] Roste a zpomaluje disk
+- Smaže heslo BIOSu
+- Opraví RAID 0
+- Přidá paměť
+> Snímek je dočasná pojistka, ne trvalý režim.
diff --git a/content/technicke-vybaveni/3-rocnik/05-zalohovani.en.md b/content/technicke-vybaveni/3-rocnik/05-zalohovani.en.md
new file mode 100644
index 0000000..29c47a1
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/05-zalohovani.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Backups
+popis: A copy you can actually restore.
+nazev_cviceni: Quiz: Backups
+nadpis_kvizu: Quiz on Backups
+---
diff --git a/content/technicke-vybaveni/3-rocnik/05-zalohovani.md b/content/technicke-vybaveni/3-rocnik/05-zalohovani.md
new file mode 100644
index 0000000..8cd8db8
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/05-zalohovani.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-zalohovani
+puvodni: hw3.5
+nazev: Zálohování
+popis: Kopie, kterou umíte vrátit.
+nazev_cviceni: Kvíz: Zálohování
+nadpis_kvizu: Kvíz k tématu Zálohování
+---
+
+# Výklad
+
+## Tři místa
+*Pravidlo*
+
+- Jedna kopie je málo, když leží u originálu.
+- Druhá má být mimo stroj a třetí mimo budovu, když na tom záleží.
+- Verze v čase vrátí soubor z pátku, ne jen ze včerejška.
+- Šifrovaná záloha bez klíče je hromada nic.
+
+> Tip: kopie na tom samém disku přežije jen omyl v koši, ne pád disku.
+
+## Ne jen plochu
+*Co zálohovat*
+
+- Data, účty a konfigurace sítě jsou různé věci.
+- Poštovní schránka v cloudu má vlastní nastavení retence.
+- Databáze se nezálohuje kopírováním otevřeného souboru naslepo.
+- Seznam, co se nezálohuje, má být vědomý.
+
+> Tip: obraz systému bez dat školy je pěkná, ale prázdná záchrana.
+
+## Obnova je součást zálohy
+*Zkouška*
+
+- Jednou za čas obnovte soubor i celý systém nanečisto.
+- Měřte, jak dlouho obnova trvá.
+- Zápis říká, kdo má klíče a kde leží média.
+- Po ransomware rozhoduje stáří poslední čisté kopie.
+
+> Tip: záloha, kterou jste nikdy neobnovili, je hypotéza.
+
+
+# Kvíz
+
+## Proč nestačí kopie na stejném disku?
+- [x] Pád disku vezme originál i kopii
+- Systém druhou složku zakazuje
+- RAID ji sám smaže
+- NTFS umí jen jeden soubor
+> Chyba média je společná.
+
+## Co není pořádná zkouška zálohy?
+- [x] Jen to, že noční úloha svítí zeleně
+- Obnova jednoho souboru
+- Změřený čas obnovy systému
+- Ověření, že klíč k šifře existuje
+> Zelená fajfka neznamená, že data jdou otevřít.
+
+## Co rozhoduje po ransomware?
+- [x] Jestli existuje čistá kopie starší než nákaza
+- Barva skříně
+- Počet ventilátorů
+- Verze spořiče obrazovky
+> Šifrovaná záloha z doby, kdy už malware běžel, je taky šifrovaná.
+
+## Proč nekopírovat otevřenou databázi jako obyčejný soubor?
+- [x] Rozpracovaný zápis bude nekonzistentní
+- Databáze nemá soubor
+- SMB databáze zakazuje
+- Kopie by smazala RAID
+> Běžící databáze se zálohuje nástrojem, který umí konzistentní snímek.
diff --git a/content/technicke-vybaveni/3-rocnik/06-ups.en.md b/content/technicke-vybaveni/3-rocnik/06-ups.en.md
new file mode 100644
index 0000000..824ef90
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/06-ups.en.md
@@ -0,0 +1,6 @@
+---
+nazev: UPS
+popis: Minutes to shut down cleanly, not a spare power station.
+nazev_cviceni: Quiz: UPS
+nadpis_kvizu: Quiz on UPS
+---
diff --git a/content/technicke-vybaveni/3-rocnik/06-ups.md b/content/technicke-vybaveni/3-rocnik/06-ups.md
new file mode 100644
index 0000000..8460d6a
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/06-ups.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-ups
+puvodni: hw3.6
+nazev: UPS
+popis: Minuty na uložení, ne náhradní elektrárna.
+nazev_cviceni: Kvíz: UPS
+nadpis_kvizu: Kvíz k tématu UPS
+---
+
+# Výklad
+
+## Překlenout výpadek
+*Úkol*
+
+- Při výpadku přejde na baterii.
+- Čas běhu závisí na odběru, ne na nálepce bez zátěže.
+- Server dostane signál a vypne se po pořádku.
+- Tiskárna a monitor na UPS servery okrádají o minuty.
+
+> Tip: malá UPS neutáhne učebnu. Utáhne server, než se uloží.
+
+## Ne každá UPS je pro zdroj serveru
+*Tvar*
+
+- Sinusový výstup je jistější pro aktivní PFC zdroje.
+- Výkon ve voltampérech není totéž co watty na štítku zdroje.
+- Přetížená UPS spadne právě ve výpadku.
+- Zkouška pod zátěží ukáže skutečné minuty.
+
+> Tip: levná UPS s obdélníkovým průběhem modernímu zdroji vadí.
+
+## Spotřební díl
+*Baterie*
+
+- Test baterie nečeká na první bouřku.
+- Výměna se dělá dřív, než článek nabobtná.
+- Teplá místnost baterii ničí rychleji.
+- UPS není náhrada za zálohu dat.
+
+> Tip: baterie po třech letech v teple už slíbené minuty nedá.
+
+
+# Kvíz
+
+## K čemu malá UPS u serveru je?
+- [x] Aby se systém stihl uložit a vypnout
+- Aby učebna jela celé odpoledne
+- Aby nahradila zálohu disků
+- Aby chladila rack
+> Jsou to minuty, ne směna.
+
+## Proč na UPS nepatří laserová tiskárna?
+- [x] Má velký odběr a sebere čas serverům
+- Tiskárna UPS zničí vždy okamžitě
+- USB tiskárny UPS zakazuje
+- Toner potřebuje jinou frekvenci
+> Špička zapékání baterii vybíjí zbytečně.
+
+## Co ukáže jen nálepka bez zátěže?
+- [x] Optimistický čas, který v provozu neplatí
+- Přesné minuty při plném serveru
+- Stav RAID
+- Teplotu procesoru
+> Měří se s tím, co na UPS opravdu visí.
+
+## Jak často baterie přestane držet slib?
+- [x] Po letech, dřív v teple
+- Nikdy, je součástí zdroje navždy
+- Až po deseti minutách prvního provozu
+- Jen když je UPS černá
+> Je to spotřební díl a má se zkoušet.
diff --git a/content/technicke-vybaveni/3-rocnik/07-firmware.en.md b/content/technicke-vybaveni/3-rocnik/07-firmware.en.md
new file mode 100644
index 0000000..e5d8139
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/07-firmware.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Firmware
+popis: An update that is not applied in the middle of a lesson.
+nazev_cviceni: Quiz: Firmware
+nadpis_kvizu: Quiz on Firmware
+---
diff --git a/content/technicke-vybaveni/3-rocnik/07-firmware.md b/content/technicke-vybaveni/3-rocnik/07-firmware.md
new file mode 100644
index 0000000..1c2535b
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/07-firmware.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-firmware
+puvodni: hw3.7
+nazev: Firmware
+popis: Aktualizace, která nejde udělat uprostřed hodiny.
+nazev_cviceni: Kvíz: Firmware
+nadpis_kvizu: Kvíz k tématu Firmware
+---
+
+# Výklad
+
+## Program v železe
+*Co to je*
+
+- Deska, disk, switch i BIOS mají vlastní verzi.
+- Chyba ve firmware shodí zařízení bez ohledu na systém.
+- Výrobce vydává opravy i roky po prodeji.
+- Neznámá verze se nejdřív zjistí, potom se teprve stahuje.
+
+> Tip: firmware není ovladač ve Windows. Žije v zařízení i bez systému.
+
+## Jedno zařízení, zdroj, záloha
+*Postup*
+
+- Přečtěte, co verze opravuje a co rozbije.
+- Záloha konfigurace switche jde bokem.
+- Napájení nesmí vypadnout v půlce zápisu.
+- Po dokončení ověřte, že naběhla i služba, ne jen dioda.
+
+> Tip: aktualizace při bouřce bez UPS je způsob, jak dostat cihlu.
+
+## Ne všechno v pátek odpoledne
+*Řada*
+
+- Nejdřív méně důležité zařízení.
+- Okno, kdy smí síť ležet, je domluvené.
+- Když nová verze zlobí, musíte umět předchozí.
+- Zápis verze patří k zařízení, ne do hlavy jednoho člověka.
+
+> Tip: neaktualizujte jádro sítě a jediný server ve stejné hodině.
+
+
+# Kvíz
+
+## Čím se firmware liší od ovladače?
+- [x] Je v zařízení a běží i bez operačního systému
+- Je to jen soubor na ploše
+- Maže se s přeinstalací vždy
+- Umí ho jen tiskárna
+> BIOS a firmware disku přežijí nový systém.
+
+## Co před aktualizací switche uložíte?
+- [x] Konfiguraci a zajistíte napájení
+- Jen tapetu na ploše
+- Heslo Wi-Fi na tabuli
+- Nic, aktualizace si pamatuje sama
+> Přerušený zápis a chybějící záloha jsou dvě pasti.
+
+## Proč neaktualizovat jádro a jediný server naráz?
+- [x] Když obojí selže, nemáte z čeho učit ani spravovat
+- Výrobce to technicky zakazuje vždy
+- Firmware umí jen jedno zařízení na budovu
+- UPS to nesežere
+> Nechte si cestu zpět.
+
+## Co po aktualizaci ověříte?
+- [x] Že služba opravdu naběhla, ne jen že svítí dioda
+- Že je krabice teplejší
+- Že se změnila barva menu
+- Nic, firmware se neověřuje
+> Start zařízení není totéž co fungující síť.
diff --git a/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.en.md b/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.en.md
new file mode 100644
index 0000000..bc59680
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Measuring performance
+popis: A number without the conditions means nothing.
+nazev_cviceni: Quiz: Measuring performance
+nadpis_kvizu: Quiz on Measuring performance
+---
diff --git a/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.md b/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.md
new file mode 100644
index 0000000..dc8d55b
--- /dev/null
+++ b/content/technicke-vybaveni/3-rocnik/08-mereni-vykonu.md
@@ -0,0 +1,71 @@
+---
+id: hardware-3-mereni-vykonu
+puvodni: hw3.8
+nazev: Měření výkonu
+popis: Číslo bez podmínek nic neznamená.
+nazev_cviceni: Kvíz: Měření výkonu
+nadpis_kvizu: Kvíz k tématu Měření výkonu
+---
+
+# Výklad
+
+## Úzké hrdlo
+*Co měřit*
+
+- Sledujte procesor, paměť, disk a síť zvlášť.
+- Vytížení 100 % chvíli není chyba, trvale ano.
+- Fronta na disku řekne víc než otáčky ventilátoru.
+- Jedno číslo z herního testu není návrh školního serveru.
+
+> Tip: rychlý procesor nezachrání pomalý disk, na kterém systém čeká.
+
+## Stejná zkouška
+*Podmínky*
+
+- Studený start a druhý běh z cache nejsou totéž.
+- Notebook na baterii schválně zpomalí.
+- Antivirus v testu umí sežrat výsledek.
+- Zapište verzi systému i to, co na pozadí jelo.
+
+> Tip: srovnávejte stejný test, stejná data a stejný čas běhu.
+
+## Kdy dokoupit
+*Rozhodnutí*
+
+- Málo paměti žene systém na disk a všechno zpomalí.
+- Plný a pomalý disk kazí i silný procesor.
+- Slabé Wi-Fi nezrychlíte výměnou grafické karty.
+- Nákup bez měření je odhad.
+
+> Tip: nejdřív paměť nebo disk, ne automaticky nejnovější procesor.
+
+
+# Kvíz
+
+## Co neznamená krátká špička procesoru na 100 %?
+- [x] Že je procesor nutně slabý
+- Že je všechno v pořádku navždy
+- Že disk neexistuje
+- Že se má hned vyměnit deska
+> Záleží, jestli špička trvá a jestli na ní někdo čeká.
+
+## Proč druhý běh testu bývá rychlejší?
+- [x] Data už jsou v cache
+- Procesor se sám přetaktuje natrvalo
+- Disk se zmenší
+- BIOS test podruhé vynechá
+> Cache není výkon železa v prvním čtení.
+
+## Co často zpomalí celý systém dřív než procesor?
+- [x] Málo paměti a pomalý disk
+- Příliš mnoho USB myš
+- Barva skříně
+- Počet ikon na ploše
+> Systém pak pořád čeká na stránkování.
+
+## Co k výsledku testu patří zapsat?
+- [x] Verze, napájení a co jelo na pozadí
+- Jen nejvyšší číslo
+- Jméno učitele
+- Heslo k BIOSu
+> Bez podmínek číslo nejde zopakovat.
diff --git a/content/technicke-vybaveni/4-rocnik/01-udrzba.en.md b/content/technicke-vybaveni/4-rocnik/01-udrzba.en.md
new file mode 100644
index 0000000..dbc5959
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/01-udrzba.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Maintenance
+popis: Cleaning and checks before something burns.
+nazev_cviceni: Quiz: Maintenance
+nadpis_kvizu: Quiz on Maintenance
+---
diff --git a/content/technicke-vybaveni/4-rocnik/01-udrzba.md b/content/technicke-vybaveni/4-rocnik/01-udrzba.md
new file mode 100644
index 0000000..843f225
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/01-udrzba.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-udrzba
+puvodni: hw4.1
+nazev: Údržba
+popis: Čištění a revize dřív, než něco shoří.
+nazev_cviceni: Kvíz: Údržba
+nadpis_kvizu: Kvíz k tématu Údržba
+---
+
+# Výklad
+
+## Řád, ne až když hučí
+*Prašnost*
+
+- Filtry se perou dřív, než zčernají.
+- Zdroj se neotevírá. Čistí se zvenku.
+- Klávesnice a myš učebny jsou spotřební a špinavé.
+- Zápis, kdy se učebna dělala, příště určí interval.
+
+> Tip: otevřená skříň vysátá naslepo umí nasát prach dovnitř zdroje.
+
+## Uvolněný kabel vypadá jako mrtvá deska
+*Kontakty*
+
+- Oxidy na stolních konektorech kazí obraz i USB.
+- Ohnutý konektor HDMI se do portu necpe silou.
+- Napájecí kabel s poškozenou izolací se vyřadí, nelepí páskou.
+- Prodlužovačka pod kobercem je požár, ne úspora místa.
+
+> Tip: než objednáte desku, domáčkněte napájení a vyzkoušejte jiný kabel obrazu.
+
+## Co ve třídě vůbec je
+*Evidence*
+
+- Štítek, umístění a rok pořízení.
+- Záruka a doklad nejsou v šuplíku jednoho člověka.
+- Odepsaný stroj se nevrací do sítě s původním účtem.
+- Pravidelná prohlídka je levnější než hodina bez učebny.
+
+> Tip: bez inventárního čísla nepoznáte, který zdroj se vrací do záruky.
+
+
+# Kvíz
+
+## Co se při údržbě se zdrojem nedělá?
+- [x] Neotevírá se
+- Omývá se mu mřížka zvenku
+- Kontroluje se kabel
+- Píše se, kdy byl čištěný
+> Uvnitř je napětí i po odpojení.
+
+## Co uděláte s kabelem s porušenou izolací?
+- [x] Vyřadíte ho
+- Zalepíte izolepou a vrátíte pod koberec
+- Schováte ho za skříň
+- Dáte ho k žákovskému počítači
+> Poškozená izolace je požár a úraz.
+
+## K čemu je inventární číslo?
+- [x] Aby šlo dohledat záruku a umístění
+- Aby zrychlilo procesor
+- Nahrazuje heslo BIOSu
+- Je to sériové číslo Windows
+> Bez něj je reklamace hádanka.
+
+## Kam nepatří odepsaný počítač?
+- [x] Zpět do sítě s původními účty
+- Do evidence odpadu
+- K smazání disků
+- Mimo žákovskou VLAN
+> Starý účet na mrtvém stroji je pořád díra, když ho někdo zapne.
diff --git a/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.en.md b/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.en.md
new file mode 100644
index 0000000..39ba2c3
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.en.md
@@ -0,0 +1,6 @@
+---
+nazev: TPM and Secure Boot
+popis: Hardware that watches the system start.
+nazev_cviceni: Quiz: TPM and Secure Boot
+nadpis_kvizu: Quiz on TPM and Secure Boot
+---
diff --git a/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.md b/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.md
new file mode 100644
index 0000000..769729c
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/02-tpm-a-secure-boot.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-tpm-a-secure-boot
+puvodni: hw4.2
+nazev: TPM a Secure Boot
+popis: Železo, které hlídá start systému.
+nazev_cviceni: Kvíz: TPM a Secure Boot
+nadpis_kvizu: Kvíz k tématu TPM a Secure Boot
+---
+
+# Výklad
+
+## Klíče mimo disk
+*TPM*
+
+- Čip drží klíče použité při startu a šifrování disku.
+- Bez něj některé systémy šifrování disku odmítnou.
+- Vymazání TPM bez zálohy klíčů znamená ztrátu přístupu k disku.
+- Na virtuálu bývá TPM také virtuální a patří k tomu stroji.
+
+> Tip: TPM není heslo, které uživatel píše při každém startu.
+
+## Řetěz důvěry
+*Start*
+
+- Firmware ověří zavaděč.
+- Zavaděč ověří jádro.
+- Cizí systém z flash disku bez podpisu nenaběhne.
+- V laboratoři se výjimka zapíše, nenechá vypnutá navždy.
+
+> Tip: Secure Boot a TPM řeší start, ne to, co uživatel spustí odpoledne.
+
+## Kensington není šifra
+*Zámek*
+
+- Zámek skříně zpomalí toho, kdo chce odnést RAM.
+- Heslo BIOSu zpomalí toho, kdo chce přeházet boot.
+- Šifra disku zastaví toho, kdo disk odnese.
+- Všechny tři vrstvy řeší jiného zloděje.
+
+> Tip: lankový zámek zdrží odnesení, data chrání šifrování disku.
+
+
+# Kvíz
+
+## Co TPM drží?
+- [x] Klíče pro start a šifrování
+- Zálohu všech dokumentů školy
+- Obraz monitoru
+- Seznam VLAN
+> Není to úložiště souborů.
+
+## Co se stane po vymazání TPM bez zálohy klíčů?
+- [x] Šifrovaný disk nemusí jít otevřít
+- Zrychlí se start
+- Smaže se jen spořič
+- Nic, TPM klíče nepoužívá
+> Klíč v čipu bez kopie je pryč.
+
+## Co Secure Boot nezastaví?
+- [x] Program, který uživatel spustí v už naběhlém systému
+- Nepodepsaný zavaděč
+- Cizí systém z flashe, pokud podpis chybí
+- Změnu řetězu startu
+> Hlídá start, ne každé pozdější kliknutí.
+
+## Co ochrání data na odcizeném disku?
+- [x] Šifrování disku
+- Jen lankový zámek skříně
+- Heslo spořiče
+- Inventární štítek
+> Zámek zdrží odnesení celé skříně, ne čtení disku na jiném stroji.
diff --git a/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.en.md b/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.en.md
new file mode 100644
index 0000000..daa49e7
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Buying a computer
+popis: The work comes first, then the box.
+nazev_cviceni: Quiz: Buying a computer
+nadpis_kvizu: Quiz on Buying a computer
+---
diff --git a/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.md b/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.md
new file mode 100644
index 0000000..ecb82c1
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/03-nakup-sestavy.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-nakup-sestavy
+puvodni: hw4.3
+nazev: Nákup sestavy
+popis: Nejdřív práce, potom krabice.
+nazev_cviceni: Kvíz: Nákup sestavy
+nadpis_kvizu: Kvíz k tématu Nákup sestavy
+---
+
+# Výklad
+
+## Učebna není střih videa
+*Účel*
+
+- Kancelář chce spolehlivost, ticho a dost paměti na prohlížeč.
+- Střih a CAD chtějí procesor, paměť a často grafiku.
+- Server chce ECC nebo aspoň diskové pole a správu, ne podsvícení.
+- Počet portů a dokování je součást ceny, ne doplněk potom.
+
+> Tip: stejná sestava pro psaní a pro CAD je buď drahá, nebo pomalá.
+
+## Co srovnávat
+*Čísla*
+
+- Paměť berte s rezervou na čtyři roky, ne na dnešní minimum.
+- SSD jako systémový disk je základ. Velký sklad může být pomalejší.
+- Zdroj má rezervu nad součtem štítků, ne jen přesný součet.
+- Záruka na místě je u učebny levnější než odvoz dvaceti strojů.
+
+> Tip: více jader nepomůže programu, který umí jen jedno.
+
+## Jedna image
+*Stejnost*
+
+- Stejný model v učebně zjednoduší image i náhradní díl.
+- Dokoupit za dva roky stejnou desku často nejde. Pište, co je uvnitř.
+- Nejlevnější kus bez portů, které učebna používá, levný není.
+- Příkon a hluk patří do zadání, ne až do reklamace.
+
+> Tip: dvacet různých desek znamená dvacet ovladačů.
+
+
+# Kvíz
+
+## Podle čeho se volí sestava?
+- [x] Podle práce, kterou na ní škola dělá
+- Podle barvy krabice
+- Podle nejvyššího čísla v reklamě
+- Podle počtu RGB ventilátorů
+> CAD a psaní dopisu nechtějí stejné železo.
+
+## Proč v učebně stejný model?
+- [x] Jedna image a jeden náhradní díl
+- Aby se lišily ovladače
+- Aby každý stroj bootoval jinak
+- Zákon to vyžaduje kvůli VLAN
+> Různé desky znamenají různou údržbu.
+
+## Co u zdroje nestačí?
+- [x] Součet štítků bez rezervy
+- Rezerva nad typický odběr
+- Správný formát do skříně
+- Pojistka, která sedí k rozvodu
+> Špička a stárnutí se do přesného součtu nevejdou.
+
+## Co zdraží zdánlivě levný notebook?
+- [x] Chybějící porty a dok, který musíte dokoupit
+- To, že má SSD
+- To, že má záruku
+- To, že je tichý
+> Učebna bez obrazových výstupů potřebuje doky.
diff --git a/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.en.md b/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.en.md
new file mode 100644
index 0000000..7496177
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Mobile devices
+popis: Tablets and phones on the school network.
+nazev_cviceni: Quiz: Mobile devices
+nadpis_kvizu: Quiz on Mobile devices
+---
diff --git a/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.md b/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.md
new file mode 100644
index 0000000..6fef017
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/04-mobilni-zarizeni.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-mobilni-zarizeni
+puvodni: hw4.4
+nazev: Mobilní zařízení
+popis: Tablet a telefon ve školní síti.
+nazev_cviceni: Kvíz: Mobilní zařízení
+nadpis_kvizu: Kvíz k tématu Mobilní zařízení
+---
+
+# Výklad
+
+## Cizí kapsy
+*Správa*
+
+- Školní tablet se dá zapsat do správy a na dálku smazat.
+- Soukromý telefon do správy školy nepatří.
+- Účet školy na soukromém zařízení má jít odebrat.
+- Ztracený školní tablet bez správy je cizí počítač s vašimi daty.
+
+> Tip: telefon žáka není školní majetek a nepatří mu školní certifikát natrvalo.
+
+## Kam ho pustit
+*Síť*
+
+- Hostovská nebo mobilní síť je oddělená.
+- Školní tablet může dostat jinou síť než návštěva.
+- Sdílené tablety se mezi žáky mažou nebo přepínají profil.
+- Nabíjení ve třídě je otázka pojistek, ne jen kabelů na stole.
+
+> Tip: telefon učitelů ve stejné VLAN jako servery je zbytečné riziko.
+
+## Baterie a pád
+*Život*
+
+- Obal a popruh jsou součást nákupu, ne charita.
+- Aktualizace systému se dělají hromadně, ne podle nálady.
+- Rozbitý displej se nevrací do oběhu s trčícím sklem.
+- Evidence IMEI nebo sériového čísla patří k inventuře.
+
+> Tip: cyklování baterie v nabíjecím kufru ji po dvou letech unaví.
+
+
+# Kvíz
+
+## Čím se liší školní tablet od soukromého telefonu?
+- [x] Školní jde zapsat do správy a na dálku vymazat
+- Soukromý musí mít školní certifikát navždy
+- Jsou to stejné režimy
+- Tablet nepatří do inventury
+> Majetek školy se dá spravovat, kapsa žáka ne.
+
+## Kam nepatří učitelský telefon?
+- [x] Do VLAN se servery
+- Do oddělené mobilní sítě
+- Na účet, který jde odebrat
+- Mimo žákovská data, pokud to jde
+> Ztracený telefon nemá být klíč k serverům.
+
+## Co sdílený tablet mezi žáky potřebuje?
+- [x] Mazání profilu nebo oddělené účty
+- Jedno heslo napsané na obalu
+- Trvalou složku předchozího žáka
+- Vypnuté aktualizace
+> Cizí účet na dalším žákovi je únik.
+
+## Co patří do evidence tabletu?
+- [x] Sériové číslo a umístění
+- Jen barva obalu
+- Heslo žáka
+- Soukromé fotky
+> Bez čísla ho nepoznáte v kupě stejných.
diff --git a/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.en.md b/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.en.md
new file mode 100644
index 0000000..5113620
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Peripherals in daily use
+popis: Printers, projectors, and what actually breaks.
+nazev_cviceni: Quiz: Peripherals in daily use
+nadpis_kvizu: Quiz on Peripherals in daily use
+---
diff --git a/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.md b/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.md
new file mode 100644
index 0000000..2b82c27
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/05-periferie-v-provozu.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-periferie-v-provozu
+puvodni: hw4.5
+nazev: Periferie v provozu
+popis: Tiskárny, projektory a co se na nich láme.
+nazev_cviceni: Kvíz: Periferie v provozu
+nadpis_kvizu: Kvíz k tématu Periferie v provozu
+---
+
+# Výklad
+
+## Lampa a síť
+*Projektor*
+
+- Zdroj obrazu a kabel jsou častější chyba než deska.
+- Filtr projektoru se ucpe prachem a přístroj se vypne.
+- Stropní držák se dotahuje, nevěší se na něj.
+- Náhradní kabel ve třídě je levnější než přestávka.
+
+> Tip: lampa má hodiny. Nečekejte, až praskne uprostřed hodiny.
+
+## Fronta a toner
+*Tisk*
+
+- Sklad toneru má být suchý, ne na topení.
+- Účet tisku omezí náhodné desítky stran.
+- Síťová tiskárna se po výměně adresy musí najít znovu.
+- Servisní interval bubnu není totéž co výměna toneru.
+
+> Tip: prázdný toner a zaseknutý papír jsou dvě různé hlášky.
+
+## Myš, sluchátka, kamera
+*Drobnosti*
+
+- Sluchátka ve třídě mají být omyvatelná.
+- Kamera na dálkovou výuku chce světlo před obličejem, ne za zády.
+- USB přijímač myši ztracený v portu projektoru se špatně hledá.
+- Spotřební věci se objednávají dřív, než dojde poslední kus.
+
+> Tip: bezdrátová myš bez náhradní baterie je v hodině mrtvá.
+
+
+# Kvíz
+
+## Co u projektoru kontrolovat dřív než desku?
+- [x] Zdroj obrazu, kabel a filtr
+- Jen typ žárovky ve stropě učebny
+- Heslo Wi-Fi
+- Verzi BIOSu učitelského telefonu
+> Obraz často končí na kabelu.
+
+## Čím se liší toner a buben?
+- [x] Toner je prach s barvou, buben je díl, který obraz přenáší
+- Jsou to totéž
+- Buben se mění každý den
+- Toner je jen u inkoustové tiskárny
+> Prázdný toner a unavený buben vypadají na stránce jinak.
+
+## Proč účty tisku?
+- [x] Omezí náhodné velké tisky
+- Zvýší rozlišení
+- Nahradí papír
+- Vypnou skener
+> Fronta bez limitu tiskne omylem celé knihy.
+
+## Co ve třídě dochází bez náhrady jako první?
+- [x] Baterie, kabely a spotřební díly
+- Procesory
+- Rackové lišty
+- TPM čipy
+> Drobnost zastaví hodinu stejně jako velká porucha.
diff --git a/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.en.md b/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.en.md
new file mode 100644
index 0000000..6e57cc0
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Disposal and data
+popis: A discarded drive still contains the school.
+nazev_cviceni: Quiz: Disposal and data
+nadpis_kvizu: Quiz on Disposal and data
+---
diff --git a/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.md b/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.md
new file mode 100644
index 0000000..3cf69fb
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/06-ekologie-a-data.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-ekologie-a-data
+puvodni: hw4.6
+nazev: Ekologie a data
+popis: Vyhozený disk pořád obsahuje školu.
+nazev_cviceni: Kvíz: Ekologie a data
+nadpis_kvizu: Kvíz k tématu Ekologie a data
+---
+
+# Výklad
+
+## Smazat neznamená vyhodit
+*Disk*
+
+- Před vyřazením se disk smaže tak, aby nešel běžně obnovit.
+- Nefunkční disk se fyzicky zničí, ne jen odloží.
+- SSD se maže příkazem, který buňky opravdu uvolní.
+- Potvrzení o likvidaci patří k inventuře.
+
+> Tip: rychlé formátování nechá data čitelná.
+
+## Není to směsný odpad
+*Železo*
+
+- Elektroodpad se odevzdává na určené místo.
+- Baterie zvlášť, a nabobtnané ještě opatrněji.
+- Toner je taky odpad s režimem, ne koš ve sborovně.
+- Štítek s jménem žáka se před odvozem sundá.
+
+> Tip: monitor a deska nepatří do popelnice za školou.
+
+## Starý stroj ven ze školy
+*Darování*
+
+- Nejdřív data, potom majetek.
+- Licence vázaná na školu se nesmí odstěhovat s počítačem.
+- Zápis, komu stroj připadl, chrání školu.
+- Nefunkční dar je jen přesun odpadu.
+
+> Tip: darovaný počítač bez smazání účtů je dar i s přístupy.
+
+
+# Kvíz
+
+## Proč nestačí rychlé formátování?
+- [x] Data jdou často obnovit
+- Formát disk fyzicky rozbije
+- SSD formát zakazuje
+- Smaže i TPM v jiném počítači
+> Zmizí odkaz, ne obsah buněk.
+
+## Kam nepatří stará deska?
+- [x] Do směsného koše
+- Do elektroodpadu
+- K evidované likvidaci
+- Po smazání dat, pokud se vyřazuje
+> Elektroodpad má vlastní režim.
+
+## Co před darem počítače uděláte?
+- [x] Smažete data a ověříte licence
+- Necháte účty, ať se nový majitel přihlásí
+- Jen umyjete skříň
+- Přilepíte heslo na víko
+> Dar není předání školní sítě.
+
+## Čím se maže SSD líp než jedním přepisem naslepo?
+- [x] Příkazem, který buňky opravdu uvolní
+- Smazáním koše
+- Změnou tapety
+- Vypnutím Secure Boot
+> Řadič SSD přepis naslepo nemusí uložit tam, kde čekáte.
diff --git a/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.en.md b/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.en.md
new file mode 100644
index 0000000..b2bd544
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.en.md
@@ -0,0 +1,6 @@
+---
+nazev: A fault method
+popis: One sentence that holds the whole procedure.
+nazev_cviceni: Quiz: A fault method
+nadpis_kvizu: Quiz on A fault method
+---
diff --git a/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.md b/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.md
new file mode 100644
index 0000000..2b6a7ad
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/07-metodika-zavady.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-metodika-zavady
+puvodni: hw4.7
+nazev: Metodika závady
+popis: Jedna věta, která drží celý postup.
+nazev_cviceni: Kvíz: Metodika závady
+nadpis_kvizu: Kvíz k tématu Metodika závady
+---
+
+# Výklad
+
+## Co přesně nejde
+*Příznak*
+
+- Zeptejte se, co uživatel čekal a co vidí.
+- Od kdy a jestli to jde jinde.
+- Jedna učebna, nebo jen jeden stůl.
+- Bez příznaku se mění díly naslepo.
+
+> Tip: ‚nejde počítač‘ může být černý obraz, špatné heslo i spadlá síť.
+
+## Nejužší místo
+*Izolace*
+
+- Vyměňte kabel, port, účet nebo zařízení, vždy jen jedno.
+- Když náhradní kus funguje, původní je vinný nebo špatně nastavený.
+- Když nefunguje ani náhradní, chyba je ve společné věci.
+- Pište si mezivýsledky. Paměť po obědě lže.
+
+> Tip: fungující kabel a fungující port rozdělí chybu na dvě hromady.
+
+## Oprava a prevence
+*Závěr*
+
+- Opravte příčinu, ne jen příznak na jednom stole.
+- Řekněte uživateli, co se změnilo.
+- Díly do opravy nepatří zpátky do skříně potichu.
+- Opakovaná závada chce úpravu nákupu nebo zapojení.
+
+> Tip: vyměněný kabel bez zápisu se příští měsíc tváří jako záhada.
+
+
+# Kvíz
+
+## Co je první otázka?
+- [x] Co přesně nejde a od kdy
+- Jakou má deska barvu
+- Jestli je ve škole VLAN 20
+- Kolik stojí nový notebook
+> Příznak zužuje vrstvu, kterou budete zkoušet.
+
+## Proč měnit jen jeden díl?
+- [x] Ať víte, který rozdíl pomohl
+- Sklad má jen jeden kabel
+- BIOS víc výměn zakazuje
+- Uživatel jinak zapomene heslo
+> Dvě výměny naráz nic nedokážou.
+
+## Náhradní kabel taky nejde. Co z toho plyne?
+- [x] Chyba je spíš ve portu, účtu nebo zařízení
+- Oba kabely jsou vždy špatné
+- Síť nemá smysl
+- Je rozbitý TPM
+> Společné selhání ukazuje na společnou část.
+
+## Co patří do zápisu opravy?
+- [x] Příznak, příčina a co se vyměnilo
+- Jen ‚hotovo‘
+- Heslo uživatele
+- Soukromý názor na učitele
+> Příště podle zápisu nezačínáte od nuly.
diff --git a/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.en.md b/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.en.md
new file mode 100644
index 0000000..9ffb293
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.en.md
@@ -0,0 +1,6 @@
+---
+nazev: Exam review
+popis: Hardware you can name and justify.
+nazev_cviceni: Quiz: Exam review
+nadpis_kvizu: Quiz on Exam review
+---
diff --git a/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.md b/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.md
new file mode 100644
index 0000000..7073ae1
--- /dev/null
+++ b/content/technicke-vybaveni/4-rocnik/08-opakovani-k-maturite.md
@@ -0,0 +1,71 @@
+---
+id: hardware-4-opakovani-k-maturite
+puvodni: hw4.8
+nazev: Opakování k maturitě
+popis: Železo, které umíte pojmenovat a zdůvodnit.
+nazev_cviceni: Kvíz: Opakování k maturitě
+nadpis_kvizu: Kvíz k tématu Opakování k maturitě
+---
+
+# Výklad
+
+## Od kliknutí k disku
+*Tok dat*
+
+- Vstup, paměť, procesor, sběrnice, disk a výstup.
+- RAM je pracovní, disk je trvalý. Není to totéž.
+- Grafika může mít vlastní paměť a pořád jede po sběrnici.
+- Úzké hrdlo je nejpomalejší článek této cesty.
+
+> Tip: maturita chce cestu, ne seznam zkratek.
+
+## Co drží učebnu
+*Spolehlivost*
+
+- RAID drží provoz při pádu disku.
+- Záloha vrátí data z jiného místa a času.
+- UPS dá čas na vypnutí.
+- Firmware a čištění jsou prevence, ne oprava po požáru.
+
+> Tip: záloha, RAID a UPS řeší tři různé nehody.
+
+## Proč právě tahle krabice
+*Rozhodnutí*
+
+- Kancelář, střih a server mají jiné priority.
+- Stejný model ve třídě šetří image.
+- Vyřazení začíná smazáním dat.
+- Závadu vedete od příznaku po jedné změně.
+
+> Tip: umíte obhájit paměť, disk a porty pro konkrétní učebnu.
+
+
+# Kvíz
+
+## Čím se liší RAM a disk?
+- [x] RAM je pracovní paměť, disk drží data i bez napájení
+- Jsou to totéž
+- Disk je vždy rychlejší
+- RAM se zálohuje na pásku
+> Vypnutí smaže obsah RAM, ne uložený soubor na disku.
+
+## Co vrátí soubor smazaný před týdnem?
+- [x] Záloha z dřívějšího dne, ne RAID
+- RAID 1 sám
+- UPS
+- Secure Boot
+> RAID má pořád aktuální, tedy i smazaný stav.
+
+## Co dá UPS?
+- [x] Čas na pořádné vypnutí
+- Kopii dat v jiné budově
+- Rychlejší procesor
+- Nový disk
+> Baterie není archiv.
+
+## Čím začíná vyřazení počítače?
+- [x] Smazáním dat
+- Sundáním obalu
+- Darem bez kontroly
+- Zápisem do VLAN
+> Nejdřív obsah, potom plech.
diff --git a/data/share/applications/org.maturita.Maturita.desktop b/data/share/applications/org.maturita.Maturita.desktop
new file mode 100644
index 0000000..0530844
--- /dev/null
+++ b/data/share/applications/org.maturita.Maturita.desktop
@@ -0,0 +1,14 @@
+[Desktop Entry]
+Type=Application
+Version=1.0
+Name=Graduately
+GenericName=Graduately study app
+GenericName[cs]=Příprava na maturitu
+Comment=Practice app for the Czech maturita exam
+Comment[cs]=Procvičování k maturitě
+Exec=graduately
+Icon=maturita
+Terminal=false
+Categories=Education;
+StartupNotify=true
+StartupWMClass=org-maturita-maturita-desktop-MainKt
diff --git a/data/share/icons/hicolor/128x128/apps/maturita.png b/data/share/icons/hicolor/128x128/apps/maturita.png
new file mode 100644
index 0000000..48d2e36
Binary files /dev/null and b/data/share/icons/hicolor/128x128/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/16x16/apps/maturita.png b/data/share/icons/hicolor/16x16/apps/maturita.png
new file mode 100644
index 0000000..7d992c8
Binary files /dev/null and b/data/share/icons/hicolor/16x16/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/256x256/apps/maturita.png b/data/share/icons/hicolor/256x256/apps/maturita.png
new file mode 100644
index 0000000..8fb4fcc
Binary files /dev/null and b/data/share/icons/hicolor/256x256/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/32x32/apps/maturita.png b/data/share/icons/hicolor/32x32/apps/maturita.png
new file mode 100644
index 0000000..9f3b1d5
Binary files /dev/null and b/data/share/icons/hicolor/32x32/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/48x48/apps/maturita.png b/data/share/icons/hicolor/48x48/apps/maturita.png
new file mode 100644
index 0000000..747ab3f
Binary files /dev/null and b/data/share/icons/hicolor/48x48/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/512x512/apps/maturita.png b/data/share/icons/hicolor/512x512/apps/maturita.png
new file mode 100644
index 0000000..4cad9e6
Binary files /dev/null and b/data/share/icons/hicolor/512x512/apps/maturita.png differ
diff --git a/data/share/icons/hicolor/64x64/apps/maturita.png b/data/share/icons/hicolor/64x64/apps/maturita.png
new file mode 100644
index 0000000..4f7fd30
Binary files /dev/null and b/data/share/icons/hicolor/64x64/apps/maturita.png differ
diff --git a/graduately-android.apk b/graduately-android.apk
deleted file mode 100644
index b571264..0000000
Binary files a/graduately-android.apk and /dev/null differ
diff --git a/graduately-installer-windows.zip b/graduately-installer-windows.zip
deleted file mode 100644
index 6ae9ad3..0000000
Binary files a/graduately-installer-windows.zip and /dev/null differ
diff --git a/graduately-ios.ipa b/graduately-ios.ipa
deleted file mode 100644
index 573f605..0000000
Binary files a/graduately-ios.ipa and /dev/null differ
diff --git a/graduately-linux-x86_64.AppImage b/graduately-linux-x86_64.AppImage
deleted file mode 100644
index 707d47e..0000000
Binary files a/graduately-linux-x86_64.AppImage and /dev/null differ
diff --git a/graduately-macos-arm64.zip b/graduately-macos-arm64.zip
deleted file mode 100644
index 8244109..0000000
Binary files a/graduately-macos-arm64.zip and /dev/null differ
diff --git a/graduately-windows-x64.zip b/graduately-windows-x64.zip
deleted file mode 100644
index ef50bd1..0000000
Binary files a/graduately-windows-x64.zip and /dev/null differ
diff --git a/installers/graduately-android.html b/installers/graduately-android.html
new file mode 100644
index 0000000..ccf32eb
--- /dev/null
+++ b/installers/graduately-android.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Stáhnout Graduately
+
+
+
+ Graduately pro Android
+ Klepni na tlačítko. Až se APK stáhne, otevři ho a povol instalaci z neznámých zdrojů.
+
+
+ Stáhnout APK
+
+
+
+ Když odkaz hlásí 404, otevři
+ větev builds
+ a stáhni graduately-android.apk odtud.
+
+
+
+
diff --git a/graduately-installer-android.zip b/installers/graduately-installer-android.zip
similarity index 75%
rename from graduately-installer-android.zip
rename to installers/graduately-installer-android.zip
index f9ddd7f..0e90dfa 100644
Binary files a/graduately-installer-android.zip and b/installers/graduately-installer-android.zip differ
diff --git a/graduately-installer-ios.zip b/installers/graduately-installer-ios.zip
similarity index 78%
rename from graduately-installer-ios.zip
rename to installers/graduately-installer-ios.zip
index cdafa4d..01d8c69 100644
Binary files a/graduately-installer-ios.zip and b/installers/graduately-installer-ios.zip differ
diff --git a/graduately-installer-linux.sh b/installers/graduately-installer-linux.sh
similarity index 100%
rename from graduately-installer-linux.sh
rename to installers/graduately-installer-linux.sh
diff --git a/graduately-installer-linux.zip b/installers/graduately-installer-linux.zip
similarity index 83%
rename from graduately-installer-linux.zip
rename to installers/graduately-installer-linux.zip
index e8682c2..1c2e54a 100644
Binary files a/graduately-installer-linux.zip and b/installers/graduately-installer-linux.zip differ
diff --git a/installers/graduately-installer-macos.command b/installers/graduately-installer-macos.command
new file mode 100755
index 0000000..d0e7d84
--- /dev/null
+++ b/installers/graduately-installer-macos.command
@@ -0,0 +1,155 @@
+#!/bin/sh
+# Graduately installer for macOS.
+#
+# Carries no application of its own. Fastly caches branch-named URLs on
+# raw.githubusercontent.com, so this script looks up the tip of `builds`
+# and downloads that commit — not a stale zip from the last five minutes.
+#
+# Double-click the bundled .app (or this file). No Terminal commands needed.
+
+set -eu
+
+REPO="pepaskopec-blip/Graduately"
+BRANCH="builds"
+ASSET="graduately-macos-arm64.zip"
+
+# An .app has no TTY; show dialogs so a regular user never needs Terminal.
+GUI=0
+if [ ! -t 1 ] && command -v osascript >/dev/null 2>&1; then
+ GUI=1
+fi
+
+say() { printf '%s\n' "$*"; }
+
+gui_dialog() {
+ osascript - "$1" <<'APPLESCRIPT'
+on run argv
+ display dialog (item 1 of argv) with title "Graduately" buttons {"OK"} default button 1
+end run
+APPLESCRIPT
+}
+
+gui_alert() {
+ osascript - "$1" <<'APPLESCRIPT'
+on run argv
+ display dialog (item 1 of argv) with title "Graduately" buttons {"OK"} default button 1 with icon stop
+end run
+APPLESCRIPT
+}
+
+die() {
+ if [ "$GUI" = 1 ]; then
+ gui_alert "$*" >/dev/null || true
+ else
+ printf '\nChyba: %s\n' "$*" >&2
+ printf 'Stiskněte Return. '
+ read -r _ || true
+ fi
+ exit 1
+}
+
+builds_sha() {
+ curl -fsSL --max-time 20 \
+ "https://github.com/$REPO/commits/$BRANCH.atom" |
+ sed -n 's/.*Commit\/\([0-9a-f]\{40\}\).*/\1/p' |
+ head -n 1
+}
+
+if [ "$GUI" = 1 ]; then
+ if ! osascript <<'APPLESCRIPT'
+display dialog "Nainstalovat Graduately do složky Aplikace? Stáhne se aktuální verze." with title "Graduately" buttons {"Zrušit", "Instalovat"} default button "Instalovat"
+APPLESCRIPT
+ then
+ exit 0
+ fi
+else
+ say "Installing Graduately"
+ say "---------------------"
+fi
+
+arch=$(uname -m)
+if [ "$arch" != "arm64" ]; then
+ die "Tento instalátor je pro Mac s čipem Apple. Tento počítač hlásí '$arch'."
+fi
+
+command -v curl >/dev/null 2>&1 || die "Na Macu chybí curl, bez něj nejde nic stáhnout."
+
+# ~/Applications is writable from the GUI updater later. /Applications
+# often is not, even when this installer can drop a .app there once.
+dest_dir="$HOME/Applications"
+mkdir -p "$dest_dir"
+# Remove legacy Maturita.app and any prior Graduately install in /Applications.
+for legacy in Maturita.app Graduately.app; do
+ if [ -e "/Applications/$legacy" ] && [ -w "/Applications/$legacy" ]; then
+ rm -rf "/Applications/$legacy"
+ fi
+done
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT INT TERM
+
+if [ "$GUI" = 1 ]; then
+ osascript <<'APPLESCRIPT' >/dev/null || true
+display notification "Stahuji aktuální verzi…" with title "Graduately"
+APPLESCRIPT
+fi
+
+sha=$(builds_sha)
+[ ${#sha} -eq 40 ] || die "Nepodařilo se najít aktuální verzi na GitHubu."
+URL="https://raw.githubusercontent.com/$REPO/$sha/$ASSET"
+LEGACY_ASSET="maturita-macos-arm64.zip"
+LEGACY_URL="https://raw.githubusercontent.com/$REPO/$sha/$LEGACY_ASSET"
+
+download_ok=0
+if [ "$GUI" = 1 ]; then
+ if curl -fsSL --max-time 1800 -o "$tmp/$ASSET" "$URL"; then
+ download_ok=1
+ elif curl -fsSL --max-time 1800 -o "$tmp/$ASSET" "$LEGACY_URL"; then
+ download_ok=1
+ fi
+ [ "$download_ok" = 1 ] ||
+ die "Stažení se nezdařilo. Zkontrolujte internet a zkuste to znovu."
+else
+ say "Downloading build $(printf '%.7s' "$sha")..."
+ if curl -fL --progress-bar --max-time 1800 -o "$tmp/$ASSET" "$URL"; then
+ download_ok=1
+ elif curl -fL --progress-bar --max-time 1800 -o "$tmp/$ASSET" "$LEGACY_URL"; then
+ say "(legacy package name)"
+ download_ok=1
+ fi
+ [ "$download_ok" = 1 ] ||
+ die "Stažení se nezdařilo. Zkontrolujte internet a zkuste to znovu."
+ say "Unpacking..."
+fi
+
+mkdir -p "$tmp/new"
+ditto -x -k "$tmp/$ASSET" "$tmp/new" || die "Archiv se nepodařilo rozbalit."
+
+app=$(find "$tmp/new" -maxdepth 1 -name '*.app' -print -quit)
+[ -n "$app" ] || die "V archivu není žádná aplikace."
+name=$(basename "$app")
+
+rm -rf "$dest_dir/$name.old"
+if [ -e "$dest_dir/$name" ]; then
+ mv "$dest_dir/$name" "$dest_dir/$name.old"
+fi
+# Drop the old product name if we are installing Graduately.app.
+if [ "$name" = "Graduately.app" ] && [ -e "$dest_dir/Maturita.app" ]; then
+ rm -rf "$dest_dir/Maturita.app"
+fi
+if mv "$app" "$dest_dir/$name"; then
+ rm -rf "$dest_dir/$name.old"
+else
+ [ -e "$dest_dir/$name.old" ] && mv "$dest_dir/$name.old" "$dest_dir/$name"
+ die "Do $dest_dir se nepodařilo zapsat."
+fi
+
+xattr -cr "$dest_dir/$name" 2>/dev/null || true
+
+if [ "$GUI" = 1 ]; then
+ gui_dialog "Hotovo. Aplikace se teď otevře." >/dev/null || true
+else
+ say ""
+ say "Done. Starting the app..."
+fi
+open "$dest_dir/$name"
diff --git a/graduately-installer-macos.zip b/installers/graduately-installer-macos.zip
similarity index 67%
rename from graduately-installer-macos.zip
rename to installers/graduately-installer-macos.zip
index b298a3f..ca8e5fd 100644
Binary files a/graduately-installer-macos.zip and b/installers/graduately-installer-macos.zip differ
diff --git a/graduately-installer-windows.cmd b/installers/graduately-installer-windows.cmd
similarity index 96%
rename from graduately-installer-windows.cmd
rename to installers/graduately-installer-windows.cmd
index 057574b..334c32a 100644
--- a/graduately-installer-windows.cmd
+++ b/installers/graduately-installer-windows.cmd
@@ -1,84 +1,84 @@
-@echo off
-rem Graduately installer for Windows.
-rem
-rem Carries no application of its own. Fastly caches branch-named URLs on
-rem raw.githubusercontent.com, so this script looks up the tip of `builds`
-rem and downloads that commit — not a stale zip from the last five minutes.
-rem
-rem Double-click the file to run it. The app updates itself from then on.
-
-setlocal EnableDelayedExpansion
-set "REPO=pepaskopec-blip/Graduately"
-set "BRANCH=builds"
-set "ASSET=graduately-windows-x64.zip"
-set "DEST=%LOCALAPPDATA%\Programs\Graduately"
-
-echo Installing Graduately
-echo ---------------------
-
-where curl >nul 2>&1 || goto :no_tools
-where tar >nul 2>&1 || goto :no_tools
-
-set "TMP_DIR=%TEMP%\graduately-installer"
-rmdir /s /q "%TMP_DIR%" 2>nul
-mkdir "%TMP_DIR%" || goto :fail_tmp
-
-echo Looking up the latest build...
-set "SHA="
-for /f "usebackq delims=" %%S in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$r = & curl.exe -fsSL --max-time 20 'https://github.com/%REPO%/commits/%BRANCH%.atom'; if ($r -match 'Commit/([0-9a-f]{40})') { $Matches[1] }"`) do set "SHA=%%S"
-if not defined SHA goto :fail_download
-set "URL=https://raw.githubusercontent.com/%REPO%/%SHA%/%ASSET%"
-set "LEGACY_URL=https://raw.githubusercontent.com/%REPO%/%SHA%/maturita-windows-x64.zip"
-
-echo Downloading build %SHA:~0,7%...
-curl -fL --progress-bar --max-time 1800 -o "%TMP_DIR%\%ASSET%" "%URL%"
-if errorlevel 1 curl -fL --progress-bar --max-time 1800 -o "%TMP_DIR%\%ASSET%" "%LEGACY_URL%" || goto :fail_download
-
-echo Installing to %DEST%
-if not exist "%DEST%" mkdir "%DEST%" || goto :fail_dest
-
-tar -xf "%TMP_DIR%\%ASSET%" -C "%DEST%" || goto :fail_extract
-
-set "LNK=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Graduately.lnk"
-powershell -NoProfile -ExecutionPolicy Bypass -Command ^
- "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%LNK%');" ^
- "$s.TargetPath='%DEST%\graduately.exe';$s.WorkingDirectory='%DEST%';$s.Save()" >nul 2>&1
-
-rmdir /s /q "%TMP_DIR%" 2>nul
-
-echo.
-echo Done. Starting the app...
-start "" "%DEST%\graduately.exe"
-exit /b 0
-
-:no_tools
-echo.
-echo Error: this installer needs curl and tar, which come with Windows 10
-echo version 1803 and later. Please update Windows, or download the zip
-echo manually from https://github.com/%REPO%/tree/%BRANCH%
-pause
-exit /b 1
-
-:fail_tmp
-echo.
-echo Error: could not create a temporary folder in %TEMP%.
-pause
-exit /b 1
-
-:fail_download
-echo.
-echo Error: the download failed. Check your internet connection.
-pause
-exit /b 1
-
-:fail_dest
-echo.
-echo Error: could not create %DEST%.
-pause
-exit /b 1
-
-:fail_extract
-echo.
-echo Error: the archive could not be unpacked.
-pause
-exit /b 1
+@echo off
+rem Graduately installer for Windows.
+rem
+rem Carries no application of its own. Fastly caches branch-named URLs on
+rem raw.githubusercontent.com, so this script looks up the tip of `builds`
+rem and downloads that commit — not a stale zip from the last five minutes.
+rem
+rem Double-click the file to run it. The app updates itself from then on.
+
+setlocal EnableDelayedExpansion
+set "REPO=pepaskopec-blip/Graduately"
+set "BRANCH=builds"
+set "ASSET=graduately-windows-x64.zip"
+set "DEST=%LOCALAPPDATA%\Programs\Graduately"
+
+echo Installing Graduately
+echo ---------------------
+
+where curl >nul 2>&1 || goto :no_tools
+where tar >nul 2>&1 || goto :no_tools
+
+set "TMP_DIR=%TEMP%\graduately-installer"
+rmdir /s /q "%TMP_DIR%" 2>nul
+mkdir "%TMP_DIR%" || goto :fail_tmp
+
+echo Looking up the latest build...
+set "SHA="
+for /f "usebackq delims=" %%S in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$r = & curl.exe -fsSL --max-time 20 'https://github.com/%REPO%/commits/%BRANCH%.atom'; if ($r -match 'Commit/([0-9a-f]{40})') { $Matches[1] }"`) do set "SHA=%%S"
+if not defined SHA goto :fail_download
+set "URL=https://raw.githubusercontent.com/%REPO%/%SHA%/%ASSET%"
+set "LEGACY_URL=https://raw.githubusercontent.com/%REPO%/%SHA%/maturita-windows-x64.zip"
+
+echo Downloading build %SHA:~0,7%...
+curl -fL --progress-bar --max-time 1800 -o "%TMP_DIR%\%ASSET%" "%URL%"
+if errorlevel 1 curl -fL --progress-bar --max-time 1800 -o "%TMP_DIR%\%ASSET%" "%LEGACY_URL%" || goto :fail_download
+
+echo Installing to %DEST%
+if not exist "%DEST%" mkdir "%DEST%" || goto :fail_dest
+
+tar -xf "%TMP_DIR%\%ASSET%" -C "%DEST%" || goto :fail_extract
+
+set "LNK=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Graduately.lnk"
+powershell -NoProfile -ExecutionPolicy Bypass -Command ^
+ "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%LNK%');" ^
+ "$s.TargetPath='%DEST%\graduately.exe';$s.WorkingDirectory='%DEST%';$s.Save()" >nul 2>&1
+
+rmdir /s /q "%TMP_DIR%" 2>nul
+
+echo.
+echo Done. Starting the app...
+start "" "%DEST%\graduately.exe"
+exit /b 0
+
+:no_tools
+echo.
+echo Error: this installer needs curl and tar, which come with Windows 10
+echo version 1803 and later. Please update Windows, or download the zip
+echo manually from https://github.com/%REPO%/tree/%BRANCH%
+pause
+exit /b 1
+
+:fail_tmp
+echo.
+echo Error: could not create a temporary folder in %TEMP%.
+pause
+exit /b 1
+
+:fail_download
+echo.
+echo Error: the download failed. Check your internet connection.
+pause
+exit /b 1
+
+:fail_dest
+echo.
+echo Error: could not create %DEST%.
+pause
+exit /b 1
+
+:fail_extract
+echo.
+echo Error: the archive could not be unpacked.
+pause
+exit /b 1
diff --git a/installers/graduately-installer-windows.zip b/installers/graduately-installer-windows.zip
new file mode 100644
index 0000000..1ac432e
Binary files /dev/null and b/installers/graduately-installer-windows.zip differ
diff --git a/installers/graduately-ios.html b/installers/graduately-ios.html
new file mode 100644
index 0000000..425163d
--- /dev/null
+++ b/installers/graduately-ios.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Graduately pro iOS
+
+
+
+ Graduately pro iPhone (iOS 26)
+
+ Nativní aplikace pro iOS 26+ s Liquid Glass. Není 1:1 kopie desktopu.
+ Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac
+ s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).
+
+
+ - Naklonuj Graduately
+ - Otevři
Maturita.xcworkspace v kořeni repozitáře
+ - V Signing vyber svůj Team
+ - Připoj iPhone a stiskni Run
+
+
+ IPA z větve builds jde nahrát i přes AltStore nebo Sideloadly
+ (unsigned balíček, potřebuješ nástroj na sideload).
+
+
+
+ Stáhnout IPA
+
+
+
+ Když odkaz hlásí 404, otevři
+ větev builds
+ a stáhni graduately-ios.ipa odtud.
+
+
+
+
diff --git a/installers/macos/Info.plist b/installers/macos/Info.plist
new file mode 100644
index 0000000..be9a582
--- /dev/null
+++ b/installers/macos/Info.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ cs
+ CFBundleExecutable
+ installer
+ CFBundleIdentifier
+ org.maturita.installer
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Nainstalovat Graduately
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ LSMinimumSystemVersion
+ 12.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+
+
diff --git a/installers/pack-installer-zips.sh b/installers/pack-installer-zips.sh
new file mode 100755
index 0000000..1310c99
--- /dev/null
+++ b/installers/pack-installer-zips.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+# Rebuild the installer zips that README links to. Browsers download a
+# zip; they render .command / .sh / .cmd / .html as a text tab.
+
+set -eu
+
+cd "$(dirname "$0")"
+here=$PWD
+
+chmod +x graduately-installer-macos.command graduately-installer-linux.sh
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT INT TERM
+
+app="$tmp/Nainstalovat Graduately.app"
+mkdir -p "$app/Contents/MacOS"
+cp macos/Info.plist "$app/Contents/Info.plist"
+cp graduately-installer-macos.command "$app/Contents/MacOS/installer"
+chmod +x "$app/Contents/MacOS/installer"
+
+rm -f "$here/graduately-installer-macos.zip"
+(cd "$tmp" && zip -r -q "$here/graduately-installer-macos.zip" \
+ "Nainstalovat Graduately.app")
+
+zip -q -FS graduately-installer-windows.zip graduately-installer-windows.cmd
+zip -q -FS graduately-installer-linux.zip graduately-installer-linux.sh
+
+# Browsers download a zip; they render .html as a text tab on GitHub.
+# The page looks up the tip of `builds` so Fastly cannot serve a cached 404
+# for the branch-named APK URL.
+cp graduately-android.html "$tmp/Stahnout Graduately.html"
+rm -f "$here/graduately-installer-android.zip"
+(cd "$tmp" && zip -q -FS "$here/graduately-installer-android.zip" \
+ "Stahnout Graduately.html")
+
+cp graduately-ios.html "$tmp/Jak nainstalovat Graduately.html"
+rm -f "$here/graduately-installer-ios.zip"
+(cd "$tmp" && zip -q -FS "$here/graduately-installer-ios.zip" \
+ "Jak nainstalovat Graduately.html")
diff --git a/ios/Graduately.xcodeproj/project.pbxproj b/ios/Graduately.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..f7dfc49
--- /dev/null
+++ b/ios/Graduately.xcodeproj/project.pbxproj
@@ -0,0 +1,437 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A101000000000000000001 /* GraduatelyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000001 /* GraduatelyApp.swift */; };
+ A101000000000000000002 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000002 /* AppModel.swift */; };
+ A101000000000000000003 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000003 /* Route.swift */; };
+ A101000000000000000004 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000004 /* JSON.swift */; };
+ A101000000000000000005 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000005 /* Content.swift */; };
+ A101000000000000000006 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000006 /* Theme.swift */; };
+ A101000000000000000007 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000007 /* Answers.swift */; };
+ A101000000000000000008 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000008 /* ProgressStore.swift */; };
+ A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
+ A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
+ A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
+ A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
+ A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
+ A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
+ A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
+ A101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000016 /* Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A201000000000000000001 /* GraduatelyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraduatelyApp.swift; sourceTree = ""; };
+ A201000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; };
+ A201000000000000000003 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = ""; };
+ A201000000000000000004 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; };
+ A201000000000000000005 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; };
+ A201000000000000000006 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; };
+ A201000000000000000007 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Answers.swift; sourceTree = ""; };
+ A201000000000000000008 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressStore.swift; sourceTree = ""; };
+ A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; };
+ A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; };
+ A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; };
+ A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = ""; };
+ A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; };
+ A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = ""; };
+ A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = ""; };
+ A201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A201000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A301000000000000000001 /* Graduately.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduately.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A401000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A501000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ A501000000000000000002 /* Graduately */,
+ A501000000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A501000000000000000002 /* Graduately */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000001 /* GraduatelyApp.swift */,
+ A201000000000000000002 /* AppModel.swift */,
+ A201000000000000000003 /* Route.swift */,
+ A501000000000000000003 /* Data */,
+ A501000000000000000004 /* Update */,
+ A501000000000000000005 /* UI */,
+ A201000000000000000016 /* Assets.xcassets */,
+ A201000000000000000018 /* Info.plist */,
+ );
+ path = Graduately;
+ sourceTree = "";
+ };
+ A501000000000000000003 /* Data */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000004 /* JSON.swift */,
+ A201000000000000000005 /* Content.swift */,
+ A201000000000000000006 /* Theme.swift */,
+ A201000000000000000007 /* Answers.swift */,
+ A201000000000000000008 /* ProgressStore.swift */,
+ );
+ path = Data;
+ sourceTree = "";
+ };
+ A501000000000000000004 /* Update */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000009 /* Updater.swift */,
+ );
+ path = Update;
+ sourceTree = "";
+ };
+ A501000000000000000005 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000010 /* Components.swift */,
+ A201000000000000000011 /* Icons.swift */,
+ A201000000000000000012 /* PathMap.swift */,
+ A201000000000000000013 /* Screens.swift */,
+ A201000000000000000014 /* Exercises.swift */,
+ A201000000000000000015 /* ExercisesMore.swift */,
+ );
+ path = UI;
+ sourceTree = "";
+ };
+ A501000000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A301000000000000000001 /* Graduately.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A601000000000000000001 /* Graduately */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A801000000000000000002 /* Build configuration list for PBXNativeTarget "Graduately" */;
+ buildPhases = (
+ A701000000000000000001 /* Sources */,
+ A401000000000000000001 /* Frameworks */,
+ A701000000000000000002 /* Resources */,
+ A701000000000000000004 /* Build content */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Graduately;
+ productName = Graduately;
+ productReference = A301000000000000000001 /* Graduately.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A001000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 2700;
+ TargetAttributes = {
+ A601000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ };
+ };
+ buildConfigurationList = A801000000000000000001 /* Build configuration list for PBXProject "Graduately" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = cs;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ cs,
+ Base,
+ );
+ mainGroup = A501000000000000000001;
+ productRefGroup = A501000000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A601000000000000000001 /* Graduately */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A701000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A101000000000000000016 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ A701000000000000000004 /* Build content */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Build content";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/content.json",
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/content.json",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "# Build content.json from the Markdown in content/ and drop it into the app\n# bundle. Nothing generated is kept in the repository, so a fresh clone opens\n# and runs in Xcode without extra steps.\nset -eu\nextract=\"${SRCROOT}/../scripts/build-content.py\"\nif [ ! -f \"$extract\" ]; then\n echo \"error: ${extract} not found. Keep ios/ inside the Graduately checkout.\" >&2\n exit 1\nfi\nout=\"${DERIVED_FILE_DIR}/content.json\"\nmkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"$extract\" --out \"$out\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"$out\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content.json\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A701000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A101000000000000000001 /* GraduatelyApp.swift in Sources */,
+ A101000000000000000002 /* AppModel.swift in Sources */,
+ A101000000000000000003 /* Route.swift in Sources */,
+ A101000000000000000004 /* JSON.swift in Sources */,
+ A101000000000000000005 /* Content.swift in Sources */,
+ A101000000000000000006 /* Theme.swift in Sources */,
+ A101000000000000000007 /* Answers.swift in Sources */,
+ A101000000000000000008 /* ProgressStore.swift in Sources */,
+ A101000000000000000009 /* Updater.swift in Sources */,
+ A101000000000000000010 /* Components.swift in Sources */,
+ A101000000000000000011 /* Icons.swift in Sources */,
+ A101000000000000000012 /* PathMap.swift in Sources */,
+ A101000000000000000013 /* Screens.swift in Sources */,
+ A101000000000000000014 /* Exercises.swift in Sources */,
+ A101000000000000000015 /* ExercisesMore.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ A901000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ A901000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ A901000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Graduately/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Graduately;
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.6;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita;
+ PRODUCT_NAME = Graduately;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A901000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Graduately/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Graduately;
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.6;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita;
+ PRODUCT_NAME = Graduately;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A801000000000000000001 /* Build configuration list for PBXProject "Graduately" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A901000000000000000001 /* Debug */,
+ A901000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A801000000000000000002 /* Build configuration list for PBXNativeTarget "Graduately" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A901000000000000000003 /* Debug */,
+ A901000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A001000000000000000001 /* Project object */;
+}
diff --git a/ios/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme b/ios/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme
new file mode 100644
index 0000000..9cd60f4
--- /dev/null
+++ b/ios/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Graduately/AppModel.swift b/ios/Graduately/AppModel.swift
new file mode 100644
index 0000000..9d324ec
--- /dev/null
+++ b/ios/Graduately/AppModel.swift
@@ -0,0 +1,380 @@
+import Foundation
+import SwiftUI
+
+final class AppModel: ObservableObject {
+ let content: Content
+ let progress: ProgressStore
+ let updater = Updater()
+
+ @Published var themeId: ThemeId
+ @Published var mode: ColorMode
+ @Published var lang: UiLang
+ @Published var tab: AppTab = .practice
+ @Published var practicePath = NavigationPath()
+ /// Bumps whenever a full path replace is scheduled, so a queued push cannot land afterwards.
+ private var pathGeneration = 0
+ @Published var searchQuery = ""
+ @Published var tick = 0
+ @Published var update = UpdateState()
+ @Published var showChangelog = false
+
+ init() {
+ let content = Content.load()
+ self.content = content
+ progress = ProgressStore(content: content)
+ themeId = progress.themeId
+ mode = progress.mode
+ lang = progress.lang
+ showChangelog = !content.changelog.isEmpty && !sameBuild(AppConfig.commit, progress.seenCommit)
+ #if DEBUG
+ // `xcrun simctl launch com.bukovinafilip.maturita -route u1e3` opens a page for screenshots.
+ if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
+ openFromSearch(r.page)
+ }
+ if let theme = UserDefaults.standard.string(forKey: "theme"), let n = Int(theme), let id = ThemeId(rawValue: n) {
+ themeId = id
+ }
+ if let m = UserDefaults.standard.string(forKey: "mode") {
+ mode = m == "dark" ? .dark : .light
+ }
+ #endif
+ }
+
+ var palette: Palette { themePalette(themeId, mode) }
+
+ func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
+
+ func fmt(_ key: String, _ args: CVarArg...) -> String {
+ content.fmt(key, lang: lang, args)
+ }
+
+ func go(_ r: Route) {
+ switch r {
+ case .stats:
+ tab = .progress
+ case .welcome, .subjects:
+ showPracticeRoot()
+ default:
+ applyPracticeRoute(r, replace: practicePath.isEmpty)
+ }
+ }
+
+ func showPracticeRoot() {
+ tab = .practice
+ schedulePracticePath(NavigationPath())
+ }
+
+ func goPage(_ page: String?) {
+ guard let page, let r = routeFromPage(page) else { return }
+ go(r)
+ }
+
+ func openFromSearch(_ page: String) {
+ guard let r = routeFromPage(page) else { return }
+ switch r {
+ case .stats:
+ tab = .progress
+ case .welcome, .subjects:
+ showPracticeRoot()
+ default:
+ // Switching tabs and assigning a deep NavigationStack path in the
+ // same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError).
+ applyPracticeRoute(r, replace: true)
+ }
+ }
+
+ /// Sets the practice tab path safely, especially when jumping from Search.
+ ///
+ /// The sidebar-style tab view keeps one navigation column for every tab.
+ /// A typed `[Route]` path does not match the other tabs, so the column
+ /// traps in `boundPathChange` while an exercise is being opened. The path
+ /// is therefore type-erased, and a push is applied on the next turn instead
+ /// of replacing the whole stack during the click.
+ private func applyPracticeRoute(_ r: Route, replace: Bool) {
+ let switching = tab != .practice
+ tab = .practice
+ if switching || replace {
+ schedulePracticePath(NavigationPath(r.stack))
+ } else {
+ let generation = pathGeneration
+ DispatchQueue.main.async { [weak self] in
+ guard let self, self.pathGeneration == generation else { return }
+ self.practicePath.append(r)
+ }
+ }
+ }
+
+ private func schedulePracticePath(_ path: NavigationPath) {
+ pathGeneration += 1
+ practicePath = NavigationPath()
+ DispatchQueue.main.async { [weak self] in
+ self?.practicePath = path
+ }
+ }
+
+ func setTheme(_ id: ThemeId) {
+ withAnimation(Motion.settle) { themeId = id }
+ progress.themeId = id
+ }
+
+ func applyMode(_ m: ColorMode) {
+ withAnimation(Motion.settle) { mode = m }
+ progress.mode = m
+ }
+
+ func applyLang(_ l: UiLang) {
+ lang = l
+ progress.lang = l
+ }
+
+ func refresh() { tick += 1 }
+
+ func markGerman(_ unit: Int, _ ex: Int) {
+ progress.markGerman(unit, ex)
+ refresh()
+ }
+
+ func markVocab(_ unit: Int) {
+ progress.markVocab(unit)
+ refresh()
+ }
+
+ func markNet(_ id: Int) {
+ progress.markNet(id)
+ refresh()
+ }
+
+ func markHw(_ id: Int) {
+ progress.markHw(id)
+ refresh()
+ }
+
+ func markOn(_ id: Int) {
+ progress.markOn(id)
+ refresh()
+ }
+
+ func markOn2(_ id: Int) {
+ progress.markOn2(id)
+ refresh()
+ }
+
+ func markOn3(_ id: Int) {
+ progress.markOn3(id)
+ refresh()
+ }
+
+ func markOn4(_ id: Int) {
+ progress.markOn4(id)
+ refresh()
+ }
+
+ func markEn(_ year: Int, _ id: Int) {
+ progress.markEn(year, id)
+ refresh()
+ }
+
+ func markDe(_ year: Int, _ id: Int) {
+ progress.markDe(year, id)
+ refresh()
+ }
+
+ func markCourse(_ course: String, _ id: Int) {
+ progress.markCourse(course, id)
+ refresh()
+ }
+
+ func markLit(_ id: Int) {
+ progress.markLit(id)
+ refresh()
+ }
+
+ func markLit2(_ id: Int) {
+ progress.markLit2(id)
+ refresh()
+ }
+
+ func markLit3(_ id: Int) {
+ progress.markLit3(id)
+ refresh()
+ }
+
+ func markLit4(_ id: Int) {
+ progress.markLit4(id)
+ refresh()
+ }
+
+ func markChem(_ id: Int) {
+ progress.markChem(id)
+ refresh()
+ }
+
+ func markBio(_ id: Int) {
+ progress.markBio(id)
+ refresh()
+ }
+
+ func markFyz(_ id: Int) {
+ progress.markFyz(id)
+ refresh()
+ }
+
+ func markFyz2(_ id: Int) {
+ progress.markFyz2(id)
+ refresh()
+ }
+
+ func markFyz3(_ id: Int) {
+ progress.markFyz3(id)
+ refresh()
+ }
+
+ func markFyz4(_ id: Int) {
+ progress.markFyz4(id)
+ refresh()
+ }
+
+ func markTg(_ id: Int) {
+ progress.markTg(id)
+ refresh()
+ }
+
+ func markTg2(_ id: Int) {
+ progress.markTg2(id)
+ refresh()
+ }
+
+ func markTg3(_ id: Int) {
+ progress.markTg3(id)
+ refresh()
+ }
+
+ func markPg(_ id: Int) {
+ progress.markPg(id)
+ refresh()
+ }
+
+ func markPg2(_ id: Int) {
+ progress.markPg2(id)
+ refresh()
+ }
+
+ func markPg3(_ id: Int) {
+ progress.markPg3(id)
+ refresh()
+ }
+
+ func markPg4(_ id: Int) {
+ progress.markPg4(id)
+ refresh()
+ }
+
+ func markSw(_ id: Int) {
+ progress.markSw(id)
+ refresh()
+ }
+
+ func markSw2(_ id: Int) {
+ progress.markSw2(id)
+ refresh()
+ }
+
+ func markSw3(_ id: Int) {
+ progress.markSw3(id)
+ refresh()
+ }
+
+ func markSw4(_ id: Int) {
+ progress.markSw4(id)
+ refresh()
+ }
+
+ func markTg4(_ id: Int) {
+ progress.markTg4(id)
+ refresh()
+ }
+
+ func markCs(_ id: Int) {
+ progress.markCs(id)
+ refresh()
+ }
+
+ func markCs2(_ id: Int) {
+ progress.markCs2(id)
+ refresh()
+ }
+
+ func markCs3(_ id: Int) {
+ progress.markCs3(id)
+ refresh()
+ }
+
+ func markCs4(_ id: Int) {
+ progress.markCs4(id)
+ refresh()
+ }
+
+ func markMat0(_ id: Int) {
+ progress.markMat0(id)
+ refresh()
+ }
+
+ func markMat(_ id: Int) {
+ progress.markMat(id)
+ refresh()
+ }
+
+ func markMat2(_ id: Int) {
+ progress.markMat2(id)
+ refresh()
+ }
+
+ func markMat3(_ id: Int) {
+ progress.markMat3(id)
+ refresh()
+ }
+
+ func markMat4(_ id: Int) {
+ progress.markMat4(id)
+ refresh()
+ }
+
+ func markMluv(_ n: Int) {
+ progress.markMluv(n)
+ refresh()
+ }
+
+ func markBookQuiz(_ id: String) {
+ progress.markBookQuiz(id)
+ refresh()
+ }
+
+ func markBookPlot(_ id: String) {
+ progress.markBookPlot(id)
+ refresh()
+ }
+
+ func checkUpdate(_ interactive: Bool) {
+ updater.check(interactive: interactive) { [weak self] state in
+ self?.update = state
+ }
+ }
+
+ func installUpdate() {
+ updater.install { [weak self] state in
+ self?.update = state
+ }
+ }
+
+ func dismissChangelog() {
+ progress.seenCommit = AppConfig.commit
+ withAnimation(Motion.settle) { showChangelog = false }
+ }
+}
+
+func sameBuild(_ a: String?, _ b: String?) -> Bool {
+ guard let a, let b, !a.isEmpty, !b.isEmpty else { return false }
+ if a.caseInsensitiveCompare(b) == .orderedSame { return true }
+ let n = min(a.count, b.count)
+ return n >= 7 && a.prefix(n).caseInsensitiveCompare(b.prefix(n)) == .orderedSame
+}
diff --git a/ios/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Graduately/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Graduately/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..9fbf69f
Binary files /dev/null and b/ios/Graduately/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/ios/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..cefcc87
--- /dev/null
+++ b/ios/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Graduately/Assets.xcassets/Contents.json b/ios/Graduately/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/Graduately/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Graduately/Data/Answers.swift b/ios/Graduately/Data/Answers.swift
new file mode 100644
index 0000000..c9b5df9
--- /dev/null
+++ b/ios/Graduately/Data/Answers.swift
@@ -0,0 +1,715 @@
+import Foundation
+
+func normalizeAnswer(_ input: String?) -> String {
+ var pre = ""
+ for ch in (input ?? "").lowercased() {
+ switch ch {
+ case "ä": pre += "ae"
+ case "ö": pre += "oe"
+ case "ü": pre += "ue"
+ case "ß": pre += "ss"
+ default: pre.append(ch)
+ }
+ }
+ let decomp = pre.decomposedStringWithCanonicalMapping
+ var out = ""
+ var prevSpace = false
+ for scalar in decomp.unicodeScalars {
+ let v = scalar.value
+ if (0x0300...0x036F).contains(v) || (0x1AB0...0x1AFF).contains(v) ||
+ (0x1DC0...0x1DFF).contains(v) || (0x20D0...0x20FF).contains(v) ||
+ (0xFE20...0xFE2F).contains(v) {
+ continue
+ }
+ if CharacterSet.whitespacesAndNewlines.contains(scalar) {
+ if !out.isEmpty && !prevSpace { out.append(" ") }
+ prevSpace = true
+ continue
+ }
+ if !CharacterSet.alphanumerics.contains(scalar) { continue }
+ prevSpace = false
+ out.unicodeScalars.append(scalar)
+ }
+ return out.trimmingCharacters(in: .whitespaces)
+}
+
+func answerAccepts(_ selNorm: String, _ ans: String?) -> Bool {
+ guard let ans, !ans.isEmpty else { return false }
+ return ans.split(separator: "|").contains { normalizeAnswer(String($0)) == selNorm }
+}
+
+func answerIncomplete(_ selNorm: String, _ ans: String?) -> Bool {
+ guard !selNorm.isEmpty, let ans, !ans.isEmpty else { return false }
+ for part in ans.split(separator: "|") {
+ let a = normalizeAnswer(String(part))
+ if a.hasPrefix(selNorm + " ") && a.count > selNorm.count + 1 { return true }
+ }
+ return false
+}
+
+func netTxtEq(_ a: String?, _ b: String?) -> Bool {
+ guard let a, let b else { return a == b }
+ let x = a.filter { !$0.isWhitespace }.lowercased()
+ let y = b.filter { !$0.isWhitespace }.lowercased()
+ return x == y
+}
+
+func hasUmlaut(_ s: String?) -> Bool {
+ guard let s else { return false }
+ return s.contains(where: { "äöüÄÖÜß".contains($0) })
+}
+
+// MARK: - Mathematics
+// Same rules as src/math_check.c. Do not route numbers through normalizeAnswer.
+
+private struct MFrac { var n: Int64 = 0; var d: Int64 = 1 }
+private struct MTerm {
+ var ok = true
+ var a = MFrac()
+ var b = MFrac()
+ var rad: Int64 = 0
+ var p = MFrac()
+}
+
+private func mGcd(_ a0: Int64, _ b0: Int64) -> Int64 {
+ var a = a0 < 0 ? -a0 : a0
+ var b = b0 < 0 ? -b0 : b0
+ while b != 0 {
+ let t = a % b
+ a = b
+ b = t
+ }
+ return a == 0 ? 1 : a
+}
+
+private func mMul(_ a: Int64, _ b: Int64) -> Int64? {
+ let r = a.multipliedReportingOverflow(by: b)
+ return r.overflow ? nil : r.partialValue
+}
+
+private func mSet(_ f: inout MFrac, _ n: Int64, _ d: Int64) -> Bool {
+ if d == 0 { return false }
+ var n = n, d = d
+ if d < 0 { n = -n; d = -d }
+ let g = mGcd(n, d)
+ f.n = n / g
+ f.d = d / g
+ return true
+}
+
+private func mAddF(_ x: MFrac, _ y: MFrac) -> MFrac? {
+ guard let xn = mMul(x.n, y.d), let yn = mMul(y.n, x.d),
+ let d = mMul(x.d, y.d) else { return nil }
+ let s = xn.addingReportingOverflow(yn)
+ if s.overflow { return nil }
+ var o = MFrac()
+ return mSet(&o, s.partialValue, d) ? o : nil
+}
+
+private func mMulF(_ x: MFrac, _ y: MFrac) -> MFrac? {
+ guard let n = mMul(x.n, y.n), let d = mMul(x.d, y.d) else { return nil }
+ var o = MFrac()
+ return mSet(&o, n, d) ? o : nil
+}
+
+private func mDivF(_ x: MFrac, _ y: MFrac) -> MFrac? {
+ if y.n == 0 { return nil }
+ return mMulF(x, MFrac(n: y.d, d: y.n))
+}
+
+private func mSameF(_ x: MFrac, _ y: MFrac) -> Bool { x.n == y.n && x.d == y.d }
+
+private func mSqrtReduce(_ n0: Int64) -> (out: Int64, rad: Int64)? {
+ if n0 < 0 { return nil }
+ if n0 > 1_000_000 { return nil }
+ var n = n0
+ var out: Int64 = 1
+ var f: Int64 = 2
+ while f * f <= n {
+ var c = 0
+ while n % f == 0 { n /= f; c += 1 }
+ if c >= 2 { out *= mPow(f, c / 2) }
+ if c % 2 == 1 { n *= f }
+ if f == 2 { f = 3 } else { f += 2 }
+ if f > 10000 { break }
+ }
+ return (out, n)
+}
+
+private func mPow(_ b: Int64, _ e: Int) -> Int64 {
+ var r: Int64 = 1
+ var i = 0
+ while i < e { r *= b; i += 1 }
+ return r
+}
+
+private func mNorm(_ t: inout MTerm) {
+ if !t.ok { return }
+ if t.rad > 1, let s = mSqrtReduce(t.rad) {
+ guard let nb = mMulF(t.b, MFrac(n: s.out, d: 1)) else { t.ok = false; return }
+ t.b = nb
+ t.rad = s.rad
+ }
+ if t.rad <= 1 {
+ guard let na = mAddF(t.a, t.b) else { t.ok = false; return }
+ t.a = na
+ t.b = MFrac()
+ t.rad = 0
+ }
+ if t.b.n == 0 { t.rad = 0 }
+}
+
+private func mAdd(_ x: MTerm, _ y: MTerm) -> MTerm {
+ if !x.ok || !y.ok { return MTerm(ok: false) }
+ var o = MTerm()
+ guard let a = mAddF(x.a, y.a), let p = mAddF(x.p, y.p) else { return MTerm(ok: false) }
+ o.a = a
+ o.p = p
+ if x.rad == 0 && y.rad == 0 {
+ o.b = MFrac(); o.rad = 0
+ } else if x.rad == 0 {
+ o.b = y.b; o.rad = y.rad
+ } else if y.rad == 0 || x.rad == y.rad {
+ guard let b = mAddF(x.b, y.b) else { return MTerm(ok: false) }
+ o.b = b; o.rad = x.rad
+ } else { return MTerm(ok: false) }
+ mNorm(&o)
+ return o
+}
+
+private func mScale(_ x: MTerm, _ f: MFrac) -> MTerm {
+ if !x.ok { return MTerm(ok: false) }
+ var o = MTerm()
+ guard let a = mMulF(x.a, f), let b = mMulF(x.b, f), let p = mMulF(x.p, f) else {
+ return MTerm(ok: false)
+ }
+ o.a = a; o.b = b; o.rad = x.rad; o.p = p
+ mNorm(&o)
+ return o
+}
+
+private func mMulT(_ x: MTerm, _ y: MTerm) -> MTerm {
+ if !x.ok || !y.ok { return MTerm(ok: false) }
+ if (x.p.n != 0 && (y.p.n != 0 || y.rad != 0)) ||
+ (y.p.n != 0 && (x.p.n != 0 || x.rad != 0)) { return MTerm(ok: false) }
+ if x.rad != 0 && y.rad != 0 && x.rad != y.rad { return MTerm(ok: false) }
+ var o = MTerm()
+ guard let aa = mMulF(x.a, y.a) else { return MTerm(ok: false) }
+ o.a = aa
+ if x.rad == 0 && y.rad == 0 {
+ o.b = MFrac(); o.rad = 0
+ } else if x.rad == 0 {
+ guard let b = mMulF(x.a, y.b) else { return MTerm(ok: false) }
+ o.b = b; o.rad = y.rad
+ } else if y.rad == 0 {
+ guard let b = mMulF(y.a, x.b) else { return MTerm(ok: false) }
+ o.b = b; o.rad = x.rad
+ } else {
+ guard let ab = mMulF(x.a, y.b), let ba = mMulF(x.b, y.a),
+ let cross = mAddF(ab, ba),
+ let sq = mMul(x.rad, y.rad) else { return MTerm(ok: false) }
+ guard let bb = mMulF(x.b, y.b), let extra = mMulF(bb, MFrac(n: sq, d: 1)),
+ let na = mAddF(o.a, extra) else { return MTerm(ok: false) }
+ o.a = na; o.b = cross; o.rad = x.rad
+ }
+ if x.p.n != 0 { o.p = x.p } else { o.p = y.p }
+ if y.a.n != 1 || y.a.d != 1 || y.b.n != 0 || y.rad != 0 || y.p.n != 0 {
+ if x.p.n != 0 {
+ guard let p = mMulF(x.p, y.a) else { return MTerm(ok: false) }
+ o.p = p
+ }
+ }
+ if x.a.n != 1 || x.a.d != 1 || x.b.n != 0 || x.rad != 0 || x.p.n != 0 {
+ if y.p.n != 0 && x.p.n == 0 {
+ guard let p = mMulF(y.p, x.a) else { return MTerm(ok: false) }
+ o.p = p
+ }
+ }
+ mNorm(&o)
+ return o
+}
+
+private func mDivT(_ x: MTerm, _ y: MTerm) -> MTerm {
+ if !x.ok || !y.ok || y.p.n != 0 { return MTerm(ok: false) }
+ if y.rad == 0 {
+ guard let f = mDivF(MFrac(n: 1, d: 1), y.a) else { return MTerm(ok: false) }
+ return mScale(x, f)
+ }
+ if x.p.n != 0 { return MTerm(ok: false) }
+ guard let yb = mMulF(y.b, MFrac(n: y.rad, d: 1)),
+ let bb = mMulF(y.b, yb),
+ let aa = mMulF(y.a, y.a),
+ let den = mAddF(aa, MFrac(n: -bb.n, d: bb.d)) else { return MTerm(ok: false) }
+ var num = MTerm()
+ num.a = y.a
+ num.b = MFrac(n: -y.b.n, d: y.b.d)
+ num.rad = y.rad
+ guard let inv = mDivF(MFrac(n: 1, d: 1), den) else { return MTerm(ok: false) }
+ return mMulT(x, mScale(num, inv))
+}
+
+private enum MTok {
+ case num(Int64, Int64)
+ case sqrt, pi, add, sub, mul, div, lp, rp
+}
+
+private func mLex(_ s: String) -> [MTok]? {
+ var toks: [MTok] = []
+ var i = s.startIndex
+ func peek(_ k: Int) -> Character? {
+ var j = i
+ var n = 0
+ while n < k && j < s.endIndex { j = s.index(after: j); n += 1 }
+ return j < s.endIndex ? s[j] : nil
+ }
+ while i < s.endIndex {
+ while i < s.endIndex && s[i].isWhitespace { i = s.index(after: i) }
+ if i >= s.endIndex { break }
+ let ch = s[i]
+ if ch.isNumber || ((ch == "." || ch == ",") && (peek(1)?.isNumber == true)) {
+ var whole: Int64 = 0
+ var saw = false
+ while i < s.endIndex && s[i].isNumber {
+ saw = true
+ whole = whole * 10 + Int64(s[i].wholeNumberValue ?? 0)
+ i = s.index(after: i)
+ }
+ if i < s.endIndex && (s[i] == "." || s[i] == ",") && (s.index(after: i) < s.endIndex && s[s.index(after: i)].isNumber) {
+ i = s.index(after: i)
+ var frac: Int64 = 0
+ var div: Int64 = 1
+ while i < s.endIndex && s[i].isNumber {
+ frac = frac * 10 + Int64(s[i].wholeNumberValue ?? 0)
+ div *= 10
+ i = s.index(after: i)
+ }
+ let n = whole * div + frac
+ toks.append(.num(n, div))
+ } else if saw {
+ toks.append(.num(whole, 1))
+ } else { return nil }
+ continue
+ }
+ if ch == "+" { toks.append(.add); i = s.index(after: i); continue }
+ if ch == "-" || ch == "−" || ch == "–" { toks.append(.sub); i = s.index(after: i); continue }
+ if ch == "*" || ch == "×" || ch == "·" { toks.append(.mul); i = s.index(after: i); continue }
+ if ch == "/" || ch == ":" { toks.append(.div); i = s.index(after: i); continue }
+ if ch == "(" { toks.append(.lp); i = s.index(after: i); continue }
+ if ch == ")" { toks.append(.rp); i = s.index(after: i); continue }
+ if ch == "π" { toks.append(.pi); i = s.index(after: i); continue }
+ if ch == "√" { toks.append(.sqrt); i = s.index(after: i); continue }
+ if ch.isLetter {
+ var j = i
+ while j < s.endIndex && s[j].isLetter { j = s.index(after: j) }
+ let w = s[i.. MTok? { i < t.count ? t[i] : nil }
+ mutating func take() -> MTok? {
+ if i >= t.count { return nil }
+ defer { i += 1 }
+ return t[i]
+ }
+ mutating func expr() -> MTerm? {
+ guard var left = term() else { return nil }
+ while let p = peek() {
+ if case .add = p { _ = take() }
+ else if case .sub = p { _ = take() }
+ else { break }
+ guard let r = term() else { return nil }
+ if case .sub = p { left = mAdd(left, mScale(r, MFrac(n: -1, d: 1))) }
+ else { left = mAdd(left, r) }
+ if !left.ok { return nil }
+ }
+ return left
+ }
+ mutating func term() -> MTerm? {
+ guard var left = unary() else { return nil }
+ while let p = peek() {
+ if case .mul = p { _ = take() }
+ else if case .div = p { _ = take() }
+ else { break }
+ guard let r = unary() else { return nil }
+ if case .div = p { left = mDivT(left, r) }
+ else { left = mMulT(left, r) }
+ if !left.ok { return nil }
+ }
+ return left
+ }
+ mutating func unary() -> MTerm? {
+ if case .sub = peek() {
+ _ = take()
+ guard let v = unary() else { return nil }
+ return mScale(v, MFrac(n: -1, d: 1))
+ }
+ if case .add = peek() {
+ _ = take()
+ return unary()
+ }
+ return factor()
+ }
+ mutating func factor() -> MTerm? {
+ guard var left = primary() else { return nil }
+ while true {
+ guard let p = peek() else { break }
+ switch p {
+ case .sqrt, .pi, .lp:
+ guard let r = primary() else { return nil }
+ left = mMulT(left, r)
+ if !left.ok { return nil }
+ case .num:
+ return nil
+ default:
+ return left
+ }
+ }
+ return left
+ }
+ mutating func primary() -> MTerm? {
+ guard let p = take() else { return nil }
+ switch p {
+ case .num(let n, let d):
+ if case .div = peek(), case .num = t[safe: i + 1] {
+ _ = take()
+ if case .num(let n2, let d2) = take() {
+ if n2 <= 0 || d2 != 1 || d != 1 { return nil }
+ var o = MTerm()
+ if !mSet(&o.a, n, n2) { return nil }
+ return o
+ }
+ return nil
+ }
+ if d == 1, case .num(let n2, let d2) = peek(), n >= 0 {
+ _ = take()
+ if case .div = peek(), case .num(let n3, let d3) = t[safe: i + 1], d2 == 1, d3 == 1, n2 >= 0, n3 > 0 {
+ _ = take()
+ _ = take()
+ var frac = MFrac()
+ if !mSet(&frac, n2, n3) { return nil }
+ var whole = MTerm()
+ whole.a = MFrac(n: n, d: 1)
+ var part = MTerm()
+ part.a = frac
+ return mAdd(whole, part)
+ }
+ i -= 1
+ }
+ var o = MTerm()
+ if !mSet(&o.a, n, d) { return nil }
+ return o
+ case .pi:
+ var o = MTerm()
+ o.p = MFrac(n: 1, d: 1)
+ return o
+ case .sqrt:
+ if case .lp = peek() {
+ _ = take()
+ guard let inner = expr(), case .rp = take() else { return nil }
+ return mSqrtTerm(inner)
+ }
+ if case .num(let n, let d) = peek(), d == 1, n >= 0 {
+ _ = take()
+ return mSqrtTerm(MTerm(a: MFrac(n: n, d: 1)))
+ }
+ return nil
+ case .lp:
+ guard let inner = expr(), case .rp = take() else { return nil }
+ return inner
+ default:
+ return nil
+ }
+ }
+}
+
+private extension Array {
+ subscript(safe i: Int) -> Element? { indices.contains(i) ? self[i] : nil }
+}
+
+private func mSqrtTerm(_ inner: MTerm) -> MTerm? {
+ if !inner.ok || inner.p.n != 0 || inner.rad != 0 || inner.b.n != 0 { return nil }
+ if inner.a.n < 0 || inner.a.d != 1 { return nil }
+ guard let s = mSqrtReduce(inner.a.n) else { return nil }
+ var o = MTerm()
+ o.b = MFrac(n: s.out, d: 1)
+ o.rad = s.rad
+ mNorm(&o)
+ return o.ok ? o : nil
+}
+
+private func mPrep(_ raw: String) -> String {
+ var s = raw.trimmingCharacters(in: .whitespaces)
+ var again = true
+ while again {
+ again = false
+ if s.count >= 2 {
+ let pairs: [(Character, Character)] = [("(", ")"), ("[", "]"), ("{", "}")]
+ if let last = s.last, pairs.contains(where: { $0.0 == s.first && $0.1 == last }) {
+ s = String(s.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
+ again = true
+ continue
+ }
+ }
+ }
+ s = mStripUnits(s).trimmingCharacters(in: .whitespaces)
+ return s
+}
+
+private func mParseTerm(_ s: String) -> MTerm? {
+ let s = mPrep(s)
+ if s.isEmpty { return nil }
+ guard let toks = mLex(s) else { return nil }
+ if toks.isEmpty { return nil }
+ var p = MParse(t: toks)
+ guard let v = p.expr(), p.i == toks.count, v.ok else { return nil }
+ return v
+}
+
+private func mExact(_ x: MTerm, _ y: MTerm) -> Bool {
+ x.ok && y.ok && mSameF(x.a, y.a) && mSameF(x.b, y.b) && x.rad == y.rad && mSameF(x.p, y.p)
+}
+
+private func mDouble(_ t: MTerm) -> Double {
+ var v = Double(t.a.n) / Double(t.a.d) + Double(t.p.n) / Double(t.p.d) * Double.pi
+ if t.b.n != 0 && t.rad > 0 {
+ v += Double(t.b.n) / Double(t.b.d) * (Double(t.rad)).squareRoot()
+ }
+ return v
+}
+
+private func mClose(_ x: MTerm, _ y: MTerm) -> Bool {
+ if !x.ok || !y.ok { return false }
+ if mExact(x, y) { return true }
+ if x.p.n != 0 || y.p.n != 0 { return false }
+ let diff = abs(mDouble(x) - mDouble(y))
+ if x.b.n != 0 || y.b.n != 0 { return diff <= 0.001 }
+ return diff <= 0.0005
+}
+
+private func mFold(_ s: String) -> String {
+ var out = ""
+ for ch in s.lowercased() {
+ switch ch {
+ case "á", "à", "ä", "â": out.append("a")
+ case "č", "ć": out.append("c")
+ case "ď": out.append("d")
+ case "é", "ě", "ë", "ê": out.append("e")
+ case "í", "ï": out.append("i")
+ case "ň": out.append("n")
+ case "ó", "ö", "ô": out.append("o")
+ case "ř": out.append("r")
+ case "š", "ś": out.append("s")
+ case "ť": out.append("t")
+ case "ú", "ů", "ü": out.append("u")
+ case "ý": out.append("y")
+ case "ž", "ź": out.append("z")
+ default:
+ if ch.isLetter || ch.isNumber { out.append(ch) }
+ }
+ }
+ return out
+}
+
+private func mStripUnits(_ raw: String) -> String {
+ var s = raw.trimmingCharacters(in: .whitespaces)
+ let units = ["procenta", "procent", "cm", "mm", "dm", "km", "m"]
+ var cut = true
+ while cut {
+ cut = false
+ while s.last?.isWhitespace == true { s.removeLast() }
+ if s.hasSuffix("°") || s.hasSuffix("%") || s.hasSuffix(".") {
+ s.removeLast()
+ cut = true
+ continue
+ }
+ for u in units {
+ guard s.count > u.count else { continue }
+ if s.lowercased().hasSuffix(u) {
+ let keep = s.dropLast(u.count)
+ if keep.last?.isLetter == true { continue }
+ s = String(keep)
+ cut = true
+ break
+ }
+ }
+ }
+ return s.trimmingCharacters(in: .whitespaces)
+}
+
+private func mSplit(_ s: String) -> [String]? {
+ let s = s.trimmingCharacters(in: .whitespaces)
+ if s.isEmpty { return nil }
+ let semi = s.contains(";")
+ var parts: [String] = []
+ var rest = Substring(s)
+ while !rest.isEmpty {
+ let end: Substring.Index
+ let step: Int
+ if semi {
+ if let i = rest.firstIndex(of: ";") { end = i; step = 1 }
+ else { end = rest.endIndex; step = 0 }
+ } else if let r = rest.range(of: ", ") {
+ end = r.lowerBound
+ step = 2
+ } else {
+ end = rest.endIndex
+ step = 0
+ }
+ let piece = rest[.. Bool {
+ guard var spec, let user else { return false }
+ spec = spec.trimmingCharacters(in: .whitespaces)
+ if spec.hasPrefix("word:") {
+ let opts = spec.dropFirst(5).split(separator: "|").map { mFold(String($0)) }
+ return opts.contains(mFold(user))
+ }
+ if spec.hasPrefix("set:") || spec.hasPrefix("pair:") {
+ let ordered = spec.hasPrefix("pair:")
+ let body = String(spec.dropFirst(ordered ? 5 : 4))
+ guard let want = mSplit(body), let got = mSplit(user) else { return false }
+ if want.count != got.count || want.isEmpty { return false }
+ var wt: [MTerm] = []
+ var gt: [MTerm] = []
+ for w in want {
+ guard let t = mParseTerm(w) else { return false }
+ wt.append(t)
+ }
+ for g in got {
+ guard let t = mParseTerm(g) else { return false }
+ gt.append(t)
+ }
+ if ordered {
+ for i in wt.indices where !mClose(wt[i], gt[i]) { return false }
+ return true
+ }
+ var used = Array(repeating: false, count: gt.count)
+ for w in wt {
+ var hit = gt.indices.first { !used[$0] && mExact(w, gt[$0]) }
+ if hit == nil { hit = gt.indices.first { !used[$0] && mClose(w, gt[$0]) } }
+ guard let hit else { return false }
+ used[hit] = true
+ }
+ return true
+ }
+ let body = spec.hasPrefix("num:") ? String(spec.dropFirst(4)) : spec
+ guard let tw = mParseTerm(body), let tg = mParseTerm(user) else { return false }
+ return mClose(tw, tg)
+}
+
+func mathIsDraw(_ spec: String?) -> Bool {
+ guard let spec else { return false }
+ return spec.hasPrefix("square:") || spec.hasPrefix("mid:") || spec.hasPrefix("right:")
+ || spec.hasPrefix("para:") || spec.hasPrefix("points:")
+}
+
+func mathDrawNeed(_ spec: String?) -> Int {
+ guard let spec else { return 0 }
+ if spec.hasPrefix("square:") { return 2 }
+ if spec.hasPrefix("mid:") || spec.hasPrefix("right:") || spec.hasPrefix("para:") { return 1 }
+ if spec.hasPrefix("points:") {
+ let body = spec.dropFirst(7)
+ return body.split(separator: ";").filter { !$0.hasPrefix("fix:") }.count
+ }
+ return 0
+}
+
+private func mParseXY(_ s: Substring) -> (Int, Int)? {
+ let t = s.trimmingCharacters(in: .whitespaces)
+ let bits = t.split(separator: ",")
+ guard bits.count == 2, let x = Int(bits[0].trimmingCharacters(in: .whitespaces)),
+ let y = Int(bits[1].trimmingCharacters(in: .whitespaces)) else { return nil }
+ return (x, y)
+}
+
+func mathDrawGiven(_ spec: String?) -> [(Int, Int)] {
+ guard let spec else { return [] }
+ if spec.hasPrefix("points:") {
+ return spec.dropFirst(7).split(separator: ";").compactMap { part -> (Int, Int)? in
+ guard part.hasPrefix("fix:") else { return nil }
+ return mParseXY(part.dropFirst(4))
+ }
+ }
+ let body: Substring
+ let n: Int
+ if spec.hasPrefix("para:") { body = spec.dropFirst(5); n = 3 }
+ else if spec.hasPrefix("square:") { body = spec.dropFirst(7); n = 2 }
+ else if spec.hasPrefix("right:") { body = spec.dropFirst(6); n = 2 }
+ else if spec.hasPrefix("mid:") { body = spec.dropFirst(4); n = 2 }
+ else { return [] }
+ return body.split(separator: ";").prefix(n).compactMap { mParseXY($0) }
+}
+
+private func mOnBoard(_ p: (Int, Int)) -> Bool {
+ p.0 >= 0 && p.0 <= 10 && p.1 >= 0 && p.1 <= 8
+}
+
+func mathDrawOk(_ spec: String?, _ pts: [(Int, Int)]) -> Bool {
+ guard let spec, mathDrawNeed(spec) == pts.count else { return false }
+ if pts.contains(where: { !mOnBoard($0) }) { return false }
+ if Set(pts.map { "\($0.0),\($0.1)" }).count != pts.count { return false }
+ let g = mathDrawGiven(spec)
+ if spec.hasPrefix("mid:") {
+ guard g.count == 2, (g[0].0 + g[1].0) % 2 == 0, (g[0].1 + g[1].1) % 2 == 0 else { return false }
+ return pts[0] == ((g[0].0 + g[1].0) / 2, (g[0].1 + g[1].1) / 2)
+ }
+ if spec.hasPrefix("para:") {
+ guard g.count == 3 else { return false }
+ return pts[0] == (g[1].0 + g[2].0 - g[0].0, g[1].1 + g[2].1 - g[0].1)
+ }
+ if spec.hasPrefix("right:") {
+ guard g.count == 2 else { return false }
+ let c = pts[0]
+ if c == g[0] || c == g[1] { return false }
+ let ux = g[0].0 - c.0, uy = g[0].1 - c.1
+ let vx = g[1].0 - c.0, vy = g[1].1 - c.1
+ return ux * vx + uy * vy == 0 && (ux != 0 || uy != 0) && (vx != 0 || vy != 0)
+ }
+ if spec.hasPrefix("square:") {
+ guard g.count == 2, pts.count == 2 else { return false }
+ let a = g[0], b = g[1]
+ let dx = b.0 - a.0, dy = b.1 - a.1
+ if dx == 0 && dy == 0 { return false }
+ let c1 = (b.0 - dy, b.1 + dx), d1 = (a.0 - dy, a.1 + dx)
+ let c2 = (b.0 + dy, b.1 - dx), d2 = (a.0 + dy, a.1 - dx)
+ func same(_ p: (Int, Int), _ q: (Int, Int), _ r: (Int, Int), _ s: (Int, Int)) -> Bool {
+ (p == r && q == s) || (p == s && q == r)
+ }
+ if !mOnBoard(c1) || !mOnBoard(d1) {
+ if !mOnBoard(c2) || !mOnBoard(d2) { return false }
+ }
+ return same(pts[0], pts[1], c1, d1) || same(pts[0], pts[1], c2, d2)
+ }
+ if spec.hasPrefix("points:") {
+ let want = spec.dropFirst(7).split(separator: ";").compactMap { part -> (Int, Int)? in
+ if part.hasPrefix("fix:") { return nil }
+ return mParseXY(part)
+ }
+ if want.count != pts.count { return false }
+ var used = Array(repeating: false, count: pts.count)
+ for w in want {
+ guard let j = pts.indices.first(where: { !used[$0] && pts[$0] == w }) else { return false }
+ used[j] = true
+ }
+ return true
+ }
+ return false
+}
diff --git a/ios/Graduately/Data/Content.swift b/ios/Graduately/Data/Content.swift
new file mode 100644
index 0000000..dafa975
--- /dev/null
+++ b/ios/Graduately/Data/Content.swift
@@ -0,0 +1,267 @@
+import Foundation
+
+enum AppConfig {
+ static var commit: String {
+ let raw = (Bundle.main.object(forInfoDictionaryKey: "GITCommit") as? String)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return raw.isEmpty || raw.hasPrefix("$(") ? "dev" : raw
+ }
+
+ static var versionName: String {
+ Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios-1"
+ }
+
+ static var updateRepo: String {
+ (Bundle.main.object(forInfoDictionaryKey: "UPDATE_REPO") as? String)
+ ?? "pepaskopec-blip/Graduately"
+ }
+
+ static var updateBranch: String {
+ (Bundle.main.object(forInfoDictionaryKey: "UPDATE_BRANCH") as? String) ?? "builds"
+ }
+}
+
+final class Content {
+ let raw: J
+ let i18n: [String: (String?, String?)]
+ let subjects: [J]
+ let german: [J]
+ let net: J
+ let netLessons: [J]
+ let netTasks: [J]
+ let netAnswers: [[J]]
+ let hw: [J]
+ let net2: [J]
+ let net3: [J]
+ let net4: [J]
+ let hw2: [J]
+ let hw3: [J]
+ let hw4: [J]
+ let on: [J]
+ let on2: [J]
+ let on3: [J]
+ let on4: [J]
+ let en: [J]
+ let en2: [J]
+ let en3: [J]
+ let en4: [J]
+ let de: [J]
+ let de2: [J]
+ let de3: [J]
+ let de4: [J]
+ let lit: [J]
+ let lit2: [J]
+ let lit3: [J]
+ let lit4: [J]
+ let chem: [J]
+ let bio: [J]
+ let fyz: [J]
+ let fyz2: [J]
+ let fyz3: [J]
+ let fyz4: [J]
+ let tg: [J]
+ let tg2: [J]
+ let tg3: [J]
+ let tg4: [J]
+ let pg: [J]
+ let pg2: [J]
+ let pg3: [J]
+ let pg4: [J]
+ let sw: [J]
+ let sw2: [J]
+ let sw3: [J]
+ let sw4: [J]
+ let cs: [J]
+ let cs2: [J]
+ let cs3: [J]
+ let cs4: [J]
+ let mat0: [J]
+ let mat: [J]
+ let mat2: [J]
+ let mat3: [J]
+ let mat4: [J]
+ let mluvnice: [J]
+ let books: [J]
+ let changelog: [J]
+ let progressKeys: [String: String]
+ let progressLegacy: [String: String]
+
+ init(_ root: [String: Any]) {
+ raw = J(root)
+ let obj = root["i18n"] as? [String: Any] ?? [:]
+ var map: [String: (String?, String?)] = [:]
+ for (key, value) in obj {
+ let e = J(value)
+ let cs = e.strOrNull("cs")
+ let en = e.strOrNull("en")
+ map[key] = (cs, en)
+ }
+ i18n = map
+ subjects = raw.arr("subjects")
+ german = raw.arr("german")
+ net = raw.obj("net") ?? J([:])
+ netLessons = net.arr("lessons")
+ netTasks = net.arr("tasks")
+ if let a = net.o["answers"] as? [Any] {
+ netAnswers = a.map { block in
+ guard let rows = block as? [Any] else { return [] }
+ return rows.compactMap { item in
+ item is [String: Any] ? J(item) : nil
+ }
+ }
+ } else {
+ netAnswers = []
+ }
+ hw = raw.arr("hw")
+ net2 = raw.arr("net2")
+ net3 = raw.arr("net3")
+ net4 = raw.arr("net4")
+ hw2 = raw.arr("hw2")
+ hw3 = raw.arr("hw3")
+ hw4 = raw.arr("hw4")
+ on = raw.arr("on")
+ on2 = raw.arr("on2")
+ on3 = raw.arr("on3")
+ on4 = raw.arr("on4")
+ en = raw.arr("en")
+ en2 = raw.arr("en2")
+ en3 = raw.arr("en3")
+ en4 = raw.arr("en4")
+ de = raw.arr("de")
+ de2 = raw.arr("de2")
+ de3 = raw.arr("de3")
+ de4 = raw.arr("de4")
+ lit = raw.arr("lit")
+ lit2 = raw.arr("lit2")
+ lit3 = raw.arr("lit3")
+ lit4 = raw.arr("lit4")
+ chem = raw.arr("chem")
+ bio = raw.arr("bio")
+ fyz = raw.arr("fyz")
+ fyz2 = raw.arr("fyz2")
+ fyz3 = raw.arr("fyz3")
+ fyz4 = raw.arr("fyz4")
+ tg = raw.arr("tg")
+ tg2 = raw.arr("tg2")
+ tg3 = raw.arr("tg3")
+ tg4 = raw.arr("tg4")
+ pg = raw.arr("pg")
+ pg2 = raw.arr("pg2")
+ pg3 = raw.arr("pg3")
+ pg4 = raw.arr("pg4")
+ sw = raw.arr("sw")
+ sw2 = raw.arr("sw2")
+ sw3 = raw.arr("sw3")
+ sw4 = raw.arr("sw4")
+ cs = raw.arr("cs")
+ cs2 = raw.arr("cs2")
+ cs3 = raw.arr("cs3")
+ cs4 = raw.arr("cs4")
+ mat0 = raw.arr("mat0")
+ mat = raw.arr("mat")
+ mat2 = raw.arr("mat2")
+ mat3 = raw.arr("mat3")
+ mat4 = raw.arr("mat4")
+ mluvnice = raw.arr("mluvnice")
+ books = raw.arr("books")
+ changelog = raw.arr("changelog")
+ let progress = root["progress"] as? [String: Any]
+ progressKeys = progress?["keys"] as? [String: String] ?? [:]
+ progressLegacy = progress?["legacy"] as? [String: String] ?? [:]
+ }
+
+ func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }
+ func netLesson(_ id: Int) -> J? { netLessons.first { $0.int("id") == id } }
+ func hwLesson(_ id: Int) -> J? { hw.first { $0.int("id") == id } }
+ func itLessons(_ course: String) -> [J] {
+ switch course {
+ case "net2": return net2
+ case "net3": return net3
+ case "net4": return net4
+ case "hw2": return hw2
+ case "hw3": return hw3
+ case "hw4": return hw4
+ default: return []
+ }
+ }
+ func itLesson(_ course: String, _ id: Int) -> J? {
+ itLessons(course).first { $0.int("id") == id }
+ }
+ func onLesson(_ id: Int) -> J? { on.first { $0.int("id") == id } }
+ func on2Lesson(_ id: Int) -> J? { on2.first { $0.int("id") == id } }
+ func on3Lesson(_ id: Int) -> J? { on3.first { $0.int("id") == id } }
+ func on4Lesson(_ id: Int) -> J? { on4.first { $0.int("id") == id } }
+ func enYear(_ year: Int) -> [J] {
+ switch year {
+ case 2: return en2
+ case 3: return en3
+ case 4: return en4
+ default: return en
+ }
+ }
+ func enLesson(_ year: Int, _ id: Int) -> J? { enYear(year).first { $0.int("id") == id } }
+ func deYear(_ year: Int) -> [J] {
+ switch year {
+ case 2: return de2
+ case 3: return de3
+ case 4: return de4
+ default: return de
+ }
+ }
+ func deLesson(_ year: Int, _ id: Int) -> J? { deYear(year).first { $0.int("id") == id } }
+ func litLesson(_ id: Int) -> J? { lit.first { $0.int("id") == id } }
+ func lit2Lesson(_ id: Int) -> J? { lit2.first { $0.int("id") == id } }
+ func lit3Lesson(_ id: Int) -> J? { lit3.first { $0.int("id") == id } }
+ func lit4Lesson(_ id: Int) -> J? { lit4.first { $0.int("id") == id } }
+ func chemLesson(_ id: Int) -> J? { chem.first { $0.int("id") == id } }
+ func bioLesson(_ id: Int) -> J? { bio.first { $0.int("id") == id } }
+ func fyzLesson(_ id: Int) -> J? { fyz.first { $0.int("id") == id } }
+ func fyz2Lesson(_ id: Int) -> J? { fyz2.first { $0.int("id") == id } }
+ func fyz3Lesson(_ id: Int) -> J? { fyz3.first { $0.int("id") == id } }
+ func fyz4Lesson(_ id: Int) -> J? { fyz4.first { $0.int("id") == id } }
+ func tgLesson(_ id: Int) -> J? { tg.first { $0.int("id") == id } }
+ func tg2Lesson(_ id: Int) -> J? { tg2.first { $0.int("id") == id } }
+ func tg3Lesson(_ id: Int) -> J? { tg3.first { $0.int("id") == id } }
+ func tg4Lesson(_ id: Int) -> J? { tg4.first { $0.int("id") == id } }
+ func pgLesson(_ id: Int) -> J? { pg.first { $0.int("id") == id } }
+ func pg2Lesson(_ id: Int) -> J? { pg2.first { $0.int("id") == id } }
+ func pg3Lesson(_ id: Int) -> J? { pg3.first { $0.int("id") == id } }
+ func pg4Lesson(_ id: Int) -> J? { pg4.first { $0.int("id") == id } }
+ func swLesson(_ id: Int) -> J? { sw.first { $0.int("id") == id } }
+ func sw2Lesson(_ id: Int) -> J? { sw2.first { $0.int("id") == id } }
+ func sw3Lesson(_ id: Int) -> J? { sw3.first { $0.int("id") == id } }
+ func sw4Lesson(_ id: Int) -> J? { sw4.first { $0.int("id") == id } }
+ func csLesson(_ id: Int) -> J? { cs.first { $0.int("id") == id } }
+ func cs2Lesson(_ id: Int) -> J? { cs2.first { $0.int("id") == id } }
+ func cs3Lesson(_ id: Int) -> J? { cs3.first { $0.int("id") == id } }
+ func cs4Lesson(_ id: Int) -> J? { cs4.first { $0.int("id") == id } }
+ func mat0Lesson(_ id: Int) -> J? { mat0.first { $0.int("id") == id } }
+ func matLesson(_ id: Int) -> J? { mat.first { $0.int("id") == id } }
+ func mat2Lesson(_ id: Int) -> J? { mat2.first { $0.int("id") == id } }
+ func mat3Lesson(_ id: Int) -> J? { mat3.first { $0.int("id") == id } }
+ func mat4Lesson(_ id: Int) -> J? { mat4.first { $0.int("id") == id } }
+ func mluv(_ n: Int) -> J? { mluvnice.first { $0.int("id") == n } }
+ func book(_ id: String) -> J? { books.first { $0.str("id") == id } }
+
+ func tr(_ key: String?, lang: UiLang) -> String {
+ guard let key, !key.isEmpty else { return "" }
+ let hit = i18n[key]
+ if lang == .en { return hit?.1 ?? key }
+ return hit?.0 ?? key
+ }
+
+ func fmt(_ key: String, lang: UiLang, _ args: [CVarArg]) -> String {
+ let template = tr(key, lang: lang).replacingOccurrences(of: "%s", with: "%@")
+ return String(format: template, arguments: args)
+ }
+
+ static func load() -> Content {
+ guard let url = Bundle.main.url(forResource: "content", withExtension: "json"),
+ let data = try? Data(contentsOf: url),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return Content([:])
+ }
+ return Content(obj)
+ }
+}
diff --git a/ios/Graduately/Data/JSON.swift b/ios/Graduately/Data/JSON.swift
new file mode 100644
index 0000000..137e769
--- /dev/null
+++ b/ios/Graduately/Data/JSON.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+struct J {
+ let o: [String: Any]
+
+ init(_ obj: Any?) {
+ o = obj as? [String: Any] ?? [:]
+ }
+
+ func has(_ key: String) -> Bool {
+ guard let value = o[key], !(value is NSNull) else { return false }
+ return true
+ }
+
+ func str(_ key: String, _ fallback: String = "") -> String {
+ guard has(key) else { return fallback }
+ if let s = o[key] as? String { return s }
+ if let n = o[key] as? NSNumber { return n.stringValue }
+ return fallback
+ }
+
+ func strOrNull(_ key: String) -> String? {
+ guard has(key) else { return nil }
+ if let s = o[key] as? String, !s.isEmpty, s != "null" { return s }
+ return nil
+ }
+
+ func bool(_ key: String, _ fallback: Bool = false) -> Bool {
+ if let b = o[key] as? Bool { return b }
+ if let n = o[key] as? NSNumber { return n.boolValue }
+ return fallback
+ }
+
+ func int(_ key: String, _ fallback: Int = 0) -> Int {
+ if let n = o[key] as? Int { return n }
+ if let n = o[key] as? NSNumber { return n.intValue }
+ if let s = o[key] as? String, let n = Int(s) { return n }
+ return fallback
+ }
+
+ func obj(_ key: String) -> J? {
+ guard let value = o[key], !(value is NSNull) else { return nil }
+ if value is [String: Any] { return J(value) }
+ return nil
+ }
+
+ func exercise(_ n: Int) -> J? {
+ guard let ex = o["exercises"] as? [String: Any] else { return nil }
+ return J(ex["\(n)"])
+ }
+
+ func arr(_ key: String) -> [J] {
+ guard let a = o[key] as? [Any] else { return [] }
+ return a.compactMap { item in
+ item is [String: Any] ? J(item) : nil
+ }
+ }
+
+ func strs(_ key: String) -> [String] {
+ guard let a = o[key] as? [Any] else { return [] }
+ return a.compactMap { item in
+ if item is NSNull { return nil }
+ if let s = item as? String, !s.isEmpty { return s }
+ return nil
+ }
+ }
+
+ func strsOrEmpty(_ key: String) -> [String?] {
+ guard let a = o[key] as? [Any] else { return [] }
+ return a.map { item in
+ if item is NSNull { return nil }
+ return item as? String
+ }
+ }
+
+ func intRows(_ key: String) -> [[String]] {
+ guard let a = o[key] as? [Any] else { return [] }
+ return a.map { row in
+ guard let cells = row as? [Any] else { return [] }
+ return cells.map { cell in
+ if let s = cell as? String { return s }
+ if let n = cell as? NSNumber { return n.stringValue }
+ return ""
+ }
+ }
+ }
+
+ func rawArray(_ key: String) -> [Any] {
+ o[key] as? [Any] ?? []
+ }
+
+ func answerList() -> [String] {
+ if let a = o["answers"] as? [Any] {
+ return a.compactMap { item in
+ if item is NSNull { return nil }
+ if let s = item as? String, !s.isEmpty { return s }
+ return nil
+ }
+ }
+ return [strOrNull("answers")].compactMap { $0 }
+ }
+}
diff --git a/ios/Graduately/Data/ProgressStore.swift b/ios/Graduately/Data/ProgressStore.swift
new file mode 100644
index 0000000..f77c330
--- /dev/null
+++ b/ios/Graduately/Data/ProgressStore.swift
@@ -0,0 +1,497 @@
+import Foundation
+
+final class ProgressStore {
+ private let defaults = UserDefaults.standard
+ private let ids: [String: String]
+
+ init(content: Content) {
+ ids = content.progressKeys
+ migrate(content.progressLegacy)
+ }
+
+ /// Progress is stored under each lesson's id from content/, so lessons can be
+ /// reordered or renamed without losing it. `address` is the in-app position.
+ private func key(_ address: String) -> String { "done." + (ids[address] ?? address) }
+ private func done(_ address: String) -> Bool { defaults.bool(forKey: key(address)) }
+ private func mark(_ address: String) { defaults.set(true, forKey: key(address)) }
+
+ private func migrate(_ legacy: [String: String]) {
+ guard !defaults.bool(forKey: "progress_ids_v2") else { return }
+ for (old, id) in legacy where defaults.bool(forKey: old) {
+ defaults.set(true, forKey: "done." + id)
+ }
+ defaults.set(true, forKey: "progress_ids_v2")
+ }
+
+ var themeId: ThemeId {
+ get { ThemeId(rawValue: defaults.integer(forKey: "theme")) ?? .catppuccin }
+ set { defaults.set(newValue.rawValue, forKey: "theme") }
+ }
+
+ var mode: ColorMode {
+ get { defaults.string(forKey: "mode") == "dark" ? .dark : .light }
+ set { defaults.set(newValue == .light ? "light" : "dark", forKey: "mode") }
+ }
+
+ var lang: UiLang {
+ get { defaults.string(forKey: "lang") == "en" ? .en : .cs }
+ set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") }
+ }
+
+ var seenCommit: String {
+ get { defaults.string(forKey: "seen_commit") ?? "" }
+ set { defaults.set(newValue, forKey: "seen_commit") }
+ }
+
+ func germanDone(_ unit: Int, _ ex: Int) -> Bool { done("g.\(unit).\(ex)") }
+ func markGerman(_ unit: Int, _ ex: Int) { mark("g.\(unit).\(ex)") }
+ func vocabDone(_ unit: Int) -> Bool { done("g.\(unit).vocab") }
+ func markVocab(_ unit: Int) { mark("g.\(unit).vocab") }
+
+ func netDone(_ id: Int) -> Bool { done("net.\(id)") }
+ func markNet(_ id: Int) { mark("net.\(id)") }
+
+ func hwDone(_ id: Int) -> Bool { done("hw.\(id)") }
+ func markHw(_ id: Int) { mark("hw.\(id)") }
+
+ func onDone(_ id: Int) -> Bool { done("on.\(id)") }
+ func markOn(_ id: Int) { mark("on.\(id)") }
+
+ func on2Done(_ id: Int) -> Bool { done("on2.\(id)") }
+ func markOn2(_ id: Int) { mark("on2.\(id)") }
+
+ func on3Done(_ id: Int) -> Bool { done("on3.\(id)") }
+ func markOn3(_ id: Int) { mark("on3.\(id)") }
+
+ func on4Done(_ id: Int) -> Bool { done("on4.\(id)") }
+ func markOn4(_ id: Int) { mark("on4.\(id)") }
+
+ func enDone(_ year: Int, _ id: Int) -> Bool { done("en.\(year).\(id)") }
+ func markEn(_ year: Int, _ id: Int) { mark("en.\(year).\(id)") }
+ func deDone(_ year: Int, _ id: Int) -> Bool { done("de.\(year).\(id)") }
+ func markDe(_ year: Int, _ id: Int) { mark("de.\(year).\(id)") }
+ func courseDone(_ course: String, _ id: Int) -> Bool { done("\(course).\(id)") }
+ func markCourse(_ course: String, _ id: Int) { mark("\(course).\(id)") }
+
+ func litDone(_ id: Int) -> Bool { done("lit.\(id)") }
+ func markLit(_ id: Int) { mark("lit.\(id)") }
+ func lit2Done(_ id: Int) -> Bool { done("lit2.\(id)") }
+ func markLit2(_ id: Int) { mark("lit2.\(id)") }
+ func lit3Done(_ id: Int) -> Bool { done("lit3.\(id)") }
+ func markLit3(_ id: Int) { mark("lit3.\(id)") }
+ func lit4Done(_ id: Int) -> Bool { done("lit4.\(id)") }
+ func markLit4(_ id: Int) { mark("lit4.\(id)") }
+
+ func chemDone(_ id: Int) -> Bool { done("chem.\(id)") }
+ func markChem(_ id: Int) { mark("chem.\(id)") }
+ func bioDone(_ id: Int) -> Bool { done("bio.\(id)") }
+ func markBio(_ id: Int) { mark("bio.\(id)") }
+ func fyzDone(_ id: Int) -> Bool { done("fyz.\(id)") }
+ func markFyz(_ id: Int) { mark("fyz.\(id)") }
+ func fyz2Done(_ id: Int) -> Bool { done("fyz2.\(id)") }
+ func markFyz2(_ id: Int) { mark("fyz2.\(id)") }
+ func fyz3Done(_ id: Int) -> Bool { done("fyz3.\(id)") }
+ func markFyz3(_ id: Int) { mark("fyz3.\(id)") }
+ func fyz4Done(_ id: Int) -> Bool { done("fyz4.\(id)") }
+ func markFyz4(_ id: Int) { mark("fyz4.\(id)") }
+ func tgDone(_ id: Int) -> Bool { done("tg.\(id)") }
+ func markTg(_ id: Int) { mark("tg.\(id)") }
+ func tg2Done(_ id: Int) -> Bool { done("tg2.\(id)") }
+ func markTg2(_ id: Int) { mark("tg2.\(id)") }
+ func tg3Done(_ id: Int) -> Bool { done("tg3.\(id)") }
+ func markTg3(_ id: Int) { mark("tg3.\(id)") }
+ func tg4Done(_ id: Int) -> Bool { done("tg4.\(id)") }
+ func markTg4(_ id: Int) { mark("tg4.\(id)") }
+ func pgDone(_ id: Int) -> Bool { done("pg.\(id)") }
+ func markPg(_ id: Int) { mark("pg.\(id)") }
+ func pg2Done(_ id: Int) -> Bool { done("pg2.\(id)") }
+ func markPg2(_ id: Int) { mark("pg2.\(id)") }
+ func pg3Done(_ id: Int) -> Bool { done("pg3.\(id)") }
+ func markPg3(_ id: Int) { mark("pg3.\(id)") }
+ func pg4Done(_ id: Int) -> Bool { done("pg4.\(id)") }
+ func markPg4(_ id: Int) { mark("pg4.\(id)") }
+ func swDone(_ id: Int) -> Bool { done("sw.\(id)") }
+ func markSw(_ id: Int) { mark("sw.\(id)") }
+ func sw2Done(_ id: Int) -> Bool { done("sw2.\(id)") }
+ func markSw2(_ id: Int) { mark("sw2.\(id)") }
+ func sw3Done(_ id: Int) -> Bool { done("sw3.\(id)") }
+ func markSw3(_ id: Int) { mark("sw3.\(id)") }
+ func sw4Done(_ id: Int) -> Bool { done("sw4.\(id)") }
+ func markSw4(_ id: Int) { mark("sw4.\(id)") }
+ func csDone(_ id: Int) -> Bool { done("cs.\(id)") }
+ func markCs(_ id: Int) { mark("cs.\(id)") }
+ func cs2Done(_ id: Int) -> Bool { done("cs2.\(id)") }
+ func markCs2(_ id: Int) { mark("cs2.\(id)") }
+ func cs3Done(_ id: Int) -> Bool { done("cs3.\(id)") }
+ func markCs3(_ id: Int) { mark("cs3.\(id)") }
+ func cs4Done(_ id: Int) -> Bool { done("cs4.\(id)") }
+ func markCs4(_ id: Int) { mark("cs4.\(id)") }
+ func mat0Done(_ id: Int) -> Bool { done("mat0.\(id)") }
+ func markMat0(_ id: Int) { mark("mat0.\(id)") }
+ func matDone(_ id: Int) -> Bool { done("mat.\(id)") }
+ func markMat(_ id: Int) { mark("mat.\(id)") }
+ func mat2Done(_ id: Int) -> Bool { done("mat2.\(id)") }
+ func markMat2(_ id: Int) { mark("mat2.\(id)") }
+ func mat3Done(_ id: Int) -> Bool { done("mat3.\(id)") }
+ func markMat3(_ id: Int) { mark("mat3.\(id)") }
+ func mat4Done(_ id: Int) -> Bool { done("mat4.\(id)") }
+ func markMat4(_ id: Int) { mark("mat4.\(id)") }
+
+ func mluvDone(_ n: Int) -> Bool { done("mluv.\(n)") }
+ func markMluv(_ n: Int) { mark("mluv.\(n)") }
+
+ func bookQuiz(_ id: String) -> Bool { done("book.\(id).quiz") }
+ func markBookQuiz(_ id: String) { mark("book.\(id).quiz") }
+ func bookPlot(_ id: String) -> Bool { done("book.\(id).plot") }
+ func markBookPlot(_ id: String) { mark("book.\(id).plot") }
+}
+
+struct ProgressSum {
+ var doneEx = 0
+ var totalEx = 0
+ var doneUnits = 0
+ var openUnits = 0
+}
+
+func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
+ var sum = ProgressSum()
+ for u in content.german where u.bool("unlocked") {
+ let id = u.int("id")
+ let names = u.strs("names")
+ sum.totalEx += names.count
+ sum.openUnits += 1
+ let done = names.indices.filter { p.germanDone(id, $0 + 1) }.count
+ sum.doneEx += done
+ if !names.isEmpty && done == names.count { sum.doneUnits += 1 }
+ }
+ for l in content.netLessons {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.netDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.hw {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.hwDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for course in ["net2", "net3", "net4", "hw2", "hw3", "hw4"] {
+ for l in content.itLessons(course) {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.courseDone(course, l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ }
+ for l in content.on {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.onDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.on2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.on2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.on3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.on3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.on4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.on4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for year in 1...4 {
+ for l in content.enYear(year) {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.enDone(year, l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.deYear(year) {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.deDone(year, l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ }
+ for l in content.lit {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.litDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.lit2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.lit2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.lit3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.lit3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.lit4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.lit4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.chem {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.chemDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.bio {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.bioDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.fyz {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.fyzDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.fyz2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.fyz2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.fyz3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.fyz3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.fyz4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.fyz4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.tg {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.tgDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.tg2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.tg2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.tg3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.tg3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.tg4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.tg4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.pg {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.pgDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.pg2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.pg2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.pg3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.pg3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.pg4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.pg4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.sw {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.swDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.sw2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.sw2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.sw3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.sw3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.sw4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.sw4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.cs {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.csDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.cs2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.cs2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.cs3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.cs3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.cs4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.cs4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.mat0 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.mat0Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.mat {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.matDone(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.mat2 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.mat2Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.mat3 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.mat3Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ for l in content.mat4 {
+ sum.totalEx += 1
+ sum.openUnits += 1
+ if p.mat4Done(l.int("id")) {
+ sum.doneEx += 1
+ sum.doneUnits += 1
+ }
+ }
+ sum.totalEx += content.mluvnice.count
+ sum.openUnits += 1
+ let md = content.mluvnice.filter { p.mluvDone($0.int("id")) }.count
+ sum.doneEx += md
+ if md == content.mluvnice.count && !content.mluvnice.isEmpty { sum.doneUnits += 1 }
+ return sum
+}
diff --git a/ios/Graduately/Data/Theme.swift b/ios/Graduately/Data/Theme.swift
new file mode 100644
index 0000000..fb439aa
--- /dev/null
+++ b/ios/Graduately/Data/Theme.swift
@@ -0,0 +1,199 @@
+import SwiftUI
+
+enum ThemeId: Int, CaseIterable {
+ case catppuccin, nord, dracula, rosePine, ocean, gruvbox, solarized, everforest, monokai, oneDark
+}
+
+enum ColorMode {
+ case dark, light
+}
+
+enum UiLang {
+ case cs, en
+}
+
+let themeNames = [
+ "Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean",
+ "Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark",
+]
+
+struct Palette {
+ let crust, base, mantle, surface0, surface1, surface2: Color
+ let overlay, text, subtext: Color
+ let accent, accent2, accent3: Color
+ let success, success2, warning, error: Color
+ let onAccent, node, lockedBg, lockedBorder, rail: Color
+ let finishDark, finishLight, finishStroke: Color
+ /// Accent that stays readable as text and icon tint on the theme's background.
+ let tint: Color
+}
+
+private func hx(_ v: UInt32) -> Color {
+ Color(
+ red: Double((v >> 16) & 0xFF) / 255,
+ green: Double((v >> 8) & 0xFF) / 255,
+ blue: Double(v & 0xFF) / 255
+ )
+}
+
+private func luminance(_ v: UInt32) -> Double {
+ func channel(_ c: UInt32) -> Double {
+ let s = Double(c) / 255
+ return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4)
+ }
+ return 0.2126 * channel((v >> 16) & 0xFF) + 0.7152 * channel((v >> 8) & 0xFF) + 0.0722 * channel(v & 0xFF)
+}
+
+private func contrast(_ a: UInt32, _ b: UInt32) -> Double {
+ let la = luminance(a), lb = luminance(b)
+ return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
+}
+
+/// Pick the first accent with enough contrast against the background; yellow on
+/// white is unreadable as tab labels and link text.
+private func readableTint(_ c: [UInt32]) -> UInt32 {
+ let background = c[1]
+ for candidate in [c[9], c[10], c[11]] where contrast(candidate, background) >= 3.0 {
+ return candidate
+ }
+ return c[7]
+}
+
+private func pal(_ c: [UInt32]) -> Palette {
+ Palette(
+ crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
+ surface1: hx(c[4]), surface2: hx(c[5]), overlay: hx(c[6]), text: hx(c[7]),
+ subtext: hx(c[8]), accent: hx(c[9]), accent2: hx(c[10]), accent3: hx(c[11]),
+ success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
+ onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
+ lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
+ finishLight: hx(c[22]), finishStroke: hx(c[23]),
+ tint: hx(readableTint(c))
+ )
+}
+
+func themePalette(_ id: ThemeId, _ mode: ColorMode) -> Palette {
+ let light = mode == .light
+ switch id {
+ case .catppuccin:
+ return light ? pal([
+ 0xf3eee6, 0xfaf7f2, 0xffffff, 0xffffff, 0xf0ebe3, 0xe5dfd4,
+ 0x8a8378, 0x1a1a1a, 0x5c574f, 0xffcc00, 0x0071e3, 0x2997ff,
+ 0x1f8a4c, 0x1a7f4a, 0xe6a800, 0xd32f2f, 0x1a1a1a,
+ 0xffffff, 0xf0ebe3, 0xd5cfc4, 0xe5dfd4, 0xf3eee6, 0xd5cfc4, 0x8a8378,
+ ]) : pal([
+ 0x000000, 0x000000, 0x111111, 0x1d1d1f, 0x2c2c2e, 0x3a3a3c,
+ 0x86868b, 0xf5f5f7, 0xa1a1a6, 0xffd60a, 0x2997ff, 0x64d2ff,
+ 0x30d158, 0x64d2ff, 0xffd60a, 0xff453a, 0x1d1d1f,
+ 0x1d1d1f, 0x111111, 0x3a3a3c, 0x2c2c2e, 0x000000, 0x3a3a3c, 0x86868b,
+ ])
+ case .nord:
+ return light ? pal([
+ 0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8,
+ 0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac,
+ 0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4,
+ 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1,
+ ]) : pal([
+ 0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88,
+ 0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac,
+ 0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440,
+ 0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a,
+ ])
+ case .dracula:
+ return light ? pal([
+ 0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2,
+ 0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2,
+ 0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2,
+ 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c,
+ ]) : pal([
+ 0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0,
+ 0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd,
+ 0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36,
+ 0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4,
+ ])
+ case .rosePine:
+ return light ? pal([
+ 0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd,
+ 0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f,
+ 0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed,
+ 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5,
+ ]) : pal([
+ 0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67,
+ 0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8,
+ 0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724,
+ 0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67,
+ ])
+ case .ocean:
+ return light ? pal([
+ 0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5,
+ 0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197,
+ 0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec,
+ 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199,
+ ]) : pal([
+ 0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89,
+ 0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff,
+ 0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26,
+ 0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868,
+ ])
+ case .gruvbox:
+ return light ? pal([
+ 0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93,
+ 0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678,
+ 0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7,
+ 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374,
+ ]) : pal([
+ 0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54,
+ 0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598,
+ 0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021,
+ 0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945,
+ ])
+ case .solarized:
+ return light ? pal([
+ 0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6,
+ 0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198,
+ 0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3,
+ 0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75,
+ ]) : pal([
+ 0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496,
+ 0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198,
+ 0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36,
+ 0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75,
+ ])
+ case .everforest:
+ return light ? pal([
+ 0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0,
+ 0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c,
+ 0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3,
+ 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91,
+ ]) : pal([
+ 0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478,
+ 0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3,
+ 0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33,
+ 0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58,
+ ])
+ case .monokai:
+ return light ? pal([
+ 0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e,
+ 0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b,
+ 0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0,
+ 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674,
+ ]) : pal([
+ 0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e,
+ 0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef,
+ 0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822,
+ 0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e,
+ ])
+ case .oneDark:
+ return light ? pal([
+ 0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
+ 0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc,
+ 0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa,
+ 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
+ ]) : pal([
+ 0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370,
+ 0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2,
+ 0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34,
+ 0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263,
+ ])
+ }
+}
diff --git a/ios/Graduately/GraduatelyApp.swift b/ios/Graduately/GraduatelyApp.swift
new file mode 100644
index 0000000..366e93c
--- /dev/null
+++ b/ios/Graduately/GraduatelyApp.swift
@@ -0,0 +1,104 @@
+import SwiftUI
+
+@main
+struct GraduatelyApp: App {
+ @StateObject private var vm = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ NativeRoot(vm: vm)
+ }
+ #if os(macOS)
+ .defaultSize(width: 1280, height: 840)
+ .windowResizability(.contentMinSize)
+ #endif
+ }
+}
+
+struct NativeRoot: View {
+ @ObservedObject var vm: AppModel
+ @State private var progressPath = NavigationPath()
+ @State private var settingsPath = NavigationPath()
+ @State private var searchPath = NavigationPath()
+
+ var body: some View {
+ Group {
+ if showsUpdateAccessory {
+ tabs.tabViewBottomAccessory { UpdateAccessory(vm: vm) }
+ } else {
+ tabs
+ }
+ }
+ .tint(vm.palette.tint)
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ .onAppear { vm.checkUpdate(false) }
+ #if os(macOS)
+ .frame(minWidth: 980, minHeight: 640)
+ #endif
+ }
+
+ private var showsUpdateAccessory: Bool {
+ vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged"
+ }
+
+ private var tabs: some View {
+ TabView(selection: $vm.tab) {
+ Tab(vm.tr("subjects_title"), systemImage: "square.grid.2x2.fill", value: .practice) {
+ NavigationStack(path: $vm.practicePath) {
+ PracticeHome(vm: vm)
+ .navigationDestination(for: Route.self) { route in
+ RouteDestination(vm: vm, route: route)
+ }
+ }
+ }
+ Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) {
+ NavigationStack(path: $progressPath) {
+ StatsScreen(vm: vm)
+ }
+ }
+ Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) {
+ NavigationStack(path: $settingsPath) {
+ SettingsScreen(vm: vm)
+ }
+ }
+ Tab(vm.tr("search"), systemImage: "magnifyingglass", value: .search) {
+ NavigationStack(path: $searchPath) {
+ SearchScreen(vm: vm)
+ }
+ }
+ }
+ .tabViewStyle(.sidebarAdaptable)
+ .tabBarMinimizeBehavior(.onScrollDown)
+ }
+}
+
+private struct UpdateAccessory: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ HStack {
+ Text(bannerText)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.subheadline)
+ #endif
+ Spacer()
+ if vm.update.canInstall {
+ Button(vm.tr("update_install")) { vm.installUpdate() }
+ .buttonStyle(.borderedProminent)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+ }
+ .padding(.horizontal, 4)
+ }
+
+ private var bannerText: String {
+ if let arg = vm.update.messageArg {
+ return vm.fmt(vm.update.messageKey, arg)
+ }
+ return vm.tr(vm.update.messageKey)
+ }
+}
diff --git a/ios/Graduately/Info.plist b/ios/Graduately/Info.plist
new file mode 100644
index 0000000..01db115
--- /dev/null
+++ b/ios/Graduately/Info.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ GITCommit
+ $(COMMIT)
+ UILaunchScreen
+
+ UPDATE_BRANCH
+ builds
+ UPDATE_REPO
+ pepaskopec-blip/Graduately
+
+
diff --git a/ios/Graduately/Route.swift b/ios/Graduately/Route.swift
new file mode 100644
index 0000000..a85395c
--- /dev/null
+++ b/ios/Graduately/Route.swift
@@ -0,0 +1,1015 @@
+import Foundation
+
+enum AppTab: Hashable {
+ case practice
+ case progress
+ case search
+ case settings
+}
+
+enum Route: Hashable {
+ case welcome
+ case subjects
+ case stats
+ case roadmap
+ case deHome
+ case deMap(Int)
+ case deLesson(Int, Int)
+ case deEx(Int, Int)
+ case unitMap(Int)
+ case germanEx(Int, Int)
+ case vocab(Int)
+ case netYears
+ case netMap
+ case netLesson(Int)
+ case netEx(Int)
+ case net2Map
+ case net2Lesson(Int)
+ case net2Ex(Int)
+ case net3Map
+ case net3Lesson(Int)
+ case net3Ex(Int)
+ case net4Map
+ case net4Lesson(Int)
+ case net4Ex(Int)
+ case hwYears
+ case hwMap
+ case hwLesson(Int)
+ case hwEx(Int)
+ case hw2Map
+ case hw2Lesson(Int)
+ case hw2Ex(Int)
+ case hw3Map
+ case hw3Lesson(Int)
+ case hw3Ex(Int)
+ case hw4Map
+ case hw4Lesson(Int)
+ case hw4Ex(Int)
+ case onYears
+ case onMap
+ case onLesson(Int)
+ case onEx(Int)
+ case on2Map
+ case on2Lesson(Int)
+ case on2Ex(Int)
+ case on3Map
+ case on3Lesson(Int)
+ case on3Ex(Int)
+ case on4Map
+ case on4Lesson(Int)
+ case on4Ex(Int)
+ case enYears
+ case enMap(Int)
+ case enLesson(Int, Int)
+ case enEx(Int, Int)
+ case litYears
+ case litMap
+ case litLesson(Int)
+ case litEx(Int)
+ case lit2Map
+ case lit2Lesson(Int)
+ case lit2Ex(Int)
+ case lit3Map
+ case lit3Lesson(Int)
+ case lit3Ex(Int)
+ case lit4Map
+ case lit4Lesson(Int)
+ case lit4Ex(Int)
+ case sciMap
+ case chemMap
+ case chemLesson(Int)
+ case chemEx(Int)
+ case bioMap
+ case bioLesson(Int)
+ case bioEx(Int)
+ case matYears
+ case mat0Map
+ case mat0Lesson(Int)
+ case mat0Ex(Int)
+ case matMap
+ case matLesson(Int)
+ case matEx(Int)
+ case mat2Map
+ case mat2Lesson(Int)
+ case mat2Ex(Int)
+ case mat3Map
+ case mat3Lesson(Int)
+ case mat3Ex(Int)
+ case mat4Map
+ case mat4Lesson(Int)
+ case mat4Ex(Int)
+ case fyzYears
+ case fyzMap
+ case fyzLesson(Int)
+ case fyzEx(Int)
+ case fyz2Map
+ case fyz2Lesson(Int)
+ case fyz2Ex(Int)
+ case fyz3Map
+ case fyz3Lesson(Int)
+ case fyz3Ex(Int)
+ case fyz4Map
+ case fyz4Lesson(Int)
+ case fyz4Ex(Int)
+ case tgYears
+ case tgMap
+ case tgLesson(Int)
+ case tgEx(Int)
+ case tg2Map
+ case tg2Lesson(Int)
+ case tg2Ex(Int)
+ case tg3Map
+ case tg3Lesson(Int)
+ case tg3Ex(Int)
+ case tg4Map
+ case tg4Lesson(Int)
+ case tg4Ex(Int)
+ case pgYears
+ case pgMap
+ case pgLesson(Int)
+ case pgEx(Int)
+ case pg2Map
+ case pg2Lesson(Int)
+ case pg2Ex(Int)
+ case pg3Map
+ case pg3Lesson(Int)
+ case pg3Ex(Int)
+ case pg4Map
+ case pg4Lesson(Int)
+ case pg4Ex(Int)
+ case swYears
+ case swMap
+ case swLesson(Int)
+ case swEx(Int)
+ case sw2Map
+ case sw2Lesson(Int)
+ case sw2Ex(Int)
+ case sw3Map
+ case sw3Lesson(Int)
+ case sw3Ex(Int)
+ case sw4Map
+ case sw4Lesson(Int)
+ case sw4Ex(Int)
+ case csYears
+ case csMap
+ case csLesson(Int)
+ case cs2Map
+ case cs2Lesson(Int)
+ case cs3Map
+ case cs3Lesson(Int)
+ case cs4Map
+ case cs4Lesson(Int)
+ case czechMap
+ case mluvnice
+ case mluvEx(Int)
+ case readingList
+ case book(String)
+ case bookQuiz(String)
+ case bookPlot(String)
+
+ var page: String {
+ switch self {
+ case .welcome: return "welcome"
+ case .subjects: return "subjects"
+ case .stats: return "stats"
+ case .roadmap: return "roadmap"
+ case .deHome: return "dehome"
+ case .deMap(let year): return "de\(year)map"
+ case .deLesson(let year, let id): return "de\(year)unit\(id)"
+ case .deEx(let year, let id): return "de\(year)ex\(id)"
+ case .unitMap(let unitId): return "unit\(unitId + 1)"
+ case .germanEx(let unitId, let ex): return "u\(unitId + 1)e\(ex)"
+ case .vocab(let unitId): return "u\(unitId + 1)vocab"
+ case .netYears: return "netyears"
+ case .netMap: return "netmap"
+ case .netLesson(let id): return "netunit\(id)"
+ case .netEx(let id): return "netex\(id)"
+ case .net2Map: return "net2map"
+ case .net2Lesson(let id): return "net2unit\(id)"
+ case .net2Ex(let id): return "net2ex\(id)"
+ case .net3Map: return "net3map"
+ case .net3Lesson(let id): return "net3unit\(id)"
+ case .net3Ex(let id): return "net3ex\(id)"
+ case .net4Map: return "net4map"
+ case .net4Lesson(let id): return "net4unit\(id)"
+ case .net4Ex(let id): return "net4ex\(id)"
+ case .hwYears: return "hwyears"
+ case .hwMap: return "hwmap"
+ case .hwLesson(let id): return "hwunit\(id)"
+ case .hwEx(let id): return "hwex\(id)"
+ case .hw2Map: return "hw2map"
+ case .hw2Lesson(let id): return "hw2unit\(id)"
+ case .hw2Ex(let id): return "hw2ex\(id)"
+ case .hw3Map: return "hw3map"
+ case .hw3Lesson(let id): return "hw3unit\(id)"
+ case .hw3Ex(let id): return "hw3ex\(id)"
+ case .hw4Map: return "hw4map"
+ case .hw4Lesson(let id): return "hw4unit\(id)"
+ case .hw4Ex(let id): return "hw4ex\(id)"
+ case .onYears: return "onyears"
+ case .onMap: return "onmap"
+ case .onLesson(let id): return "onunit\(id)"
+ case .onEx(let id): return "onex\(id)"
+ case .on2Map: return "on2map"
+ case .on2Lesson(let id): return "on2unit\(id)"
+ case .on2Ex(let id): return "on2ex\(id)"
+ case .on3Map: return "on3map"
+ case .on3Lesson(let id): return "on3unit\(id)"
+ case .on3Ex(let id): return "on3ex\(id)"
+ case .on4Map: return "on4map"
+ case .on4Lesson(let id): return "on4unit\(id)"
+ case .on4Ex(let id): return "on4ex\(id)"
+ case .enYears: return "enyears"
+ case .enMap(let year): return "en\(year)map"
+ case .enLesson(let year, let id): return "en\(year)unit\(id)"
+ case .enEx(let year, let id): return "en\(year)ex\(id)"
+ case .litYears: return "lityears"
+ case .litMap: return "litmap"
+ case .litLesson(let id): return "litunit\(id)"
+ case .litEx(let id): return "litex\(id)"
+ case .lit2Map: return "lit2map"
+ case .lit2Lesson(let id): return "lit2unit\(id)"
+ case .lit2Ex(let id): return "lit2ex\(id)"
+ case .lit3Map: return "lit3map"
+ case .lit3Lesson(let id): return "lit3unit\(id)"
+ case .lit3Ex(let id): return "lit3ex\(id)"
+ case .lit4Map: return "lit4map"
+ case .lit4Lesson(let id): return "lit4unit\(id)"
+ case .lit4Ex(let id): return "lit4ex\(id)"
+ case .sciMap: return "scimap"
+ case .chemMap: return "chemmap"
+ case .chemLesson(let id): return "chemunit\(id)"
+ case .chemEx(let id): return "chemex\(id)"
+ case .bioMap: return "biomap"
+ case .bioLesson(let id): return "biounit\(id)"
+ case .bioEx(let id): return "bioex\(id)"
+ case .matYears: return "matyears"
+ case .mat0Map: return "mat0map"
+ case .mat0Lesson(let id): return "mat0unit\(id)"
+ case .mat0Ex(let id): return "mat0ex\(id)"
+ case .matMap: return "matmap"
+ case .matLesson(let id): return "matunit\(id)"
+ case .matEx(let id): return "matex\(id)"
+ case .mat2Map: return "mat2map"
+ case .mat2Lesson(let id): return "mat2unit\(id)"
+ case .mat2Ex(let id): return "mat2ex\(id)"
+ case .mat3Map: return "mat3map"
+ case .mat3Lesson(let id): return "mat3unit\(id)"
+ case .mat3Ex(let id): return "mat3ex\(id)"
+ case .mat4Map: return "mat4map"
+ case .mat4Lesson(let id): return "mat4unit\(id)"
+ case .mat4Ex(let id): return "mat4ex\(id)"
+ case .fyzYears: return "fyzyears"
+ case .fyzMap: return "fyzmap"
+ case .fyzLesson(let id): return "fyzunit\(id)"
+ case .fyzEx(let id): return "fyzex\(id)"
+ case .fyz2Map: return "fyz2map"
+ case .fyz2Lesson(let id): return "fyz2unit\(id)"
+ case .fyz2Ex(let id): return "fyz2ex\(id)"
+ case .fyz3Map: return "fyz3map"
+ case .fyz3Lesson(let id): return "fyz3unit\(id)"
+ case .fyz3Ex(let id): return "fyz3ex\(id)"
+ case .fyz4Map: return "fyz4map"
+ case .fyz4Lesson(let id): return "fyz4unit\(id)"
+ case .fyz4Ex(let id): return "fyz4ex\(id)"
+ case .tgYears: return "tgyears"
+ case .tgMap: return "tgmap"
+ case .tgLesson(let id): return "tgunit\(id)"
+ case .tgEx(let id): return "tgex\(id)"
+ case .tg2Map: return "tg2map"
+ case .tg2Lesson(let id): return "tg2unit\(id)"
+ case .tg2Ex(let id): return "tg2ex\(id)"
+ case .tg3Map: return "tg3map"
+ case .tg3Lesson(let id): return "tg3unit\(id)"
+ case .tg3Ex(let id): return "tg3ex\(id)"
+ case .tg4Map: return "tg4map"
+ case .tg4Lesson(let id): return "tg4unit\(id)"
+ case .tg4Ex(let id): return "tg4ex\(id)"
+ case .pgYears: return "pgyears"
+ case .pgMap: return "pgmap"
+ case .pgLesson(let id): return "pgunit\(id)"
+ case .pgEx(let id): return "pgex\(id)"
+ case .pg2Map: return "pg2map"
+ case .pg2Lesson(let id): return "pg2unit\(id)"
+ case .pg2Ex(let id): return "pg2ex\(id)"
+ case .pg3Map: return "pg3map"
+ case .pg3Lesson(let id): return "pg3unit\(id)"
+ case .pg3Ex(let id): return "pg3ex\(id)"
+ case .pg4Map: return "pg4map"
+ case .pg4Lesson(let id): return "pg4unit\(id)"
+ case .pg4Ex(let id): return "pg4ex\(id)"
+ case .swYears: return "swyears"
+ case .swMap: return "swmap"
+ case .swLesson(let id): return "swunit\(id)"
+ case .swEx(let id): return "swex\(id)"
+ case .sw2Map: return "sw2map"
+ case .sw2Lesson(let id): return "sw2unit\(id)"
+ case .sw2Ex(let id): return "sw2ex\(id)"
+ case .sw3Map: return "sw3map"
+ case .sw3Lesson(let id): return "sw3unit\(id)"
+ case .sw3Ex(let id): return "sw3ex\(id)"
+ case .sw4Map: return "sw4map"
+ case .sw4Lesson(let id): return "sw4unit\(id)"
+ case .sw4Ex(let id): return "sw4ex\(id)"
+ case .csYears: return "csyears"
+ case .csMap: return "csmap"
+ case .csLesson(let id): return "csunit\(id)"
+ case .cs2Map: return "cs2map"
+ case .cs2Lesson(let id): return "cs2unit\(id)"
+ case .cs3Map: return "cs3map"
+ case .cs3Lesson(let id): return "cs3unit\(id)"
+ case .cs4Map: return "cs4map"
+ case .cs4Lesson(let id): return "cs4unit\(id)"
+ case .czechMap: return "czechmap"
+ case .mluvnice: return "mluvnice"
+ case .mluvEx(let n): return "mluve\(n)"
+ case .readingList: return "readinglist"
+ case .book(let id):
+ if id == "1984" { return "cetba1984" }
+ if id == "fuks" { return "cetbaFuks" }
+ return "cetba_" + id.replacingOccurrences(of: "-", with: "_")
+ case .bookQuiz(let id):
+ if id == "1984" { return "cetba1984quiz" }
+ if id == "fuks" { return "cetbaFuksQuiz" }
+ return "cetba_" + id.replacingOccurrences(of: "-", with: "_") + "_quiz"
+ case .bookPlot(let id):
+ if id == "1984" { return "cetba1984dej" }
+ if id == "fuks" { return "cetbaFuksDej" }
+ return "cetba_" + id.replacingOccurrences(of: "-", with: "_") + "_dej"
+ }
+ }
+
+ /// Stack of destinations on top of the practice root.
+ var stack: [Route] {
+ switch self {
+ case .welcome, .subjects, .stats:
+ return []
+ case .deHome:
+ return [.deHome]
+ case .deMap(let year):
+ return [.deHome, .deMap(year)]
+ case .deLesson(let year, let id):
+ return [.deHome, .deMap(year), .deLesson(year, id)]
+ case .deEx(let year, let id):
+ return [.deHome, .deMap(year), .deLesson(year, id), .deEx(year, id)]
+ case .roadmap:
+ return [.deHome, .roadmap]
+ case .unitMap(let id):
+ return [.deHome, .roadmap, .unitMap(id)]
+ case .germanEx(let unit, let ex):
+ return [.deHome, .roadmap, .unitMap(unit), .germanEx(unit, ex)]
+ case .vocab(let unit):
+ return [.deHome, .roadmap, .unitMap(unit), .vocab(unit)]
+ case .netYears:
+ return [.netYears]
+ case .netMap:
+ return [.netYears, .netMap]
+ case .netLesson(let id):
+ return [.netYears, .netMap, .netLesson(id)]
+ case .netEx(let id):
+ return [.netYears, .netMap, .netLesson(id), .netEx(id)]
+ case .net2Map:
+ return [.netYears, .net2Map]
+ case .net2Lesson(let id):
+ return [.netYears, .net2Map, .net2Lesson(id)]
+ case .net2Ex(let id):
+ return [.netYears, .net2Map, .net2Lesson(id), .net2Ex(id)]
+ case .net3Map:
+ return [.netYears, .net3Map]
+ case .net3Lesson(let id):
+ return [.netYears, .net3Map, .net3Lesson(id)]
+ case .net3Ex(let id):
+ return [.netYears, .net3Map, .net3Lesson(id), .net3Ex(id)]
+ case .net4Map:
+ return [.netYears, .net4Map]
+ case .net4Lesson(let id):
+ return [.netYears, .net4Map, .net4Lesson(id)]
+ case .net4Ex(let id):
+ return [.netYears, .net4Map, .net4Lesson(id), .net4Ex(id)]
+ case .hwYears:
+ return [.hwYears]
+ case .hwMap:
+ return [.hwYears, .hwMap]
+ case .hwLesson(let id):
+ return [.hwYears, .hwMap, .hwLesson(id)]
+ case .hwEx(let id):
+ return [.hwYears, .hwMap, .hwLesson(id), .hwEx(id)]
+ case .hw2Map:
+ return [.hwYears, .hw2Map]
+ case .hw2Lesson(let id):
+ return [.hwYears, .hw2Map, .hw2Lesson(id)]
+ case .hw2Ex(let id):
+ return [.hwYears, .hw2Map, .hw2Lesson(id), .hw2Ex(id)]
+ case .hw3Map:
+ return [.hwYears, .hw3Map]
+ case .hw3Lesson(let id):
+ return [.hwYears, .hw3Map, .hw3Lesson(id)]
+ case .hw3Ex(let id):
+ return [.hwYears, .hw3Map, .hw3Lesson(id), .hw3Ex(id)]
+ case .hw4Map:
+ return [.hwYears, .hw4Map]
+ case .hw4Lesson(let id):
+ return [.hwYears, .hw4Map, .hw4Lesson(id)]
+ case .hw4Ex(let id):
+ return [.hwYears, .hw4Map, .hw4Lesson(id), .hw4Ex(id)]
+ case .onYears:
+ return [.onYears]
+ case .onMap:
+ return [.onYears, .onMap]
+ case .onLesson(let id):
+ return [.onYears, .onMap, .onLesson(id)]
+ case .onEx(let id):
+ return [.onYears, .onMap, .onLesson(id), .onEx(id)]
+ case .on2Map:
+ return [.onYears, .on2Map]
+ case .on2Lesson(let id):
+ return [.onYears, .on2Map, .on2Lesson(id)]
+ case .on2Ex(let id):
+ return [.onYears, .on2Map, .on2Lesson(id), .on2Ex(id)]
+ case .on3Map:
+ return [.onYears, .on3Map]
+ case .on3Lesson(let id):
+ return [.onYears, .on3Map, .on3Lesson(id)]
+ case .on3Ex(let id):
+ return [.onYears, .on3Map, .on3Lesson(id), .on3Ex(id)]
+ case .on4Map:
+ return [.onYears, .on4Map]
+ case .on4Lesson(let id):
+ return [.onYears, .on4Map, .on4Lesson(id)]
+ case .on4Ex(let id):
+ return [.onYears, .on4Map, .on4Lesson(id), .on4Ex(id)]
+ case .enYears:
+ return [.enYears]
+ case .enMap(let year):
+ return [.enYears, .enMap(year)]
+ case .enLesson(let year, let id):
+ return [.enYears, .enMap(year), .enLesson(year, id)]
+ case .enEx(let year, let id):
+ return [.enYears, .enMap(year), .enLesson(year, id), .enEx(year, id)]
+ case .litYears:
+ return [.czechMap, .litYears]
+ case .litMap:
+ return [.czechMap, .litYears, .litMap]
+ case .litLesson(let id):
+ return [.czechMap, .litYears, .litMap, .litLesson(id)]
+ case .litEx(let id):
+ return [.czechMap, .litYears, .litMap, .litLesson(id), .litEx(id)]
+ case .lit2Map:
+ return [.czechMap, .litYears, .lit2Map]
+ case .lit2Lesson(let id):
+ return [.czechMap, .litYears, .lit2Map, .lit2Lesson(id)]
+ case .lit2Ex(let id):
+ return [.czechMap, .litYears, .lit2Map, .lit2Lesson(id), .lit2Ex(id)]
+ case .lit3Map:
+ return [.czechMap, .litYears, .lit3Map]
+ case .lit3Lesson(let id):
+ return [.czechMap, .litYears, .lit3Map, .lit3Lesson(id)]
+ case .lit3Ex(let id):
+ return [.czechMap, .litYears, .lit3Map, .lit3Lesson(id), .lit3Ex(id)]
+ case .lit4Map:
+ return [.czechMap, .litYears, .lit4Map]
+ case .lit4Lesson(let id):
+ return [.czechMap, .litYears, .lit4Map, .lit4Lesson(id)]
+ case .lit4Ex(let id):
+ return [.czechMap, .litYears, .lit4Map, .lit4Lesson(id), .lit4Ex(id)]
+ case .sciMap:
+ return [.sciMap]
+ case .chemMap:
+ return [.sciMap, .chemMap]
+ case .chemLesson(let id):
+ return [.sciMap, .chemMap, .chemLesson(id)]
+ case .chemEx(let id):
+ return [.sciMap, .chemMap, .chemLesson(id), .chemEx(id)]
+ case .bioMap:
+ return [.sciMap, .bioMap]
+ case .bioLesson(let id):
+ return [.sciMap, .bioMap, .bioLesson(id)]
+ case .bioEx(let id):
+ return [.sciMap, .bioMap, .bioLesson(id), .bioEx(id)]
+ case .matYears:
+ return [.matYears]
+ case .mat0Map:
+ return [.matYears, .mat0Map]
+ case .mat0Lesson(let id):
+ return [.matYears, .mat0Map, .mat0Lesson(id)]
+ case .mat0Ex(let id):
+ return [.matYears, .mat0Map, .mat0Lesson(id), .mat0Ex(id)]
+ case .matMap:
+ return [.matYears, .matMap]
+ case .matLesson(let id):
+ return [.matYears, .matMap, .matLesson(id)]
+ case .matEx(let id):
+ return [.matYears, .matMap, .matLesson(id), .matEx(id)]
+ case .mat2Map:
+ return [.matYears, .mat2Map]
+ case .mat2Lesson(let id):
+ return [.matYears, .mat2Map, .mat2Lesson(id)]
+ case .mat2Ex(let id):
+ return [.matYears, .mat2Map, .mat2Lesson(id), .mat2Ex(id)]
+ case .mat3Map:
+ return [.matYears, .mat3Map]
+ case .mat3Lesson(let id):
+ return [.matYears, .mat3Map, .mat3Lesson(id)]
+ case .mat3Ex(let id):
+ return [.matYears, .mat3Map, .mat3Lesson(id), .mat3Ex(id)]
+ case .mat4Map:
+ return [.matYears, .mat4Map]
+ case .mat4Lesson(let id):
+ return [.matYears, .mat4Map, .mat4Lesson(id)]
+ case .mat4Ex(let id):
+ return [.matYears, .mat4Map, .mat4Lesson(id), .mat4Ex(id)]
+ case .fyzYears:
+ return [.fyzYears]
+ case .fyzMap:
+ return [.fyzYears, .fyzMap]
+ case .fyzLesson(let id):
+ return [.fyzYears, .fyzMap, .fyzLesson(id)]
+ case .fyzEx(let id):
+ return [.fyzYears, .fyzMap, .fyzLesson(id), .fyzEx(id)]
+ case .fyz2Map:
+ return [.fyzYears, .fyz2Map]
+ case .fyz2Lesson(let id):
+ return [.fyzYears, .fyz2Map, .fyz2Lesson(id)]
+ case .fyz2Ex(let id):
+ return [.fyzYears, .fyz2Map, .fyz2Lesson(id), .fyz2Ex(id)]
+ case .fyz3Map:
+ return [.fyzYears, .fyz3Map]
+ case .fyz3Lesson(let id):
+ return [.fyzYears, .fyz3Map, .fyz3Lesson(id)]
+ case .fyz3Ex(let id):
+ return [.fyzYears, .fyz3Map, .fyz3Lesson(id), .fyz3Ex(id)]
+ case .fyz4Map:
+ return [.fyzYears, .fyz4Map]
+ case .fyz4Lesson(let id):
+ return [.fyzYears, .fyz4Map, .fyz4Lesson(id)]
+ case .fyz4Ex(let id):
+ return [.fyzYears, .fyz4Map, .fyz4Lesson(id), .fyz4Ex(id)]
+ case .tgYears:
+ return [.tgYears]
+ case .tgMap:
+ return [.tgYears, .tgMap]
+ case .tgLesson(let id):
+ return [.tgYears, .tgMap, .tgLesson(id)]
+ case .tgEx(let id):
+ return [.tgYears, .tgMap, .tgLesson(id), .tgEx(id)]
+ case .tg2Map:
+ return [.tgYears, .tg2Map]
+ case .tg2Lesson(let id):
+ return [.tgYears, .tg2Map, .tg2Lesson(id)]
+ case .tg2Ex(let id):
+ return [.tgYears, .tg2Map, .tg2Lesson(id), .tg2Ex(id)]
+ case .tg3Map:
+ return [.tgYears, .tg3Map]
+ case .tg3Lesson(let id):
+ return [.tgYears, .tg3Map, .tg3Lesson(id)]
+ case .tg3Ex(let id):
+ return [.tgYears, .tg3Map, .tg3Lesson(id), .tg3Ex(id)]
+ case .tg4Map:
+ return [.tgYears, .tg4Map]
+ case .tg4Lesson(let id):
+ return [.tgYears, .tg4Map, .tg4Lesson(id)]
+ case .tg4Ex(let id):
+ return [.tgYears, .tg4Map, .tg4Lesson(id), .tg4Ex(id)]
+ case .pgYears:
+ return [.pgYears]
+ case .pgMap:
+ return [.pgYears, .pgMap]
+ case .pgLesson(let id):
+ return [.pgYears, .pgMap, .pgLesson(id)]
+ case .pgEx(let id):
+ return [.pgYears, .pgMap, .pgLesson(id), .pgEx(id)]
+ case .pg2Map:
+ return [.pgYears, .pg2Map]
+ case .pg2Lesson(let id):
+ return [.pgYears, .pg2Map, .pg2Lesson(id)]
+ case .pg2Ex(let id):
+ return [.pgYears, .pg2Map, .pg2Lesson(id), .pg2Ex(id)]
+ case .pg3Map:
+ return [.pgYears, .pg3Map]
+ case .pg3Lesson(let id):
+ return [.pgYears, .pg3Map, .pg3Lesson(id)]
+ case .pg3Ex(let id):
+ return [.pgYears, .pg3Map, .pg3Lesson(id), .pg3Ex(id)]
+ case .pg4Map:
+ return [.pgYears, .pg4Map]
+ case .pg4Lesson(let id):
+ return [.pgYears, .pg4Map, .pg4Lesson(id)]
+ case .pg4Ex(let id):
+ return [.pgYears, .pg4Map, .pg4Lesson(id), .pg4Ex(id)]
+ case .swYears:
+ return [.swYears]
+ case .swMap:
+ return [.swYears, .swMap]
+ case .swLesson(let id):
+ return [.swYears, .swMap, .swLesson(id)]
+ case .swEx(let id):
+ return [.swYears, .swMap, .swLesson(id), .swEx(id)]
+ case .sw2Map:
+ return [.swYears, .sw2Map]
+ case .sw2Lesson(let id):
+ return [.swYears, .sw2Map, .sw2Lesson(id)]
+ case .sw2Ex(let id):
+ return [.swYears, .sw2Map, .sw2Lesson(id), .sw2Ex(id)]
+ case .sw3Map:
+ return [.swYears, .sw3Map]
+ case .sw3Lesson(let id):
+ return [.swYears, .sw3Map, .sw3Lesson(id)]
+ case .sw3Ex(let id):
+ return [.swYears, .sw3Map, .sw3Lesson(id), .sw3Ex(id)]
+ case .sw4Map:
+ return [.swYears, .sw4Map]
+ case .sw4Lesson(let id):
+ return [.swYears, .sw4Map, .sw4Lesson(id)]
+ case .sw4Ex(let id):
+ return [.swYears, .sw4Map, .sw4Lesson(id), .sw4Ex(id)]
+ case .csYears:
+ return [.csYears]
+ case .csMap:
+ return [.csYears, .csMap]
+ case .csLesson(let id):
+ return [.csYears, .csMap, .csLesson(id)]
+ case .cs2Map:
+ return [.csYears, .cs2Map]
+ case .cs2Lesson(let id):
+ return [.csYears, .cs2Map, .cs2Lesson(id)]
+ case .cs3Map:
+ return [.csYears, .cs3Map]
+ case .cs3Lesson(let id):
+ return [.csYears, .cs3Map, .cs3Lesson(id)]
+ case .cs4Map:
+ return [.csYears, .cs4Map]
+ case .cs4Lesson(let id):
+ return [.csYears, .cs4Map, .cs4Lesson(id)]
+ case .czechMap:
+ return [.czechMap]
+ case .mluvnice:
+ return [.czechMap, .mluvnice]
+ case .mluvEx(let n):
+ return [.czechMap, .mluvnice, .mluvEx(n)]
+ case .readingList:
+ return [.czechMap, .readingList]
+ case .book(let id):
+ return [.czechMap, .readingList, .book(id)]
+ case .bookQuiz(let id):
+ return [.czechMap, .readingList, .book(id), .bookQuiz(id)]
+ case .bookPlot(let id):
+ return [.czechMap, .readingList, .book(id), .bookPlot(id)]
+ }
+ }
+}
+
+func routeFromPage(_ page: String) -> Route? {
+ switch page {
+ case "welcome": return .welcome
+ case "subjects": return .subjects
+ case "stats": return .stats
+ case "roadmap": return .roadmap
+ case "dehome": return .deHome
+ case "netyears": return .netYears
+ case "netmap": return .netMap
+ case "net2map": return .net2Map
+ case "net3map": return .net3Map
+ case "net4map": return .net4Map
+ case "hw2map": return .hw2Map
+ case "hw3map": return .hw3Map
+ case "hw4map": return .hw4Map
+ case "hwyears": return .hwYears
+ case "hwmap": return .hwMap
+ case "onyears": return .onYears
+ case "onmap": return .onMap
+ case "on2map": return .on2Map
+ case "on3map": return .on3Map
+ case "on4map": return .on4Map
+ case "enyears": return .enYears
+ case "lityears": return .litYears
+ case "litmap": return .litMap
+ case "lit2map": return .lit2Map
+ case "lit3map": return .lit3Map
+ case "lit4map": return .lit4Map
+ case "scimap": return .sciMap
+ case "chemmap": return .chemMap
+ case "biomap": return .bioMap
+ case "matyears": return .matYears
+ case "mat0map": return .mat0Map
+ case "matmap": return .matMap
+ case "mat2map": return .mat2Map
+ case "mat3map": return .mat3Map
+ case "mat4map": return .mat4Map
+ case "fyzyears": return .fyzYears
+ case "fyzmap": return .fyzMap
+ case "fyz2map": return .fyz2Map
+ case "fyz3map": return .fyz3Map
+ case "fyz4map": return .fyz4Map
+ case "tgyears": return .tgYears
+ case "tgmap": return .tgMap
+ case "tg2map": return .tg2Map
+ case "tg3map": return .tg3Map
+ case "tg4map": return .tg4Map
+ case "pgyears": return .pgYears
+ case "pgmap": return .pgMap
+ case "pg2map": return .pg2Map
+ case "pg3map": return .pg3Map
+ case "pg4map": return .pg4Map
+ case "swyears": return .swYears
+ case "swmap": return .swMap
+ case "sw2map": return .sw2Map
+ case "sw3map": return .sw3Map
+ case "sw4map": return .sw4Map
+ case "csyears": return .csYears
+ case "csmap": return .csMap
+ case "cs2map": return .cs2Map
+ case "cs3map": return .cs3Map
+ case "cs4map": return .cs4Map
+ case "czechmap": return .czechMap
+ case "mluvnice": return .mluvnice
+ case "readinglist": return .readingList
+ case "cetba1984": return .book("1984")
+ case "cetba1984quiz": return .bookQuiz("1984")
+ case "cetba1984dej": return .bookPlot("1984")
+ case "cetbaFuks": return .book("fuks")
+ case "cetbaFuksQuiz": return .bookQuiz("fuks")
+ case "cetbaFuksDej": return .bookPlot("fuks")
+ default:
+ if page.hasPrefix("cetba_"), page.hasSuffix("_quiz") {
+ let slug = String(page.dropFirst(6).dropLast(5)).replacingOccurrences(of: "_", with: "-")
+ return .bookQuiz(slug)
+ }
+ if page.hasPrefix("cetba_"), page.hasSuffix("_dej") {
+ let slug = String(page.dropFirst(6).dropLast(4)).replacingOccurrences(of: "_", with: "-")
+ return .bookPlot(slug)
+ }
+ if page.hasPrefix("cetba_") {
+ let slug = String(page.dropFirst(6)).replacingOccurrences(of: "_", with: "-")
+ return .book(slug)
+ }
+ if let m = page.wholeMatch(of: /unit(\d+)/), let n = Int(m.1) {
+ return .unitMap(n - 1)
+ }
+ if let m = page.wholeMatch(of: /u(\d+)e(\d+)/), let u = Int(m.1), let e = Int(m.2) {
+ return .germanEx(u - 1, e)
+ }
+ if let m = page.wholeMatch(of: /u(\d+)vocab/), let u = Int(m.1) {
+ return .vocab(u - 1)
+ }
+ if let m = page.wholeMatch(of: /netunit(\d+)/), let n = Int(m.1) {
+ return .netLesson(n)
+ }
+ if let m = page.wholeMatch(of: /netex(\d+)/), let n = Int(m.1) {
+ return .netEx(n)
+ }
+ if let m = page.wholeMatch(of: /hwunit(\d+)/), let n = Int(m.1) {
+ return .hwLesson(n)
+ }
+ if let m = page.wholeMatch(of: /hwex(\d+)/), let n = Int(m.1) {
+ return .hwEx(n)
+ }
+ if let m = page.wholeMatch(of: /chemunit(\d+)/), let n = Int(m.1) {
+ return .chemLesson(n)
+ }
+ if let m = page.wholeMatch(of: /chemex(\d+)/), let n = Int(m.1) {
+ return .chemEx(n)
+ }
+ if let m = page.wholeMatch(of: /biounit(\d+)/), let n = Int(m.1) {
+ return .bioLesson(n)
+ }
+ if let m = page.wholeMatch(of: /bioex(\d+)/), let n = Int(m.1) {
+ return .bioEx(n)
+ }
+ if let m = page.wholeMatch(of: /mat0unit(\d+)/), let n = Int(m.1) {
+ return .mat0Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /mat0ex(\d+)/), let n = Int(m.1) {
+ return .mat0Ex(n)
+ }
+ if let m = page.wholeMatch(of: /mat4unit(\d+)/), let n = Int(m.1) {
+ return .mat4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /mat4ex(\d+)/), let n = Int(m.1) {
+ return .mat4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /mat3unit(\d+)/), let n = Int(m.1) {
+ return .mat3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /mat3ex(\d+)/), let n = Int(m.1) {
+ return .mat3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /mat2unit(\d+)/), let n = Int(m.1) {
+ return .mat2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /mat2ex(\d+)/), let n = Int(m.1) {
+ return .mat2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /matunit(\d+)/), let n = Int(m.1) {
+ return .matLesson(n)
+ }
+ if let m = page.wholeMatch(of: /matex(\d+)/), let n = Int(m.1) {
+ return .matEx(n)
+ }
+ if let m = page.wholeMatch(of: /fyz4unit(\d+)/), let n = Int(m.1) {
+ return .fyz4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /fyz4ex(\d+)/), let n = Int(m.1) {
+ return .fyz4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /fyz3unit(\d+)/), let n = Int(m.1) {
+ return .fyz3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /fyz3ex(\d+)/), let n = Int(m.1) {
+ return .fyz3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /fyz2unit(\d+)/), let n = Int(m.1) {
+ return .fyz2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /fyz2ex(\d+)/), let n = Int(m.1) {
+ return .fyz2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /fyzunit(\d+)/), let n = Int(m.1) {
+ return .fyzLesson(n)
+ }
+ if let m = page.wholeMatch(of: /fyzex(\d+)/), let n = Int(m.1) {
+ return .fyzEx(n)
+ }
+ if let m = page.wholeMatch(of: /sw4unit(\d+)/), let n = Int(m.1) {
+ return .sw4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /sw4ex(\d+)/), let n = Int(m.1) {
+ return .sw4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /sw3unit(\d+)/), let n = Int(m.1) {
+ return .sw3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /sw3ex(\d+)/), let n = Int(m.1) {
+ return .sw3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /sw2unit(\d+)/), let n = Int(m.1) {
+ return .sw2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /sw2ex(\d+)/), let n = Int(m.1) {
+ return .sw2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /swunit(\d+)/), let n = Int(m.1) {
+ return .swLesson(n)
+ }
+ if let m = page.wholeMatch(of: /swex(\d+)/), let n = Int(m.1) {
+ return .swEx(n)
+ }
+ if let m = page.wholeMatch(of: /pg4unit(\d+)/), let n = Int(m.1) {
+ return .pg4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /pg4ex(\d+)/), let n = Int(m.1) {
+ return .pg4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /pg3unit(\d+)/), let n = Int(m.1) {
+ return .pg3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /pg3ex(\d+)/), let n = Int(m.1) {
+ return .pg3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /pg2unit(\d+)/), let n = Int(m.1) {
+ return .pg2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /pg2ex(\d+)/), let n = Int(m.1) {
+ return .pg2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /pgunit(\d+)/), let n = Int(m.1) {
+ return .pgLesson(n)
+ }
+ if let m = page.wholeMatch(of: /pgex(\d+)/), let n = Int(m.1) {
+ return .pgEx(n)
+ }
+ if let m = page.wholeMatch(of: /tg4unit(\d+)/), let n = Int(m.1) {
+ return .tg4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /tg4ex(\d+)/), let n = Int(m.1) {
+ return .tg4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /tg3unit(\d+)/), let n = Int(m.1) {
+ return .tg3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /tg3ex(\d+)/), let n = Int(m.1) {
+ return .tg3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /tg2unit(\d+)/), let n = Int(m.1) {
+ return .tg2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /tg2ex(\d+)/), let n = Int(m.1) {
+ return .tg2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /tgunit(\d+)/), let n = Int(m.1) {
+ return .tgLesson(n)
+ }
+ if let m = page.wholeMatch(of: /tgex(\d+)/), let n = Int(m.1) {
+ return .tgEx(n)
+ }
+ if let m = page.wholeMatch(of: /cs4unit(\d+)/), let n = Int(m.1) {
+ return .cs4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /cs3unit(\d+)/), let n = Int(m.1) {
+ return .cs3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /cs2unit(\d+)/), let n = Int(m.1) {
+ return .cs2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /csunit(\d+)/), let n = Int(m.1) {
+ return .csLesson(n)
+ }
+ if let m = page.wholeMatch(of: /lit4unit(\d+)/), let n = Int(m.1) {
+ return .lit4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /lit4ex(\d+)/), let n = Int(m.1) {
+ return .lit4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /lit3unit(\d+)/), let n = Int(m.1) {
+ return .lit3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /lit3ex(\d+)/), let n = Int(m.1) {
+ return .lit3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /lit2unit(\d+)/), let n = Int(m.1) {
+ return .lit2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /lit2ex(\d+)/), let n = Int(m.1) {
+ return .lit2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /litunit(\d+)/), let n = Int(m.1) {
+ return .litLesson(n)
+ }
+ if let m = page.wholeMatch(of: /litex(\d+)/), let n = Int(m.1) {
+ return .litEx(n)
+ }
+ if let m = page.wholeMatch(of: /de(\d)map/), let y = Int(m.1) {
+ return .deMap(y)
+ }
+ if let m = page.wholeMatch(of: /de(\d)unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ return .deLesson(y, n)
+ }
+ if let m = page.wholeMatch(of: /de(\d)ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ return .deEx(y, n)
+ }
+ if let m = page.wholeMatch(of: /net([234])unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ switch y {
+ case 3: return .net3Lesson(n)
+ case 4: return .net4Lesson(n)
+ default: return .net2Lesson(n)
+ }
+ }
+ if let m = page.wholeMatch(of: /net([234])ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ switch y {
+ case 3: return .net3Ex(n)
+ case 4: return .net4Ex(n)
+ default: return .net2Ex(n)
+ }
+ }
+ if let m = page.wholeMatch(of: /hw([234])unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ switch y {
+ case 3: return .hw3Lesson(n)
+ case 4: return .hw4Lesson(n)
+ default: return .hw2Lesson(n)
+ }
+ }
+ if let m = page.wholeMatch(of: /hw([234])ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ switch y {
+ case 3: return .hw3Ex(n)
+ case 4: return .hw4Ex(n)
+ default: return .hw2Ex(n)
+ }
+ }
+ if let m = page.wholeMatch(of: /en(\d)map/), let y = Int(m.1) {
+ return .enMap(y)
+ }
+ if let m = page.wholeMatch(of: /en(\d)unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ return .enLesson(y, n)
+ }
+ if let m = page.wholeMatch(of: /en(\d)ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
+ return .enEx(y, n)
+ }
+ if let m = page.wholeMatch(of: /on4unit(\d+)/), let n = Int(m.1) {
+ return .on4Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /on4ex(\d+)/), let n = Int(m.1) {
+ return .on4Ex(n)
+ }
+ if let m = page.wholeMatch(of: /on3unit(\d+)/), let n = Int(m.1) {
+ return .on3Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /on3ex(\d+)/), let n = Int(m.1) {
+ return .on3Ex(n)
+ }
+ if let m = page.wholeMatch(of: /on2unit(\d+)/), let n = Int(m.1) {
+ return .on2Lesson(n)
+ }
+ if let m = page.wholeMatch(of: /on2ex(\d+)/), let n = Int(m.1) {
+ return .on2Ex(n)
+ }
+ if let m = page.wholeMatch(of: /onunit(\d+)/), let n = Int(m.1) {
+ return .onLesson(n)
+ }
+ if let m = page.wholeMatch(of: /onex(\d+)/), let n = Int(m.1) {
+ return .onEx(n)
+ }
+ if let m = page.wholeMatch(of: /mluve(\d+)/), let n = Int(m.1) {
+ return .mluvEx(n)
+ }
+ return nil
+ }
+}
diff --git a/ios/Graduately/UI/Components.swift b/ios/Graduately/UI/Components.swift
new file mode 100644
index 0000000..e0aaf2a
--- /dev/null
+++ b/ios/Graduately/UI/Components.swift
@@ -0,0 +1,741 @@
+import SwiftUI
+
+enum Motion {
+ static let press = Animation.easeOut(duration: 0.16)
+ static let settle = Animation.easeOut(duration: 0.28)
+ static let feedback = Animation.spring(response: 0.32, dampingFraction: 0.82)
+}
+
+/// Slight press-in for custom cards and map nodes. System buttons keep their own style.
+struct PressScaleStyle: ButtonStyle {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(reduceMotion || !configuration.isPressed ? 1 : 0.98)
+ .animation(reduceMotion ? nil : Motion.press, value: configuration.isPressed)
+ }
+}
+
+/// Named so it does not collide with the app's `Content` type, which would
+/// otherwise hide `ViewModifier.Content` and fail the build.
+private struct SoftAppear: View {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var shown = false
+ var inner: Inner
+
+ var body: some View {
+ inner
+ .opacity(reduceMotion || shown ? 1 : 0)
+ .offset(y: reduceMotion || shown ? 0 : 8)
+ .onAppear {
+ guard !reduceMotion else {
+ shown = true
+ return
+ }
+ withAnimation(Motion.settle) { shown = true }
+ }
+ }
+}
+
+extension View {
+ /// One short fade-in. Use on a landing surface, not on every pushed screen.
+ func softAppear() -> some View {
+ SoftAppear(inner: self)
+ }
+}
+
+#if os(macOS)
+enum MacChrome {
+ static let pageInset: CGFloat = 28
+ static let cardPadding: CGFloat = 22
+ static let listRowMinHeight: CGFloat = 52
+ static let homeTileMin: CGFloat = 260
+ static let homeTileHeight: CGFloat = 172
+ static let pathNode: CGFloat = 84
+ /// Pages stop growing here so cards and text keep their shape on wide displays.
+ static let contentWidth: CGFloat = 1240
+ static let readableWidth: CGFloat = 860
+ static let actionWidth: CGFloat = 520
+}
+
+/// Fixed-height cards in as many columns as fit, centred and capped in width.
+struct MacFillGrid: View {
+ var count: Int
+ var minWidth: CGFloat = 240
+ var minHeight: CGFloat = 148
+ var spacing: CGFloat = 16
+ var inset: CGFloat = 28
+ var maxTileWidth: CGFloat = 340
+ @ViewBuilder var content: (Int) -> Content
+
+ var body: some View {
+ GeometryReader { geo in
+ let available = max(geo.size.width - inset * 2, minWidth)
+ let fit = max(1, Int((available + spacing) / (minWidth + spacing)))
+ let cols = max(1, min(count, fit))
+ let width = min(available, CGFloat(cols) * maxTileWidth + CGFloat(cols - 1) * spacing)
+ ScrollView {
+ LazyVGrid(
+ columns: Array(repeating: GridItem(.flexible(), spacing: spacing, alignment: .top), count: cols),
+ alignment: .leading,
+ spacing: spacing
+ ) {
+ ForEach(0.. 0 {
+ HStack(spacing: 10) {
+ ProgressBar(value: Double(progress.done) / Double(progress.total))
+ Text("\(progress.done) / \(progress.total)")
+ .font(.subheadline.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .softSurface(cornerRadius: 20)
+ .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ }
+}
+#endif
+
+/// Flat capsule bar. Unlike ProgressView it shows nothing at zero.
+struct ProgressBar: View {
+ let value: Double
+ var height: CGFloat = 8
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.08))
+ if value > 0 {
+ Capsule()
+ .fill(Color.accentColor)
+ .frame(width: max(height, geo.size.width * min(value, 1)))
+ }
+ }
+ }
+ .frame(height: height)
+ .animation(Motion.settle, value: value)
+ }
+}
+
+struct ProgressRing: View {
+ let value: Double
+ var lineWidth: CGFloat = 12
+ @ViewBuilder var label: () -> Label
+
+ var body: some View {
+ ZStack {
+ Circle().stroke(Color.primary.opacity(0.08), lineWidth: lineWidth)
+ if value > 0 {
+ Circle()
+ .trim(from: 0, to: min(value, 1))
+ .stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+ label()
+ }
+ .animation(Motion.settle, value: value)
+ }
+}
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 6
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ arrange(width: proposal.width ?? .infinity, subviews: subviews).size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = arrange(width: bounds.width, subviews: subviews)
+ for (i, frame) in result.frames.enumerated() {
+ subviews[i].place(
+ at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
+ proposal: ProposedViewSize(frame.size)
+ )
+ }
+ }
+
+ private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
+ var frames: [CGRect] = []
+ var x: CGFloat = 0
+ var y: CGFloat = 0
+ var rowH: CGFloat = 0
+ var maxW: CGFloat = 0
+ for view in subviews {
+ let size = view.sizeThatFits(.unspecified)
+ if x > 0 && x + size.width > width {
+ x = 0
+ y += rowH + spacing
+ rowH = 0
+ }
+ frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
+ x += size.width + spacing
+ rowH = max(rowH, size.height)
+ maxW = max(maxW, x - spacing)
+ }
+ return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
+ }
+}
+
+struct PrimaryButton: View {
+ let label: String
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(label)
+ .font(.body.weight(.semibold))
+ .frame(maxWidth: .infinity)
+ #if os(macOS)
+ .padding(.vertical, 4)
+ #endif
+ }
+ #if os(macOS)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.extraLarge)
+ #else
+ .buttonStyle(.glassProminent)
+ .controlSize(.large)
+ #endif
+ }
+}
+
+struct PillButton: View {
+ let label: String
+ var action: () -> Void
+
+ var body: some View {
+ Button(label, action: action)
+ .buttonStyle(.bordered)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+}
+
+struct GlassCard: View {
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ content()
+ #if os(macOS)
+ .padding(MacChrome.cardPadding)
+ #else
+ .padding(16)
+ #endif
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .softSurface(cornerRadius: 22)
+ }
+}
+
+extension View {
+ /// Soft card surface without Liquid Glass refraction (which reads as a mirror
+ /// on large macOS panels over the wallpaper / sidebar extension).
+ func softSurface(cornerRadius: CGFloat) -> some View {
+ background {
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .fill(.background.secondary)
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .strokeBorder(.separator.opacity(0.35), lineWidth: 0.5)
+ }
+ }
+}
+
+struct FeedbackLine: View {
+ let text: String
+ let kind: String
+ let palette: Palette
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ Group {
+ if !text.isEmpty {
+ Label {
+ Text(text)
+ } icon: {
+ Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
+ }
+ #if os(macOS)
+ .font(.body.weight(.semibold))
+ #else
+ .font(.subheadline.weight(.semibold))
+ #endif
+ .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
+ .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
+ }
+ }
+ .animation(reduceMotion ? nil : Motion.feedback, value: text)
+ .animation(reduceMotion ? nil : Motion.feedback, value: kind)
+ }
+}
+
+struct Meaning: View {
+ let text: String
+ let palette: Palette
+ let visible: Bool
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ Group {
+ if visible && !text.isEmpty {
+ Text(text)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.footnote)
+ #endif
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+ .transition(.opacity)
+ }
+ }
+ .animation(reduceMotion ? nil : Motion.settle, value: visible)
+ }
+}
+
+struct WordField: View {
+ @Binding var value: String
+ var placeholder: String
+ var mark: Bool?
+ var palette: Palette?
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ HStack(spacing: 8) {
+ TextField(placeholder, text: $value)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ if let mark {
+ Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
+ }
+ }
+ .padding(.horizontal, 12)
+ #if os(macOS)
+ .padding(.vertical, 14)
+ #else
+ .padding(.vertical, 10)
+ #endif
+ .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
+ )
+ .animation(reduceMotion ? nil : Motion.settle, value: mark)
+ }
+
+ private var border: Color {
+ if mark == true { return palette?.success ?? .green }
+ if mark == false { return palette?.error ?? .red }
+ return Color(.separator)
+ }
+}
+
+struct SegChip: View {
+ let label: String
+ let selected: Bool
+ var action: () -> Void
+
+ var body: some View {
+ Button(label, action: action)
+ .buttonStyle(.bordered)
+ .tint(selected ? .accentColor : .secondary)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+}
+
+struct Hint: View {
+ let text: String
+ var body: some View {
+ Text(text)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.footnote)
+ #endif
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 6)
+ }
+}
+
+struct Prompt: View {
+ let text: String
+ var body: some View {
+ Text(text)
+ #if os(macOS)
+ .font(.title3.weight(.medium))
+ #else
+ .font(.body.weight(.medium))
+ #endif
+ }
+}
+
+struct WordChip: View {
+ let text: String
+ var action: () -> Void
+
+ var body: some View {
+ Button(text, action: action)
+ .buttonStyle(.bordered)
+ .tint(.primary)
+ #if os(macOS)
+ .controlSize(.large)
+ #else
+ .controlSize(.small)
+ #endif
+ }
+}
+
+/// Drop zone for word ordering: visible even when empty.
+struct WordSlot: View {
+ let empty: Bool
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ content()
+ #if os(macOS)
+ .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading)
+ .padding(12)
+ #else
+ .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
+ .padding(8)
+ #endif
+ .background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4]))
+ .foregroundStyle(.tertiary)
+ )
+ }
+}
+
+struct OptionChip: View {
+ let text: String
+ let selected: Bool
+ let mark: Bool?
+ var action: () -> Void
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 10) {
+ Image(systemName: symbol)
+ .font(.title3)
+ .foregroundStyle(symbolColor)
+ Text(text)
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ #if os(macOS)
+ .padding(.vertical, 8)
+ #else
+ .padding(.vertical, 4)
+ #endif
+ }
+ .buttonStyle(.bordered)
+ .tint(tint)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ .padding(.bottom, 4)
+ .animation(reduceMotion ? nil : Motion.settle, value: mark)
+ .animation(reduceMotion ? nil : Motion.press, value: selected)
+ }
+
+ private var symbol: String {
+ if mark == true { return "checkmark.circle.fill" }
+ if mark == false { return "xmark.circle.fill" }
+ return selected ? "largecircle.fill.circle" : "circle"
+ }
+
+ private var symbolColor: Color {
+ if mark == true { return .green }
+ if mark == false { return .red }
+ return selected ? .accentColor : .secondary
+ }
+
+ private var tint: Color {
+ if mark == true { return .green }
+ if mark == false { return .red }
+ return selected ? .accentColor : .secondary
+ }
+}
+
+extension View {
+ @ViewBuilder
+ func navSubtitle(_ text: String?) -> some View {
+ if let text, !text.isEmpty {
+ navigationSubtitle(text)
+ } else {
+ self
+ }
+ }
+
+ /// Detail screens with their own bottom action hide the tab bar so the
+ /// primary button is never covered by it.
+ @ViewBuilder
+ func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
+ #if os(iOS)
+ navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .navSubtitle(subtitle)
+ .toolbar(.hidden, for: .tabBar)
+ #else
+ navigationTitle(title)
+ .navSubtitle(subtitle)
+ #endif
+ }
+
+ @ViewBuilder
+ func sheetDetents() -> some View {
+ #if os(iOS)
+ presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ // Liquid Glass sheets refract the exercise chrome underneath into a
+ // bright tinted band across the word list; keep the sheet opaque.
+ .presentationBackground(.background)
+ #else
+ self
+ #endif
+ }
+
+ /// Leading-aligned content for exercise screens; on the Mac a centred readable column.
+ func exerciseContent() -> some View {
+ #if os(macOS)
+ frame(maxWidth: MacChrome.readableWidth, alignment: .leading)
+ .padding(.horizontal, MacChrome.pageInset)
+ .padding(.vertical, 28)
+ .frame(maxWidth: .infinity)
+ #else
+ frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ #endif
+ }
+
+ /// Caps a Mac page at [width] and centres it; no-op on iOS.
+ @ViewBuilder
+ func macPage(_ width: CGFloat? = nil) -> some View {
+ #if os(macOS)
+ frame(maxWidth: width ?? MacChrome.contentWidth)
+ .frame(maxWidth: .infinity)
+ #else
+ self
+ #endif
+ }
+
+ /// macOS content lists use the inset style so they sit under the floating
+ /// glass sidebar instead of looking like a second source list.
+ @ViewBuilder
+ func appListStyle() -> some View {
+ #if os(macOS)
+ listStyle(.inset)
+ .environment(\.defaultMinListRowHeight, MacChrome.listRowMinHeight)
+ .scrollContentBackground(.hidden)
+ .padding(.horizontal, 8)
+ #else
+ self
+ #endif
+ }
+
+ @ViewBuilder
+ func appFormStyle() -> some View {
+ #if os(macOS)
+ formStyle(.grouped)
+ .scenePadding()
+ #else
+ self
+ #endif
+ }
+
+ /// Pin a primary action above scrolling content using the system scroll
+ /// edge effect instead of an opaque bar.
+ func glassBottomBar(@ViewBuilder _ bar: () -> Bar) -> some View {
+ safeAreaBar(edge: .bottom, spacing: 8, content: bar)
+ }
+}
+
+/// Bottom action area used by exercises and lesson slides.
+struct BottomAction: View {
+ let label: String
+ var action: () -> Void
+ @ViewBuilder var extra: () -> Extra
+
+ var body: some View {
+ VStack(spacing: 10) {
+ extra()
+ PrimaryButton(label: label, action: action)
+ }
+ .padding(.horizontal, {
+ #if os(macOS)
+ MacChrome.pageInset
+ #else
+ 16
+ #endif
+ }())
+ #if os(macOS)
+ .padding(.top, 12)
+ .padding(.bottom, 14)
+ .frame(maxWidth: MacChrome.actionWidth)
+ .frame(maxWidth: .infinity)
+ #else
+ .padding(.top, 8)
+ .padding(.bottom, 4)
+ #endif
+ }
+}
+
+/// Primary + secondary text sized for desktop lists, with optional exercise progress.
+struct ListRowLabel: View {
+ let title: String
+ var subtitle: String? = nil
+ var progress: (done: Int, total: Int)? = nil
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title)
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.caption)
+ #endif
+ .foregroundStyle(.secondary)
+ }
+ if let progress, progress.total > 0 {
+ ProgressLine(done: progress.done, total: progress.total)
+ .padding(.top, 4)
+ }
+ }
+ }
+}
+
+/// Slim bar with "done / total" beside it, for list rows.
+struct ProgressLine: View {
+ let done: Int
+ let total: Int
+
+ var body: some View {
+ HStack(spacing: 8) {
+ ProgressBar(value: total == 0 ? 0 : Double(done) / Double(total), height: 5)
+ Text("\(done) / \(total)")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+func subjectSymbol(_ icon: String) -> String {
+ switch icon {
+ case "de": return "character.book.closed.fill"
+ case "wifi": return "wifi"
+ case "chip": return "cpu"
+ case "cz": return "text.book.closed.fill"
+ case "people": return "person.2.fill"
+ case "flask": return "flask.fill"
+ case "bolt": return "bolt.fill"
+ case "function": return "function"
+ case "ruler": return "ruler.fill"
+ case "code": return "chevron.left.forwardslash.chevron.right"
+ case "brush": return "paintbrush.fill"
+ case "apps": return "square.grid.2x2.fill"
+ default: return "lock.fill"
+ }
+}
+
+/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise.
+struct SubjectIcon: View {
+ let icon: String
+ var open = true
+ var size: CGFloat = 26
+
+ var body: some View {
+ Group {
+ switch icon {
+ case "de": GermanFlag(size: size)
+ case "cz": CzechFlag(size: size)
+ case "uk": UkFlag(size: size)
+ default:
+ Image(systemName: subjectSymbol(icon))
+ .font(.system(size: size * 0.72, weight: .semibold))
+ .foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
+ }
+ }
+ .frame(width: size, height: size)
+ .opacity(open ? 1 : 0.6)
+ }
+}
diff --git a/ios/Graduately/UI/Exercises.swift b/ios/Graduately/UI/Exercises.swift
new file mode 100644
index 0000000..fb4ccea
--- /dev/null
+++ b/ios/Graduately/UI/Exercises.swift
@@ -0,0 +1,668 @@
+import SwiftUI
+
+struct ExerciseScaffold: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let action: String
+ var onAction: () -> Void
+ let feedback: String
+ let kind: String
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) { content() }
+ .exerciseContent()
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .detailScreen(title, subtitle: sub)
+ .glassBottomBar {
+ BottomAction(label: action, action: onAction) {
+ if !feedback.isEmpty {
+ FeedbackLine(text: feedback, kind: kind, palette: p)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
+ }
+ }
+ }
+ .animation(Motion.feedback, value: feedback)
+ }
+}
+
+struct GermanExercise: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+ let ex: Int
+
+ var body: some View {
+ if let spec = vm.content.germanUnit(unitId)?.exercise(ex) {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
+ vm.markGerman(unitId, ex)
+ }
+ }
+ }
+}
+
+struct VocabExercise: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+
+ var body: some View {
+ if let spec = vm.content.germanUnit(unitId)?.obj("vocab") {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) {
+ vm.markVocab(unitId)
+ }
+ }
+ }
+}
+
+struct MluvExercise: View {
+ @ObservedObject var vm: AppModel
+ let n: Int
+
+ var body: some View {
+ if let spec = vm.content.mluv(n) {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
+ vm.markMluv(n)
+ }
+ }
+ }
+}
+
+struct DispatchExercise: View {
+ @ObservedObject var vm: AppModel
+ let spec: J
+ let titleFallback: String
+ var onDone: () -> Void
+
+ var body: some View {
+ let title = spec.str("title").isEmpty ? titleFallback : spec.str("title")
+ let sub = spec.strOrNull("sub").map { vm.tr($0) }
+ switch spec.str("type") {
+ case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill":
+ ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ default: Text("?").foregroundStyle(.primary)
+ }
+ }
+}
+
+private struct ChoiceEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var picks: [Int]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count))
+ }
+
+ var body: some View {
+ let qs = spec.arr("questions")
+ let meanings = spec.strs("meanings")
+ let expls = spec.strs("expls")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count
+ if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry_short"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ ForEach(qs.indices, id: \.self) { i in
+ Text("\(i + 1). \(qs[i].str("prompt"))")
+ .font(.body.weight(.semibold))
+ .padding(.bottom, 6)
+ choiceOptions(qs[i], index: i)
+ if revealed {
+ if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
+ if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) }
+ if let expl = qs[i].strOrNull("expl") {
+ Meaning(text: expl, palette: p, visible: true)
+ }
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func choiceOptions(_ question: J, index: Int) -> some View {
+ let options = question.strs("options")
+ #if os(macOS)
+ LazyVGrid(
+ columns: [
+ GridItem(.flexible(), spacing: 12, alignment: .top),
+ GridItem(.flexible(), spacing: 12, alignment: .top),
+ ],
+ spacing: 8
+ ) {
+ optionChips(options, index: index, correct: question.int("correct"))
+ }
+ #else
+ optionChips(options, index: index, correct: question.int("correct"))
+ #endif
+ }
+
+ @ViewBuilder
+ private func optionChips(_ options: [String], index: Int, correct: Int) -> some View {
+ ForEach(Array(options.enumerated()), id: \.offset) { o, opt in
+ let sel = picks[index] == o
+ let mark: Bool? = revealed && sel ? picks[index] == correct : nil
+ OptionChip(text: opt, selected: sel, mark: mark) { picks[index] = o }
+ }
+ }
+}
+
+private struct AssemblyEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var placed: [[Int]]
+ @State private var pools: [[Int]]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let items = spec.arr("items")
+ _placed = State(initialValue: items.map { _ in [] })
+ _pools = State(initialValue: items.map { item in
+ Array(0.. Void
+ @State private var fields: [[String]]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
+ _fields = State(initialValue: items.map { q in
+ Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count))
+ })
+ }
+
+ var body: some View {
+ let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
+ let p = vm.palette
+ let umlaut = items.contains { q in
+ q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt"))
+ }
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var ok = 0, total = 0, incomplete = false
+ for (i, q) in items.enumerated() {
+ let answers = q.answerList()
+ for (j, value) in fields[i].enumerated() {
+ total += 1
+ let ans = j < answers.count ? answers[j] : ""
+ let norm = normalizeAnswer(value)
+ if answerAccepts(norm, ans) { ok += 1 }
+ else if answerIncomplete(norm, ans) { incomplete = true }
+ }
+ }
+ if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ if let note = spec.strOrNull("note") { Hint(text: note) }
+ if let sample = spec.strOrNull("sample") { Hint(text: sample) }
+ if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)") }
+ if umlaut { Hint(text: vm.tr("hint_umlauts")) }
+ ForEach(items.indices, id: \.self) { i in
+ let q = items[i]
+ Prompt(text: q.str("prompt"))
+ let answers = q.answerList()
+ let hints = q.strs("hints")
+ ForEach(fields[i].indices, id: \.self) { j in
+ let ans = j < answers.count ? answers[j] : ""
+ WordField(
+ value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
+ placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
+ mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil,
+ palette: p
+ )
+ .padding(.bottom, 4)
+ }
+ if revealed {
+ if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) }
+ if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) }
+ if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) }
+ if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) }
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+}
+
+private struct FreeEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var shown: [Bool]
+ @State private var values: [String]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
+ _shown = State(initialValue: Array(repeating: false, count: qs.count))
+ _values = State(initialValue: Array(repeating: "", count: qs.count))
+ }
+
+ var body: some View {
+ let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
+ fb = (vm.tr("feedback_ok"), "ok")
+ shown = shown.map { _ in true }
+ onDone()
+ }, feedback: fb.0, kind: fb.1) {
+ if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip)) }
+ ForEach(qs.indices, id: \.self) { i in
+ let q = qs[i]
+ Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"))
+ WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), placeholder: vm.tr("your_answer"), palette: p)
+ PillButton(label: vm.tr("show_sample")) { shown[i].toggle() }
+ .padding(.vertical, 6)
+ if shown[i] {
+ let sample = q.str("sample")
+ let qcs = q.str("qCs")
+ let acs = q.str("aCs")
+ Meaning(
+ text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)),
+ palette: p,
+ visible: true
+ )
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+}
+
+private struct AssignEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var active = 0
+ @State private var loc: [Int]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count))
+ }
+
+ var body: some View {
+ let items = spec.arr("items")
+ let groups = spec.strs("groups")
+ let meanings = spec.strs("meanings")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") }
+ if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ Hint(text: vm.tr("assign_hint"))
+ FlowLayout(spacing: 8) {
+ ForEach(groups.indices, id: \.self) { i in
+ SegChip(label: groups[i], selected: active == i) { active = i }
+ }
+ }
+ Text(vm.tr("wordbank"))
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.caption)
+ #endif
+ .foregroundStyle(.secondary)
+ .padding(.top, 10)
+ FlowLayout(spacing: 6) {
+ ForEach(items.indices, id: \.self) { i in
+ if loc[i] == -1 {
+ let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
+ WordChip(text: label) { loc[i] = active }
+ }
+ }
+ }
+ ForEach(groups.indices, id: \.self) { g in
+ Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10)
+ FlowLayout(spacing: 6) {
+ ForEach(items.indices, id: \.self) { i in
+ if loc[i] == g {
+ let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
+ WordChip(text: label) { loc[i] = -1 }
+ }
+ }
+ }
+ }
+ if revealed {
+ ForEach(items.indices, id: \.self) { i in
+ if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
+ }
+ }
+ }
+ }
+}
+
+private struct HangmanEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var word = 0
+ @State private var misses = 0
+ @State private var guessed: [Bool]
+ @State private var fb = ("", "")
+ @State private var finished = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count))
+ }
+
+ var body: some View {
+ let words = spec.strs("words")
+ let tips = spec.strs("tips")
+ let letters = spec.strs("letters")
+ let p = vm.palette
+ let cur = word < words.count ? words[word].uppercased() : ""
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary)
+ if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") }
+ Canvas { ctx, size in
+ var gallows = Path()
+ gallows.move(to: CGPoint(x: 40, y: size.height - 10))
+ gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10))
+ gallows.move(to: CGPoint(x: 70, y: size.height - 10))
+ gallows.addLine(to: CGPoint(x: 70, y: 16))
+ gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
+ ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ let x = size.width * 0.3 + 40
+ if misses > 0 {
+ var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
+ ctx.stroke(rope, with: .color(.primary), lineWidth: 4)
+ }
+ if misses > 1 {
+ ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4)
+ }
+ if misses > 2 {
+ var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
+ ctx.stroke(body, with: .color(.primary), lineWidth: 4)
+ }
+ if misses > 3 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
+ ctx.stroke(a, with: .color(.primary), lineWidth: 4)
+ }
+ if misses > 4 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
+ ctx.stroke(a, with: .color(.primary), lineWidth: 4)
+ }
+ if misses > 5 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
+ ctx.stroke(a, with: .color(.primary), lineWidth: 4)
+ }
+ }
+ .frame(height: 160)
+ HStack(spacing: 6) {
+ ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in
+ let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
+ let show = idx.map { guessed[$0] } ?? false
+ Text(show ? String(ch) : "_")
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .monospaced()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ FlowLayout(spacing: 6) {
+ ForEach(letters, id: \.self) { letter in
+ if let i = letters.firstIndex(of: letter) {
+ Button {
+ guessed[i] = true
+ if !cur.localizedCaseInsensitiveContains(letter) {
+ misses += 1
+ if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") }
+ } else if cur.allSatisfy({ ch in
+ let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
+ return idx.map { guessed[$0] } ?? false
+ }) {
+ if word + 1 >= words.count {
+ finished = true
+ fb = (vm.tr("hm_done"), "ok")
+ onDone()
+ } else {
+ fb = (vm.tr("hm_wrong"), "ok")
+ word += 1
+ misses = 0
+ guessed = guessed.map { _ in false }
+ }
+ }
+ } label: {
+ Text(letter)
+ .font(.body.weight(.semibold))
+ .frame(minWidth: 22)
+ }
+ .buttonStyle(.bordered)
+ .tint(guessed[i] ? .secondary : .primary)
+ .disabled(guessed[i] || finished)
+ }
+ }
+ }
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ if misses >= 6 && !finished {
+ PillButton(label: vm.tr("hm_retry")) {
+ misses = 0
+ guessed = guessed.map { _ in false }
+ fb = ("", "")
+ }
+ }
+ }
+ .exerciseContent()
+ }
+ .detailScreen(title, subtitle: sub)
+ }
+}
+
+private extension Array {
+ func chunked(_ size: Int) -> [[Element]] {
+ stride(from: 0, to: count, by: size).map { Array(self[$0.. Void
+ @State private var cards: [(String, String, String)]
+ @State private var index = 0
+ @State private var done = 0
+ @State private var value = ""
+ @State private var fb = ("", "")
+ let total: Int
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let list = spec.arr("sections").flatMap { sec in
+ sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) }
+ }
+ _cards = State(initialValue: list)
+ total = list.count
+ }
+
+ var body: some View {
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ProgressView(value: Double(done), total: Double(max(total, 1)))
+ Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary)
+ if cards.isEmpty {
+ Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill")
+ .font(.headline)
+ .foregroundStyle(p.success)
+ .padding(.top, 8)
+ } else if index < cards.count {
+ let cur = cards[index]
+ GlassCard {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
+ Text(cur.1).font(.title2.weight(.semibold))
+ }
+ }
+ .padding(.vertical, 8)
+ if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
+ WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
+ .submitLabel(.done)
+ .onSubmit { check(cur) }
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ }
+ }
+ .exerciseContent()
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .detailScreen(title, subtitle: sub)
+ .glassBottomBar {
+ if index < cards.count {
+ BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() }
+ }
+ }
+ }
+
+ private func check(_ cur: (String, String, String)) {
+ let norm = normalizeAnswer(value)
+ if answerAccepts(norm, cur.2) {
+ done += 1
+ cards.remove(at: index)
+ if cards.isEmpty {
+ fb = (vm.tr("trans_done"), "ok")
+ onDone()
+ } else {
+ if index >= cards.count { index = 0 }
+ value = ""
+ fb = ("", "")
+ }
+ } else if answerIncomplete(norm, cur.2) {
+ fb = (vm.tr("trans_incomplete"), "warn")
+ } else {
+ fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
+ let item = cards.remove(at: index)
+ cards.append(item)
+ if index >= cards.count { index = 0 }
+ value = ""
+ }
+ }
+}
diff --git a/ios/Graduately/UI/ExercisesMore.swift b/ios/Graduately/UI/ExercisesMore.swift
new file mode 100644
index 0000000..783dd4d
--- /dev/null
+++ b/ios/Graduately/UI/ExercisesMore.swift
@@ -0,0 +1,1884 @@
+import AVFoundation
+import SwiftUI
+
+struct DialogEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let pool = spec.strs("pool")
+ let meanings = spec.strs("meanings")
+ let lines = dialogLines()
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in
+ ForEach(lines.indices, id: \.self) { i in
+ let line = lines[i]
+ if let name = line.name {
+ Text(name)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 6)
+ }
+ ComboLine(
+ vm: vm, pool: pool, picks: picks, expected: expected, idx: i,
+ answer: line.answer,
+ prefix: line.prefix,
+ suffix: line.suffix,
+ meaning: i < meanings.count ? meanings[i] : nil,
+ revealed: revealed
+ )
+ }
+ }
+ }
+
+ private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] {
+ var out: [(name: String?, prefix: String, answer: String, suffix: String)] = []
+ for d in spec.arr("dialogues") {
+ var header: String? = d.str("name")
+ for row in d.arr("rows") {
+ out.append((
+ header,
+ [row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "),
+ row.str("answer"),
+ row.str("after")
+ ))
+ header = nil
+ }
+ }
+ return out
+ }
+}
+
+struct ComboEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let pool = spec.strs("pool")
+ let rows = spec.arr("rows")
+ let meanings = spec.strs("meanings")
+ let type = spec.str("type")
+ if type == "verb_sections" {
+ let sections = spec.arr("sections")
+ let allRows = sections.flatMap { $0.arr("rows") }
+ ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
+ ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
+ if let title = line.title {
+ Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
+ }
+ ComboLine(
+ vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
+ answer: line.answer, prefix: line.before, suffix: line.after,
+ meaning: line.meaning,
+ revealed: revealed || spec.bool("transUpfront")
+ )
+ }
+ }
+ } else if type == "ordne" {
+ ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in
+ ForEach(rows.indices, id: \.self) { i in
+ ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed)
+ ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed)
+ }
+ }
+ } else if type == "fill" {
+ let blanks = rows.reduce(0) { $0 + $1.answerList().count }
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
+ if let sample = spec.strOrNull("sample") { Hint(text: sample) }
+ ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
+ FlowLayout(spacing: 4) {
+ if let num = block.num {
+ Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary)
+ }
+ ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
+ switch part {
+ case .text(let s):
+ Text(s).foregroundStyle(.primary)
+ case .blank(let idx, let ans):
+ ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
+ }
+ }
+ if let mean = block.meaning {
+ Text(vm.tr(mean)).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .padding(.bottom, 6)
+ if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean {
+ Meaning(text: vm.tr(mean), palette: vm.palette, visible: true)
+ }
+ }
+ }
+ } else {
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
+ if let sample = spec.strOrNull("sample") { Hint(text: sample) }
+ ForEach(rows.indices, id: \.self) { i in
+ let r = rows[i]
+ let prefix: String = {
+ switch type {
+ case "number": return "\(r.str("digits")) = "
+ case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) "
+ case "seq": return r.str("before")
+ case "verbclue": return "\(r.str("emoji")) \(r.str("before"))"
+ default: return r.str("before")
+ }
+ }()
+ let suffix: String = {
+ switch type {
+ case "seq", "verbclue", "verb": return r.str("after")
+ default: return ""
+ }
+ }()
+ ComboLine(
+ vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"),
+ prefix: prefix, suffix: suffix,
+ meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"),
+ revealed: revealed || spec.bool("preMeaning")
+ )
+ }
+ }
+ }
+ }
+
+ private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] {
+ var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = []
+ for sec in spec.arr("sections") {
+ var header: String? = sec.str("title")
+ let meanings = sec.strs("meanings")
+ for (ri, row) in sec.arr("rows").enumerated() {
+ out.append((
+ header,
+ row.str("before"),
+ row.str("answer"),
+ row.str("after"),
+ ri < meanings.count ? meanings[ri] : nil
+ ))
+ header = nil
+ }
+ }
+ return out
+ }
+
+ private enum FillPart {
+ case text(String)
+ case blank(Int, String)
+ }
+
+ private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] {
+ let meanings = spec.strs("meanings")
+ var i = 0
+ return spec.arr("rows").enumerated().map { ri, r in
+ var parts: [FillPart] = []
+ let segs = r.strsOrEmpty("segs")
+ let ans = r.answerList()
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.blank(i, ans[si]))
+ i += 1
+ }
+ }
+ return (
+ r.strOrNull("num"),
+ parts,
+ ri < meanings.count ? meanings[ri] : nil,
+ r.strOrNull("mean")
+ )
+ }
+ }
+}
+
+private struct ComboRows: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let pool: [String]
+ let n: Int
+ var onDone: () -> Void
+ var revealMean = false
+ @ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body
+ @State private var picks: [String]
+ @State private var expected: [String]
+ @State private var fb = ("", "")
+ @State private var revealed: Bool
+
+ init(
+ vm: AppModel,
+ title: String,
+ sub: String?,
+ pool: [String],
+ n: Int,
+ onDone: @escaping () -> Void,
+ revealMean: Bool = false,
+ @ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body
+ ) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.pool = pool
+ self.n = n
+ self.onDone = onDone
+ self.revealMean = revealMean
+ self.bodyContent = bodyContent
+ _picks = State(initialValue: Array(repeating: "", count: n))
+ _expected = State(initialValue: Array(repeating: "", count: n))
+ _revealed = State(initialValue: revealMean)
+ }
+
+ var body: some View {
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = expected.indices.allSatisfy { i in
+ let exp = expected[i]
+ return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp)
+ }
+ if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ bodyContent($picks, $expected, revealed)
+ }
+ }
+}
+
+private struct ComboLine: View {
+ @ObservedObject var vm: AppModel
+ let pool: [String]
+ @Binding var picks: [String]
+ @Binding var expected: [String]
+ let idx: Int
+ let answer: String
+ var prefix: String = ""
+ var suffix: String = ""
+ var meaning: String?
+ var revealed: Bool
+ var inline = false
+ @State private var open = false
+
+ var body: some View {
+ let p = vm.palette
+ let pick = idx < picks.count ? picks[idx] : ""
+ let mark: Bool? = {
+ if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) }
+ return nil
+ }()
+ let chip = Button {
+ open = true
+ } label: {
+ HStack(spacing: 4) {
+ Text(pick.isEmpty ? "…" : pick)
+ .foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
+ .frame(minWidth: 36)
+ Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
+ .font(.caption2)
+ .foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .tint(mark == true ? p.success : mark == false ? p.error : .secondary)
+
+ Group {
+ if inline {
+ chip
+ } else {
+ FlowLayout(spacing: 6) {
+ if !prefix.isEmpty { Text(prefix) }
+ chip
+ if !suffix.isEmpty { Text(suffix) }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 8)
+ }
+ }
+ .onAppear {
+ if idx >= 0 && idx < expected.count { expected[idx] = answer }
+ }
+ .onChange(of: answer) { _, new in
+ if idx >= 0 && idx < expected.count { expected[idx] = new }
+ }
+ .sheet(isPresented: $open) {
+ WordPicker(palette: p, pool: pool) { word in
+ if let word, idx < picks.count { picks[idx] = word }
+ open = false
+ }
+ .sheetDetents()
+ }
+ if revealed, let meaning, !meaning.isEmpty {
+ Meaning(text: vm.tr(meaning), palette: p, visible: true)
+ }
+ }
+}
+
+struct WordPicker: View {
+ let palette: Palette
+ let pool: [String]
+ var onPick: (String?) -> Void
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(Array(pool.enumerated()), id: \.offset) { _, w in
+ Button {
+ onPick(w)
+ } label: {
+ Text(w)
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ .listStyle(.plain)
+ .scrollContentBackground(.visible)
+ .navigationTitle("Vyberte slovo")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
+ }
+ }
+ }
+ .sheetDetents()
+ }
+}
+
+struct Ex2Ex: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let items = spec.arr("items")
+ let pool = spec.strs("pool")
+ let blanks = items.reduce(0) { acc, item in
+ acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count }
+ }
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in
+ ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in
+ Meaning(text: block.czech, palette: vm.palette, visible: true)
+ ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
+ FlowLayout(spacing: 4) {
+ if let num = row.num {
+ Text(num).foregroundStyle(.primary).padding(.trailing, 6)
+ }
+ ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
+ switch part {
+ case .text(let s):
+ Text(s).foregroundStyle(.primary)
+ case .blank(let idx, let ans):
+ ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private enum Part {
+ case text(String)
+ case blank(Int, String)
+ }
+
+ private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] {
+ var i = 0
+ return spec.arr("items").map { item in
+ let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in
+ var parts: [Part] = []
+ let segs = row.strsOrEmpty("segs")
+ let ans = row.strs("answers")
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.blank(i, ans[si]))
+ i += 1
+ }
+ }
+ return (row.strOrNull("num"), parts)
+ }
+ return (item.str("czech"), rows)
+ }
+ }
+}
+
+struct LettersEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var fields: [String]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let n = spec.arr("rows").flatMap { $0.strs("answers") }.count
+ _fields = State(initialValue: Array(repeating: "", count: n))
+ }
+
+ var body: some View {
+ let rows = spec.arr("rows")
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var i = 0, ok = 0, total = 0
+ for r in rows {
+ for ans in r.strs("answers") {
+ total += 1
+ if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 }
+ i += 1
+ }
+ }
+ if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
+ Hint(text: vm.tr("hint_umlauts"))
+ }
+ LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
+ }
+ }
+}
+
+private struct LettersRows: View {
+ @ObservedObject var vm: AppModel
+ let rows: [J]
+ @Binding var fields: [String]
+ let revealed: Bool
+
+ var body: some View {
+ let p = vm.palette
+ let pairs = build()
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(pairs.indices, id: \.self) { ri in
+ let row = pairs[ri]
+ if let num = row.num { Text(num).foregroundStyle(.secondary) }
+ FlowLayout {
+ ForEach(row.parts.indices, id: \.self) { pi in
+ switch row.parts[pi] {
+ case .text(let s):
+ Text(s).foregroundStyle(.primary)
+ case .field(let idx, let ans):
+ WordField(
+ value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
+ placeholder: "",
+ mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil,
+ palette: p
+ )
+ .frame(width: 72)
+ }
+ }
+ }
+ if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) }
+ if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) }
+ }
+ }
+ }
+
+ private enum Part {
+ case text(String)
+ case field(Int, String)
+ }
+
+ private struct RowModel {
+ var num: String?
+ var parts: [Part]
+ var czech: String?
+ var mean: String?
+ }
+
+ private func build() -> [RowModel] {
+ var i = 0
+ return rows.map { r in
+ var parts: [Part] = []
+ let segs = r.strsOrEmpty("segs")
+ let ans = r.strs("answers")
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.field(i, ans[si]))
+ i += 1
+ }
+ }
+ return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean"))
+ }
+ }
+}
+
+struct TableEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var fields: [String]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count))
+ }
+
+ var body: some View {
+ let persons = spec.strs("persons")
+ let nouns = spec.strs("nouns")
+ let answers = spec.intRows("answers")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var ok = 0
+ for (r, row) in answers.enumerated() {
+ for (c, ans) in row.enumerated() {
+ let i = r * persons.count + c
+ if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 }
+ }
+ }
+ if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ ForEach(nouns.indices, id: \.self) { r in
+ Text(nouns[r]).font(.body.weight(.bold)).foregroundStyle(.primary)
+ ForEach(persons.indices, id: \.self) { c in
+ let i = r * persons.count + c
+ Text(persons[c]).font(.caption).foregroundStyle(.secondary)
+ WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
+ }
+ Spacer().frame(height: 8)
+ }
+ }
+ }
+}
+
+struct RevealEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var shown: [Bool]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count))
+ }
+
+ var body: some View {
+ let items = spec.arr("items")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
+ shown = shown.map { _ in true }
+ fb = (vm.tr("feedback_ok"), "ok")
+ onDone()
+ }, feedback: fb.0, kind: fb.1) {
+ if let note = spec.strOrNull("note") { Hint(text: note) }
+ ForEach(items.indices, id: \.self) { i in
+ Prompt(text: items[i].str("prompt"))
+ PillButton(label: vm.tr("show_sample")) { shown[i].toggle() }
+ .padding(.vertical, 6)
+ if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
+ Spacer().frame(height: 8)
+ }
+ }
+ }
+}
+
+struct NetQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.netLesson(id) {
+ if id == 1 {
+ VlsmEx(vm: vm, lesson: lesson)
+ } else {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": "net_quiz_intro",
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markNet(id)
+ }
+ }
+ }
+ }
+}
+
+struct SciQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let area: String
+ let id: Int
+
+ var body: some View {
+ let lesson = area == "bio" ? vm.content.bioLesson(id) : vm.content.chemLesson(id)
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ if area == "bio" { vm.markBio(id) } else { vm.markChem(id) }
+ }
+ }
+ }
+}
+
+private func mathLesson(_ vm: AppModel, year: Int, id: Int) -> J? {
+ switch year {
+ case 0: return vm.content.mat0Lesson(id)
+ case 2: return vm.content.mat2Lesson(id)
+ case 3: return vm.content.mat3Lesson(id)
+ case 4: return vm.content.mat4Lesson(id)
+ default: return vm.content.matLesson(id)
+ }
+}
+
+private func markMathLesson(_ vm: AppModel, year: Int, id: Int) {
+ switch year {
+ case 0: vm.markMat0(id)
+ case 2: vm.markMat2(id)
+ case 3: vm.markMat3(id)
+ case 4: vm.markMat4(id)
+ default: vm.markMat(id)
+ }
+}
+
+struct MathPracticeScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+ @State private var text: [Int: String] = [:]
+ @State private var points: [Int: [(Int, Int)]] = [:]
+ @State private var marks: [Int: Bool] = [:]
+ @State private var revealed = false
+ @State private var feedback = ""
+ @State private var kind = ""
+
+ var body: some View {
+ if let lesson = mathLesson(vm, year: year, id: id) {
+ let problems = lesson.arr("problems")
+ ExerciseScaffold(
+ vm: vm,
+ title: vm.tr(lesson.str("exTitleKey")),
+ sub: vm.tr(lesson.str("quizHeadKey")),
+ action: vm.tr("check"),
+ onAction: { check(problems) },
+ feedback: feedback,
+ kind: kind
+ ) {
+ Text(vm.tr("math_quiz_intro"))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+ ForEach(problems.indices, id: \.self) { i in
+ mathCard(problems[i], index: i)
+ }
+ }
+ .onChange(of: year) { _, _ in reset() }
+ .onChange(of: id) { _, _ in reset() }
+ }
+ }
+
+ private func reset() {
+ text = [:]
+ points = [:]
+ marks = [:]
+ revealed = false
+ feedback = ""
+ kind = ""
+ }
+
+ private func check(_ problems: [J]) {
+ var all = !problems.isEmpty
+ var next: [Int: Bool] = [:]
+ for i in problems.indices {
+ let spec = problems[i].str("answer")
+ let good = mathIsDraw(spec)
+ ? mathDrawOk(spec, points[i] ?? [])
+ : mathAnswerOk(spec, text[i] ?? "")
+ next[i] = good
+ if !good { all = false }
+ }
+ marks = next
+ revealed = true
+ if all {
+ feedback = vm.tr("feedback_ok")
+ kind = "ok"
+ markMathLesson(vm, year: year, id: id)
+ } else {
+ feedback = vm.tr("feedback_retry_short")
+ kind = "bad"
+ }
+ }
+
+ @ViewBuilder
+ private func mathCard(_ problem: J, index i: Int) -> some View {
+ let spec = problem.str("answer")
+ let p = vm.palette
+ let border: Color = {
+ guard let mark = marks[i] else { return p.surface2 }
+ return mark ? p.success : p.error
+ }()
+ VStack(alignment: .leading, spacing: 8) {
+ Text(problem.str("prompt"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ if mathIsDraw(spec) {
+ Text(vm.tr("math_draw_hint"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ MathGrid(
+ given: mathDrawGiven(spec),
+ user: points[i] ?? [],
+ need: mathDrawNeed(spec),
+ mark: marks[i],
+ palette: p
+ ) { next in
+ points[i] = next
+ }
+ HStack {
+ Button(vm.tr("math_undo")) {
+ var pts = points[i] ?? []
+ if !pts.isEmpty {
+ pts.removeLast()
+ points[i] = pts
+ }
+ }
+ Button(vm.tr("math_clear")) { points[i] = [] }
+ }
+ .buttonStyle(.bordered)
+ } else {
+ TextField(vm.tr("math_answer_ph"), text: Binding(
+ get: { text[i] ?? "" },
+ set: { text[i] = $0 }
+ ))
+ .textFieldStyle(.roundedBorder)
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.numbersAndPunctuation)
+ #endif
+ .autocorrectionDisabled()
+ }
+ if revealed {
+ Text(problem.str("hint"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(12)
+ .background(p.surface0, in: RoundedRectangle(cornerRadius: 12))
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(border, lineWidth: 1.5))
+ }
+}
+
+private struct MathGrid: View {
+ let given: [(Int, Int)]
+ let user: [(Int, Int)]
+ let need: Int
+ let mark: Bool?
+ let palette: Palette
+ var onChange: ([(Int, Int)]) -> Void
+
+ private let cell: CGFloat = 28
+ private let pad: CGFloat = 18
+ private let gx = 10
+ private let gy = 8
+ private var width: CGFloat { pad * 2 + CGFloat(gx) * cell }
+ private var height: CGFloat { pad * 2 + CGFloat(gy) * cell }
+
+ private func screen(_ x: Int, _ y: Int) -> CGPoint {
+ CGPoint(x: pad + CGFloat(x) * cell, y: height - pad - CGFloat(y) * cell)
+ }
+
+ private func snap(_ loc: CGPoint) -> (Int, Int)? {
+ let cx = (loc.x - pad) / cell
+ let cy = (height - pad - loc.y) / cell
+ let ix = Int(cx.rounded())
+ let iy = Int(cy.rounded())
+ if ix < 0 || ix > gx || iy < 0 || iy > gy { return nil }
+ if abs(cx - CGFloat(ix)) > 0.45 || abs(cy - CGFloat(iy)) > 0.45 { return nil }
+ return (ix, iy)
+ }
+
+ var body: some View {
+ Canvas { ctx, _ in
+ let rect = CGRect(x: 0, y: 0, width: width, height: height)
+ ctx.fill(Path(rect), with: .color(palette.surface1))
+ var grid = Path()
+ for i in 0...gx {
+ let x = pad + CGFloat(i) * cell
+ grid.move(to: CGPoint(x: x, y: pad))
+ grid.addLine(to: CGPoint(x: x, y: height - pad))
+ }
+ for i in 0...gy {
+ let y = pad + CGFloat(i) * cell
+ grid.move(to: CGPoint(x: pad, y: y))
+ grid.addLine(to: CGPoint(x: width - pad, y: y))
+ }
+ ctx.stroke(grid, with: .color(palette.subtext.opacity(0.55)), lineWidth: 1)
+ var axes = Path()
+ axes.move(to: CGPoint(x: pad, y: height - pad))
+ axes.addLine(to: CGPoint(x: width - pad, y: height - pad))
+ axes.move(to: CGPoint(x: pad, y: height - pad))
+ axes.addLine(to: CGPoint(x: pad, y: pad))
+ ctx.stroke(axes, with: .color(palette.text), lineWidth: 1.6)
+ if given.count >= 2 {
+ var seg = Path()
+ seg.move(to: screen(given[0].0, given[0].1))
+ seg.addLine(to: screen(given[1].0, given[1].1))
+ if given.count >= 3 {
+ seg.move(to: screen(given[0].0, given[0].1))
+ seg.addLine(to: screen(given[2].0, given[2].1))
+ }
+ ctx.stroke(seg, with: .color(palette.accent), lineWidth: 2)
+ }
+ let names = ["A", "B", "C", "D"]
+ for (i, g) in given.enumerated() where i < names.count {
+ let pt = screen(g.0, g.1)
+ ctx.fill(Path(ellipseIn: CGRect(x: pt.x - 5.5, y: pt.y - 5.5, width: 11, height: 11)), with: .color(palette.accent))
+ ctx.draw(
+ Text(names[i]).font(.system(size: 13, weight: .semibold)).foregroundStyle(palette.text),
+ at: CGPoint(x: pt.x + 8, y: pt.y - 8),
+ anchor: .bottomLeading
+ )
+ }
+ let dot = mark == false ? palette.error : palette.success
+ for (i, u) in user.enumerated() {
+ let pt = screen(u.0, u.1)
+ ctx.fill(Path(ellipseIn: CGRect(x: pt.x - 5.5, y: pt.y - 5.5, width: 11, height: 11)), with: .color(dot))
+ ctx.draw(
+ Text("\(i + 1)").font(.system(size: 12, weight: .bold)).foregroundStyle(palette.text),
+ at: CGPoint(x: pt.x + 8, y: pt.y - 8),
+ anchor: .bottomLeading
+ )
+ }
+ }
+ .frame(width: width, height: height)
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0).onEnded { value in
+ guard let hit = snap(value.location) else { return }
+ if given.contains(where: { $0.0 == hit.0 && $0.1 == hit.1 }) { return }
+ var next = user
+ if let idx = next.firstIndex(where: { $0.0 == hit.0 && $0.1 == hit.1 }) {
+ next.remove(at: idx)
+ } else if next.count < need {
+ next.append(hit)
+ } else {
+ return
+ }
+ onChange(next)
+ }
+ )
+ }
+}
+
+struct ItYearList: View {
+ @ObservedObject var vm: AppModel
+ let course: String
+ let titleKey: String
+ let subKey: String
+ let lesson: (Int) -> Route
+
+ var body: some View {
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr(titleKey),
+ subtitle: vm.tr(subKey),
+ rows: vm.content.itLessons(course).map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "\(course)\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.courseDone(course, id),
+ destination: lesson(id)
+ )
+ }
+ )
+ }
+}
+
+struct ItSlides: View {
+ @ObservedObject var vm: AppModel
+ let course: String
+ let id: Int
+ let next: Route
+
+ var body: some View {
+ if let l = vm.content.itLesson(course, id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: next)
+ }
+ }
+}
+
+struct ChoiceLessonScreen: View {
+ @ObservedObject var vm: AppModel
+ let lesson: J?
+ let onDone: () -> Void
+
+ var body: some View {
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey")), onDone: onDone)
+ }
+ }
+}
+
+struct TgQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+
+ var body: some View {
+ let lesson: J? = {
+ switch year {
+ case 2: return vm.content.tg2Lesson(id)
+ case 3: return vm.content.tg3Lesson(id)
+ case 4: return vm.content.tg4Lesson(id)
+ default: return vm.content.tgLesson(id)
+ }
+ }()
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ switch year {
+ case 2: vm.markTg2(id)
+ case 3: vm.markTg3(id)
+ case 4: vm.markTg4(id)
+ default: vm.markTg(id)
+ }
+ }
+ }
+ }
+}
+
+struct PgQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+
+ var body: some View {
+ let lesson: J? = {
+ switch year {
+ case 2: return vm.content.pg2Lesson(id)
+ case 3: return vm.content.pg3Lesson(id)
+ case 4: return vm.content.pg4Lesson(id)
+ default: return vm.content.pgLesson(id)
+ }
+ }()
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ switch year {
+ case 2: vm.markPg2(id)
+ case 3: vm.markPg3(id)
+ case 4: vm.markPg4(id)
+ default: vm.markPg(id)
+ }
+ }
+ }
+ }
+}
+
+struct SwQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+
+ var body: some View {
+ let lesson: J? = {
+ switch year {
+ case 2: return vm.content.sw2Lesson(id)
+ case 3: return vm.content.sw3Lesson(id)
+ case 4: return vm.content.sw4Lesson(id)
+ default: return vm.content.swLesson(id)
+ }
+ }()
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ switch year {
+ case 2: vm.markSw2(id)
+ case 3: vm.markSw3(id)
+ case 4: vm.markSw4(id)
+ default: vm.markSw(id)
+ }
+ }
+ }
+ }
+}
+
+struct FyzQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+
+ var body: some View {
+ let lesson: J? = {
+ switch year {
+ case 2: return vm.content.fyz2Lesson(id)
+ case 3: return vm.content.fyz3Lesson(id)
+ case 4: return vm.content.fyz4Lesson(id)
+ default: return vm.content.fyzLesson(id)
+ }
+ }()
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ switch year {
+ case 2: vm.markFyz2(id)
+ case 3: vm.markFyz3(id)
+ case 4: vm.markFyz4(id)
+ default: vm.markFyz(id)
+ }
+ }
+ }
+ }
+}
+
+struct LitYearQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+
+ var body: some View {
+ let lesson: J? = {
+ switch year {
+ case 2: return vm.content.lit2Lesson(id)
+ case 3: return vm.content.lit3Lesson(id)
+ case 4: return vm.content.lit4Lesson(id)
+ default: return vm.content.litLesson(id)
+ }
+ }()
+ if let lesson {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ switch year {
+ case 2: vm.markLit2(id)
+ case 3: vm.markLit3(id)
+ case 4: vm.markLit4(id)
+ default: vm.markLit(id)
+ }
+ }
+ }
+ }
+}
+
+struct OnQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.onLesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markOn(id)
+ }
+ }
+ }
+}
+
+struct On2QuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.on2Lesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markOn2(id)
+ }
+ }
+ }
+}
+
+struct On3QuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.on3Lesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markOn3(id)
+ }
+ }
+ }
+}
+
+struct On4QuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.on4Lesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markOn4(id)
+ }
+ }
+ }
+}
+
+struct HwQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.hwLesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markHw(id)
+ }
+ }
+ }
+}
+
+private struct VlsmRow {
+ var pfx = ""
+ var net = ""
+ var bc = ""
+ var lo = ""
+ var hi = ""
+}
+
+private struct VlsmEx: View {
+ @ObservedObject var vm: AppModel
+ let lesson: J
+ @State private var done: [Bool]
+ @State private var rows: [[VlsmRow]]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, lesson: J) {
+ self.vm = vm
+ self.lesson = lesson
+ let answers = vm.content.netAnswers
+ _done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count))
+ _rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) })
+ }
+
+ var body: some View {
+ let tasks = vm.content.netTasks
+ let answers = vm.content.netAnswers
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(tasks.indices, id: \.self) { ti in
+ let task = tasks[ti]
+ let block = ti < answers.count ? answers[ti] : []
+ let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
+ GlassCard {
+ Label {
+ Text(task.str("prompt"))
+ } icon: {
+ Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle")
+ .foregroundStyle(done[ti] ? p.success : .secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ if infeasible {
+ Hint(text: vm.tr("net_infeasible"))
+ } else {
+ ForEach(block.indices, id: \.self) { si in
+ Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.top, 6)
+ PrefixMenu(value: Binding(
+ get: { rows[ti][si].pfx },
+ set: { rows[ti][si].pfx = $0 }
+ ), palette: p)
+ WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p)
+ .keyboardType(.decimalPad)
+ WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p)
+ .keyboardType(.decimalPad)
+ WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p)
+ .keyboardType(.decimalPad)
+ WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p)
+ .keyboardType(.decimalPad)
+ }
+ PillButton(label: vm.tr("check")) {
+ let ok = block.indices.allSatisfy { si in
+ let a = block[si]
+ let field = rows[ti][si]
+ return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx")
+ && netTxtEq(field.net, a.str("net"))
+ && netTxtEq(field.bc, a.str("bcast"))
+ && netTxtEq(field.lo, a.str("lo"))
+ && netTxtEq(field.hi, a.str("hi"))
+ }
+ if ok {
+ done[ti] = true
+ fb = (vm.tr("feedback_ok"), "ok")
+ maybeFinish()
+ } else {
+ fb = (vm.tr("feedback_retry"), "err")
+ }
+ }
+ .padding(.vertical, 6)
+ }
+ PillButton(label: vm.tr("net_solution")) {
+ if !infeasible {
+ for si in block.indices {
+ rows[ti][si].pfx = "/\(block[si].int("pfx"))"
+ rows[ti][si].net = block[si].str("net")
+ rows[ti][si].bc = block[si].str("bcast")
+ rows[ti][si].lo = block[si].str("lo")
+ rows[ti][si].hi = block[si].str("hi")
+ }
+ }
+ done[ti] = true
+ fb = ("", "")
+ maybeFinish()
+ }
+ if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
+ Divider().padding(.vertical, 8)
+ }
+ }
+ .exerciseContent()
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .detailScreen(vm.tr("net_ex_title"))
+ .glassBottomBar {
+ if !fb.0.isEmpty {
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
+ }
+ }
+ .animation(Motion.feedback, value: fb.0)
+ }
+
+ private func maybeFinish() {
+ if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) }
+ }
+}
+
+private struct PrefixMenu: View {
+ @Binding var value: String
+ let palette: Palette
+ @State private var open = false
+
+ var body: some View {
+ Button { open = true } label: {
+ HStack {
+ Text("Prefix").foregroundStyle(.secondary)
+ Spacer()
+ Text(value.isEmpty ? "/…" : value)
+ .foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
+ .monospacedDigit()
+ Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ .buttonStyle(.bordered)
+ .tint(.secondary)
+ .sheet(isPresented: $open) {
+ WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
+ if let word { value = word }
+ open = false
+ }
+ .sheetDetents()
+ }
+ }
+}
+
+struct LitQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+ @State private var idx = 0
+ @State private var score = 0
+ @State private var answered = false
+ @State private var finished = false
+ @State private var picked = -1
+
+ var body: some View {
+ if let book = vm.content.book(id) {
+ let qs = book.arr("quiz")
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ if finished {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 10) {
+ Label(vm.tr("lit_finished"), systemImage: "rosette")
+ #if os(macOS)
+ .font(.largeTitle.weight(.bold))
+ #else
+ .font(.title2.weight(.bold))
+ #endif
+ Text(vm.tr("lit_finished_text"))
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ .foregroundStyle(.secondary)
+ Text(vm.fmt("lit_score_fmt", score, qs.count))
+ #if os(macOS)
+ .font(.title2.weight(.semibold))
+ #else
+ .font(.headline)
+ #endif
+ }
+ }
+ } else if idx < qs.count {
+ let q = qs[idx]
+ ProgressView(value: Double(idx + (answered ? 1 : 0)), total: Double(qs.count))
+ HStack {
+ Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
+ Spacer()
+ Text(vm.fmt("lit_score_fmt", score, qs.count))
+ }
+ #if os(macOS)
+ .font(.body.weight(.semibold))
+ #else
+ .font(.footnote)
+ #endif
+ .foregroundStyle(.secondary)
+ Text(q.str("prompt"))
+ #if os(macOS)
+ .font(.title.weight(.semibold))
+ #else
+ .font(.title3.weight(.semibold))
+ #endif
+ .padding(.vertical, 12)
+ #if os(macOS)
+ LazyVGrid(
+ columns: [
+ GridItem(.flexible(), spacing: 12, alignment: .top),
+ GridItem(.flexible(), spacing: 12, alignment: .top),
+ ],
+ spacing: 8
+ ) {
+ bookQuizOptions(q)
+ }
+ #else
+ bookQuizOptions(q)
+ #endif
+ if answered {
+ Meaning(text: q.str("expl"), palette: p, visible: true)
+ }
+ }
+ }
+ .exerciseContent()
+ }
+ .detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
+ .glassBottomBar {
+ if finished {
+ BottomAction(label: vm.tr("lit_again"), action: {
+ idx = 0; score = 0; answered = false; finished = false; picked = -1
+ }) { EmptyView() }
+ } else if answered {
+ BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: {
+ if idx + 1 >= qs.count {
+ finished = true
+ vm.markBookQuiz(id)
+ } else {
+ idx += 1
+ answered = false
+ picked = -1
+ }
+ }) { EmptyView() }
+ }
+ }
+ .animation(Motion.settle, value: answered)
+ }
+ }
+
+ @ViewBuilder
+ private func bookQuizOptions(_ q: J) -> some View {
+ ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
+ let correct = i == q.int("correct")
+ let chosen = answered && picked == i
+ OptionChip(
+ text: opt,
+ selected: chosen,
+ mark: answered ? (correct ? true : (chosen ? false : nil)) : nil
+ ) {
+ guard !answered else { return }
+ picked = i
+ answered = true
+ if correct { score += 1 }
+ }
+ }
+ }
+}
+
+struct PlotScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+
+ var body: some View {
+ if let book = vm.content.book(id) {
+ let spec = J([
+ "type": "assembly",
+ "title": vm.tr(book.str("plotTitle")),
+ "sub": book.str("plotSub"),
+ "items": book.rawArray("plot"),
+ "meanings": book.rawArray("plotMeaning"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) {
+ vm.markBookPlot(id)
+ }
+ }
+ }
+}
+
+private final class EnSpeaker: NSObject, ObservableObject, AVSpeechSynthesizerDelegate, @unchecked Sendable {
+ enum Phase { case idle, playing, paused }
+
+ private let synth = AVSpeechSynthesizer()
+ private var text = ""
+ private var base = 0
+ private var cursor = 0
+ private var restarting = false
+ private var settingsDirty = false
+
+ @Published var rate: Float = 0.9
+ @Published var volume: Float = 1
+ @Published var phase: Phase = .idle
+ var language = "en-GB"
+ var onFinish: (() -> Void)?
+
+ override init() {
+ super.init()
+ synth.delegate = self
+ }
+
+ func toggle(_ full: String) {
+ if text != full {
+ text = full
+ cursor = 0
+ base = 0
+ synth.stopSpeaking(at: .immediate)
+ phase = .idle
+ }
+ switch phase {
+ case .idle:
+ cursor = 0
+ start(from: 0)
+ case .playing:
+ synth.pauseSpeaking(at: .immediate)
+ phase = .paused
+ case .paused:
+ if settingsDirty || !synth.isPaused {
+ start(from: cursor)
+ } else {
+ synth.continueSpeaking()
+ phase = .playing
+ }
+ settingsDirty = false
+ }
+ }
+
+ func applySettings() {
+ settingsDirty = true
+ guard phase == .playing else { return }
+ restarting = true
+ synth.stopSpeaking(at: .immediate)
+ start(from: cursor)
+ settingsDirty = false
+ }
+
+ private func start(from index: Int) {
+ let ns = text as NSString
+ let start = min(max(0, index), ns.length)
+ base = start
+ let rest = ns.substring(from: start)
+ let utterance = AVSpeechUtterance(string: rest.isEmpty ? text : rest)
+ if rest.isEmpty { base = 0 }
+ utterance.voice = AVSpeechSynthesisVoice(language: language)
+ ?? AVSpeechSynthesisVoice(language: language.hasPrefix("de") ? "de-DE" : "en-US")
+ utterance.rate = AVSpeechUtteranceDefaultSpeechRate * rate
+ utterance.volume = volume
+ synth.speak(utterance)
+ phase = .playing
+ }
+
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
+ cursor = base + characterRange.location
+ }
+
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
+ DispatchQueue.main.async {
+ self.phase = .idle
+ self.cursor = 0
+ self.onFinish?()
+ }
+ }
+
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
+ if restarting {
+ restarting = false
+ return
+ }
+ DispatchQueue.main.async {
+ if self.phase == .playing { self.phase = .idle }
+ }
+ }
+}
+
+struct EngExScreen: View {
+ @ObservedObject var vm: AppModel
+ let year: Int
+ let id: Int
+ let voice: String
+ let lessonOf: (AppModel, Int, Int) -> J?
+ let mark: (AppModel, Int, Int) -> Void
+ @StateObject private var speaker: EnSpeaker
+ @State private var readPicks: [Int]
+ @State private var listenPicks: [Int]
+ @State private var quizPicks: [Int]
+ @State private var gaps: [String]
+ @State private var essay = ""
+ @State private var played = false
+ @State private var revealed = false
+ @State private var showModel = false
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, year: Int, id: Int, voice: String = "en-GB",
+ lesson: @escaping (AppModel, Int, Int) -> J? = { $0.content.enLesson($1, $2) },
+ mark: @escaping (AppModel, Int, Int) -> Void = { $0.markEn($1, $2) }) {
+ self.vm = vm
+ self.year = year
+ self.id = id
+ self.voice = voice
+ self.lessonOf = lesson
+ self.mark = mark
+ let row = lesson(vm, year, id)
+ _readPicks = State(initialValue: Array(repeating: -1, count: row?.arr("readQuiz").count ?? 0))
+ _listenPicks = State(initialValue: Array(repeating: -1, count: row?.arr("listenQuiz").count ?? 0))
+ _quizPicks = State(initialValue: Array(repeating: -1, count: row?.arr("quiz").count ?? 0))
+ _gaps = State(initialValue: Array(repeating: "", count: row?.arr("gaps").count ?? 0))
+ let speaker = EnSpeaker()
+ speaker.language = voice
+ _speaker = StateObject(wrappedValue: speaker)
+ }
+
+ var body: some View {
+ if let lesson = lessonOf(vm, year, id) {
+ let listening = lesson.strOrNull("listening")
+ let pinListen = listening != nil && lesson.strOrNull("reading") == nil
+ if pinListen, let listening {
+ scaffold(lesson) {
+ exerciseBody(lesson, inlineListening: nil)
+ }
+ .safeAreaInset(edge: .top, spacing: 0) {
+ listenBlock(listening)
+ .padding(.horizontal, pagePad)
+ .padding(.top, 4)
+ .padding(.bottom, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.background)
+ }
+ } else {
+ scaffold(lesson) {
+ exerciseBody(lesson, inlineListening: listening)
+ }
+ }
+ }
+ }
+
+ private func scaffold(_ lesson: J, @ViewBuilder content: @escaping () -> Content) -> some View {
+ ExerciseScaffold(
+ vm: vm,
+ title: vm.tr(lesson.str("titleKey")),
+ sub: nil,
+ action: vm.tr("check"),
+ onAction: { check(lesson) },
+ feedback: fb.0,
+ kind: fb.1,
+ content: content
+ )
+ }
+
+ @ViewBuilder
+ private func exerciseBody(_ lesson: J, inlineListening: String?) -> some View {
+ let p = vm.palette
+ if let reading = lesson.strOrNull("reading") {
+ section("en_sec_read")
+ Text(reading).textSelection(.enabled)
+ choiceBlock(lesson.arr("readQuiz"), picks: $readPicks)
+ }
+ if let inlineListening {
+ listenBlock(inlineListening)
+ choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
+ } else if lesson.strOrNull("listening") != nil {
+ choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
+ }
+ let gapRows = lesson.arr("gaps")
+ if !gapRows.isEmpty {
+ section("en_sec_gap")
+ ForEach(gapRows.indices, id: \.self) { i in
+ Text("\(i + 1). \(gapRows[i].str("prompt"))")
+ .font(.body.weight(.semibold))
+ WordField(
+ value: Binding(get: { gaps[i] }, set: { gaps[i] = $0 }),
+ placeholder: "",
+ mark: revealed ? answerAccepts(normalizeAnswer(gaps[i]), gapRows[i].str("answers")) : nil,
+ palette: p
+ )
+ if revealed, let hint = gapRows[i].strOrNull("meaning") {
+ Meaning(text: hint, palette: p, visible: true)
+ }
+ }
+ }
+ if let writing = lesson.obj("writing") {
+ section("en_sec_write")
+ Text(writing.str("prompt"))
+ TextEditor(text: $essay)
+ .frame(minHeight: 140)
+ .padding(8)
+ .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
+ if showModel {
+ Text(vm.tr("en_model"))
+ .font(.subheadline.weight(.semibold))
+ Text(writing.str("model")).textSelection(.enabled)
+ }
+ }
+ let quiz = lesson.arr("quiz")
+ if !quiz.isEmpty {
+ section("en_sec_quiz")
+ choiceBlock(quiz, picks: $quizPicks)
+ }
+ }
+
+ private var pagePad: CGFloat {
+ #if os(macOS)
+ MacChrome.pageInset
+ #else
+ 16
+ #endif
+ }
+
+ private func listenBlock(_ text: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ section("en_sec_listen")
+ Text(vm.tr("en_listen_note"))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ GlassCard {
+ #if os(macOS)
+ HStack(alignment: .top, spacing: 22) {
+ listenControls(text)
+ listenScript(text)
+ }
+ #else
+ VStack(alignment: .leading, spacing: 14) {
+ listenScript(text)
+ listenControls(text)
+ }
+ #endif
+ }
+ }
+ }
+
+ private func listenScript(_ text: String) -> some View {
+ Text(text)
+ .font(.body)
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func section(_ key: String) -> some View {
+ Text(vm.tr(key))
+ .font(.title3.weight(.bold))
+ .padding(.top, 8)
+ }
+
+ private func listenControls(_ text: String) -> some View {
+ let label: String = switch speaker.phase {
+ case .playing: vm.tr("en_pause")
+ case .paused: vm.tr("en_resume")
+ case .idle: vm.tr("en_play")
+ }
+ return VStack(alignment: .leading, spacing: 12) {
+ Button(label) {
+ played = true
+ speaker.toggle(text)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .frame(maxWidth: .infinity)
+ listenSlider(vm.tr("en_speed"), value: Double(speaker.rate), range: 0.6...1.5, caption: String(format: "%.1f×", speaker.rate)) { next in
+ speaker.rate = Float(next)
+ }
+ listenSlider(vm.tr("en_volume"), value: Double(speaker.volume), range: 0...1, caption: "\(Int(speaker.volume * 100)) %") { next in
+ speaker.volume = Float(next)
+ }
+ }
+ .frame(width: 228)
+ }
+
+ private func listenSlider(_ title: String, value: Double, range: ClosedRange, caption: String, set: @escaping (Double) -> Void) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ HStack(spacing: 8) {
+ Slider(
+ value: Binding(get: { value }, set: set),
+ in: range,
+ onEditingChanged: { editing in
+ if !editing { speaker.applySettings() }
+ }
+ )
+ .controlSize(.small)
+ Text(caption)
+ .font(.caption.weight(.semibold))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .frame(width: 46, alignment: .trailing)
+ }
+ }
+ }
+
+ private func choiceBlock(_ questions: [J], picks: Binding<[Int]>) -> some View {
+ let p = vm.palette
+ return ForEach(questions.indices, id: \.self) { i in
+ let q = questions[i]
+ VStack(alignment: .leading, spacing: 6) {
+ Text("\(i + 1). \(q.str("prompt"))")
+ .font(.body.weight(.semibold))
+ ForEach(Array(q.strs("options").enumerated()), id: \.offset) { o, opt in
+ let selected = picks.wrappedValue[i] == o
+ let mark: Bool? = revealed && selected ? picks.wrappedValue[i] == q.int("correct") : nil
+ OptionChip(text: opt, selected: selected, mark: mark) {
+ picks.wrappedValue[i] = o
+ }
+ }
+ if revealed, let expl = q.strOrNull("expl") {
+ Meaning(text: expl, palette: p, visible: true)
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+
+ private func check(_ lesson: J) {
+ if lesson.strOrNull("listening") != nil && !played {
+ fb = (vm.tr("en_listen_first"), "err")
+ return
+ }
+ let readOk = score(lesson.arr("readQuiz"), readPicks)
+ let listenOk = score(lesson.arr("listenQuiz"), listenPicks)
+ let quizOk = score(lesson.arr("quiz"), quizPicks)
+ let gapRows = lesson.arr("gaps")
+ let gapOk = gapRows.indices.filter {
+ answerAccepts(normalizeAnswer(gaps[$0]), gapRows[$0].str("answers"))
+ }.count
+ var writeOk = true
+ if let writing = lesson.obj("writing") {
+ let keys = writing.str("keys").split(separator: "|").map(String.init)
+ let enough = wordCount(essay) >= writing.int("minWords", 20)
+ writeOk = enough && keys.allSatisfy { hasWord(essay, $0) }
+ }
+ revealed = true
+ let all = readOk && listenOk && quizOk && gapOk == gapRows.count && writeOk
+ if all {
+ fb = (vm.tr("feedback_ok"), "ok")
+ showModel = true
+ mark(vm, year, id)
+ } else if lesson.obj("writing") != nil && !writeOk && readOk && listenOk && quizOk && gapOk == gapRows.count {
+ fb = (vm.tr("en_write_short"), "err")
+ } else {
+ fb = (vm.tr("feedback_retry_short"), "err")
+ }
+ }
+
+ private func score(_ questions: [J], _ picks: [Int]) -> Bool {
+ questions.indices.allSatisfy { picks[$0] == questions[$0].int("correct") }
+ }
+
+ private func wordCount(_ text: String) -> Int {
+ let n = normalizeAnswer(text)
+ return n.isEmpty ? 0 : n.split(separator: " ").count
+ }
+
+ private func hasWord(_ text: String, _ word: String) -> Bool {
+ let n = " \(normalizeAnswer(text)) "
+ let w = normalizeAnswer(word)
+ return !w.isEmpty && n.contains(" \(w) ")
+ }
+}
+
diff --git a/ios/Graduately/UI/Icons.swift b/ios/Graduately/UI/Icons.swift
new file mode 100644
index 0000000..6434277
--- /dev/null
+++ b/ios/Graduately/UI/Icons.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+// Circular flags used as subject glyphs; everything else is an SF Symbol.
+
+struct GermanFlag: View {
+ var size: CGFloat = 48
+ var body: some View {
+ Canvas { ctx, sz in
+ let band = sz.height / 3
+ ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
+ ctx.fill(Path(CGRect(x: 0, y: 0, width: sz.width, height: band)), with: .color(Color(red: 0.18, green: 0.18, blue: 0.19)))
+ ctx.fill(Path(CGRect(x: 0, y: band, width: sz.width, height: band)), with: .color(Color(red: 0.72, green: 0.27, blue: 0.24)))
+ ctx.fill(Path(CGRect(x: 0, y: 2 * band, width: sz.width, height: band)), with: .color(Color(red: 0.84, green: 0.70, blue: 0.32)))
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CzechFlag: View {
+ var size: CGFloat = 48
+ var body: some View {
+ Canvas { ctx, sz in
+ ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
+ ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(white: 0.98)))
+ ctx.fill(Path(CGRect(x: 0, y: sz.height / 2, width: sz.width, height: sz.height / 2)), with: .color(Color(red: 0.83, green: 0.16, blue: 0.18)))
+ var tri = Path()
+ tri.move(to: .zero)
+ tri.addLine(to: CGPoint(x: sz.width * 0.55, y: sz.height / 2))
+ tri.addLine(to: CGPoint(x: 0, y: sz.height))
+ tri.closeSubpath()
+ ctx.fill(tri, with: .color(Color(red: 0.07, green: 0.24, blue: 0.55)))
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct UkFlag: View {
+ var size: CGFloat = 48
+ var body: some View {
+ Canvas { ctx, sz in
+ let w = sz.width
+ let h = sz.height
+ ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
+ ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(red: 0.01, green: 0.14, blue: 0.40)))
+ var saltire = Path()
+ saltire.move(to: .zero)
+ saltire.addLine(to: CGPoint(x: w, y: h))
+ saltire.move(to: CGPoint(x: w, y: 0))
+ saltire.addLine(to: CGPoint(x: 0, y: h))
+ ctx.stroke(saltire, with: .color(.white), lineWidth: w * 0.16)
+ ctx.stroke(saltire, with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)), lineWidth: w * 0.055)
+ ctx.fill(Path(CGRect(x: 0, y: h * 0.38, width: w, height: h * 0.24)), with: .color(.white))
+ ctx.fill(Path(CGRect(x: w * 0.38, y: 0, width: w * 0.24, height: h)), with: .color(.white))
+ ctx.fill(Path(CGRect(x: 0, y: h * 0.43, width: w, height: h * 0.14)), with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)))
+ ctx.fill(Path(CGRect(x: w * 0.43, y: 0, width: w * 0.14, height: h)), with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)))
+ }
+ .frame(width: size, height: size)
+ }
+}
diff --git a/ios/Graduately/UI/PathMap.swift b/ios/Graduately/UI/PathMap.swift
new file mode 100644
index 0000000..5c7e4e4
--- /dev/null
+++ b/ios/Graduately/UI/PathMap.swift
@@ -0,0 +1,376 @@
+import SwiftUI
+
+struct MapNode: Identifiable {
+ let id: String
+ let label: String
+ let locked: Bool
+ let done: Bool
+ let current: Bool
+ var finish: Bool = false
+ var destination: Route?
+ /// Glyph for a node that has no number, such as a side branch.
+ var symbol: String? = nil
+}
+
+private struct NodePos {
+ var x: CGFloat
+ var y: CGFloat
+ var row: Int
+}
+
+private struct Serpentine {
+ let points: [NodePos]
+ let width: CGFloat
+ let height: CGFloat
+ let spacing: CGFloat
+}
+
+/// Keep enough room under/above each node for a two-line caption.
+#if os(macOS)
+private let labelBand: CGFloat = 56
+#else
+private let labelBand: CGFloat = 44
+#endif
+
+private func serpentine(
+ count n: Int,
+ avail: CGFloat,
+ mx: CGFloat,
+ my: CGFloat,
+ spac: CGFloat,
+ gap: CGFloat,
+ wave: CGFloat,
+ nodeSize: CGFloat,
+ lift: CGFloat = 0
+) -> Serpentine {
+ guard n > 0 else {
+ return Serpentine(points: [], width: avail, height: my * 2, spacing: spac)
+ }
+
+ // Pick the fewest rows that fit the preferred spacing in the available width.
+ // Do NOT pre-scale as if every node were on one row — that crushed tall maps.
+ var rows = 1
+ var cols = n
+ while rows <= n {
+ cols = max(1, (n + rows - 1) / rows)
+ if 2 * mx + CGFloat(cols - 1) * spac <= avail + 1 { break }
+ rows += 1
+ }
+ rows = min(rows, n)
+ cols = max(1, (n + rows - 1) / rows)
+
+ let needed = 2 * mx + CGFloat(cols - 1) * spac
+ let scale = needed > avail ? max(0.72, avail / needed) : 1
+
+ let smx = mx * scale
+ let smy = max(my * scale, nodeSize / 2 + labelBand + 8)
+ let sspac = max(spac * scale, nodeSize + 36)
+ // Gap must clear labels that sit between rows (outward placement still
+ // needs space when wave lifts nodes toward each other).
+ let sgap = max(gap * scale, nodeSize + labelBand * 2 + 28)
+ let swave = min(wave * scale, sgap * 0.12)
+ let slift = lift * scale
+
+ let cw = 2 * smx + CGFloat(cols - 1) * sspac
+ let ch = 2 * smy + CGFloat(rows - 1) * sgap + slift + labelBand
+ let phase = CGFloat(2 * Double.pi * 1.7 / Double(max(n - 1, 1)))
+
+ var pts = Array(repeating: NodePos(x: 0, y: 0, row: 0), count: n)
+ for r in 0.. NodePos {
+ let n = pts.count
+ guard n > 1 else { return pts.first ?? NodePos(x: 0, y: 0, row: 0) }
+ var k = Int(t)
+ var u = t - CGFloat(k)
+ if k < 0 { k = 0; u = 0 }
+ if k >= n - 1 { k = n - 2; u = 1 }
+ let p1 = pts[k]
+ let p2 = pts[k + 1]
+ let p0 = k - 1 >= 0
+ ? pts[k - 1]
+ : NodePos(x: p1.x - (p2.x - p1.x), y: p1.y - (p2.y - p1.y), row: p1.row)
+ let p3 = k + 2 < n
+ ? pts[k + 2]
+ : NodePos(x: p2.x + (p2.x - p1.x), y: p2.y + (p2.y - p1.y), row: p2.row)
+ let u2 = u * u
+ let u3 = u2 * u
+ let x = 0.5 * (2 * p1.x + (-p0.x + p2.x) * u
+ + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * u2
+ + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * u3)
+ let y = 0.5 * (2 * p1.y + (-p0.y + p2.y) * u
+ + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2
+ + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3)
+ return NodePos(x: x, y: y, row: p1.row)
+}
+
+struct PathMap: View {
+ let nodes: [MapNode]
+ let palette: Palette
+ var litUntil: CGFloat = 0
+ var nodeSize: CGFloat = 64
+ var mx: CGFloat = 90
+ var my: CGFloat = 110
+ var spac: CGFloat = 170
+ var gap: CGFloat = 220
+ var wave: CGFloat = 18
+ var branch: (index: Int, node: MapNode)? = nil
+
+ var body: some View {
+ GeometryReader { geo in
+ let layout = serpentine(
+ count: nodes.count,
+ avail: max(geo.size.width - 24, 480),
+ mx: mx,
+ my: my,
+ spac: spac,
+ gap: gap,
+ wave: wave,
+ nodeSize: nodeSize,
+ lift: branch == nil ? 0 : 56
+ )
+ let labelWidth = min(140, max(88, layout.spacing - 16))
+ let canvasH = layout.height + labelBand + 24
+ ScrollView([.horizontal, .vertical]) {
+ ZStack(alignment: .topLeading) {
+ railCanvas(points: layout.points)
+ .frame(width: layout.width, height: canvasH)
+
+ ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
+ let pt = layout.points[i]
+ let labelBelow = pt.row % 2 == 0
+ nodeView(node, index: i)
+ .position(x: pt.x, y: pt.y)
+ Text(node.label)
+ #if os(macOS)
+ .font(.callout.weight(.semibold))
+ #else
+ .font(.caption2.weight(.semibold))
+ #endif
+ .foregroundStyle(node.locked ? palette.overlay : palette.text)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .minimumScaleFactor(0.85)
+ .frame(width: labelWidth, height: labelBand, alignment: labelBelow ? .top : .bottom)
+ .position(
+ x: pt.x,
+ y: labelBelow
+ ? pt.y + nodeSize / 2 + labelBand / 2 + 6
+ : pt.y - nodeSize / 2 - labelBand / 2 - 6
+ )
+ }
+
+ if let branch, branch.index < layout.points.count {
+ let base = layout.points[branch.index]
+ let tip = NodePos(x: base.x + 64, y: base.y - 128, row: base.row)
+ Path { p in
+ p.move(to: CGPoint(x: base.x, y: base.y - nodeSize / 2))
+ p.addQuadCurve(
+ to: CGPoint(x: tip.x, y: tip.y + nodeSize / 2),
+ control: CGPoint(x: base.x + 14, y: tip.y + 28)
+ )
+ }
+ .stroke(palette.rail, style: StrokeStyle(lineWidth: 10, lineCap: .round))
+ nodeView(branch.node, index: nil)
+ .position(x: tip.x, y: tip.y)
+ Text(branch.node.label)
+ #if os(macOS)
+ .font(.callout.weight(.semibold))
+ #else
+ .font(.caption2.weight(.semibold))
+ #endif
+ .foregroundStyle(palette.text)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .frame(width: labelWidth, height: labelBand, alignment: .bottom)
+ .position(x: tip.x, y: tip.y - nodeSize / 2 - labelBand / 2 - 6)
+ }
+ }
+ .frame(width: layout.width + 24, height: canvasH, alignment: .topLeading)
+ .frame(
+ minWidth: geo.size.width,
+ minHeight: max(canvasH, geo.size.height),
+ alignment: Alignment(
+ horizontal: layout.width + 24 < geo.size.width ? .center : .leading,
+ vertical: canvasH < geo.size.height ? .center : .top
+ )
+ )
+ }
+ }
+ #if os(macOS)
+ .frame(minHeight: 520)
+ #else
+ .frame(minHeight: 420)
+ #endif
+ .softAppear()
+ }
+
+ @ViewBuilder
+ private func railCanvas(points: [NodePos]) -> some View {
+ Canvas { ctx, _ in
+ guard points.count >= 2 else { return }
+ func stroke(_ t0: CGFloat, _ t1: CGFloat, color: Color, width: CGFloat, dashed: Bool = false) {
+ var path = Path()
+ let steps = max(1, Int((t1 - t0) * 48))
+ let start = catmull(points, t0)
+ path.move(to: CGPoint(x: start.x, y: start.y))
+ for s in 1...steps {
+ let t = t0 + (t1 - t0) * CGFloat(s) / CGFloat(steps)
+ let pt = catmull(points, t)
+ path.addLine(to: CGPoint(x: pt.x, y: pt.y))
+ }
+ var style = StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round)
+ if dashed { style.dash = [1, 18] }
+ ctx.stroke(path, with: .color(color), style: style)
+ }
+ let end = CGFloat(points.count - 1)
+ stroke(0, end, color: palette.rail, width: 10)
+ if litUntil > 0 {
+ stroke(0, min(litUntil, end), color: palette.accent, width: 10)
+ }
+ let mutedStart = litUntil > 0 ? litUntil : 0
+ if mutedStart < end {
+ stroke(mutedStart, end, color: palette.subtext.opacity(0.22), width: 10, dashed: true)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func nodeView(_ node: MapNode, index: Int?) -> some View {
+ let content = ZStack {
+ Circle()
+ .fill(nodeFill(node))
+ .overlay {
+ if node.locked {
+ Circle().strokeBorder(palette.lockedBorder, lineWidth: 1)
+ } else if !node.current && !node.done {
+ Circle().strokeBorder(palette.accent.opacity(0.55), lineWidth: 2.5)
+ }
+ }
+ if node.finish {
+ VStack(spacing: 2) {
+ if let index {
+ Text("\(index + 1)")
+ #if os(macOS)
+ .font(.title2.weight(.black))
+ #else
+ .font(.headline.weight(.black))
+ #endif
+ }
+ FinishFlagBadge(palette: palette)
+ }
+ .foregroundStyle(palette.overlay)
+ } else if node.locked {
+ VStack(spacing: 2) {
+ if let index {
+ Text("\(index + 1)")
+ #if os(macOS)
+ .font(.title2.weight(.black))
+ #else
+ .font(.headline.weight(.black))
+ #endif
+ }
+ Image(systemName: "lock.fill")
+ #if os(macOS)
+ .font(.caption.weight(.bold))
+ #else
+ .font(.caption2.weight(.bold))
+ #endif
+ }
+ .foregroundStyle(palette.overlay)
+ } else if node.done {
+ VStack(spacing: 2) {
+ if let index {
+ Text("\(index + 1)")
+ #if os(macOS)
+ .font(.title2.weight(.black))
+ #else
+ .font(.headline.weight(.black))
+ #endif
+ } else if let symbol = node.symbol {
+ Image(systemName: symbol)
+ #if os(macOS)
+ .font(.title3.weight(.semibold))
+ #else
+ .font(.body.weight(.semibold))
+ #endif
+ }
+ Image(systemName: "checkmark")
+ #if os(macOS)
+ .font(.caption.weight(.bold))
+ #else
+ .font(.caption2.weight(.bold))
+ #endif
+ }
+ .foregroundStyle(palette.onAccent)
+ } else if let index {
+ Text("\(index + 1)")
+ #if os(macOS)
+ .font(.title2.weight(.black))
+ #else
+ .font(.headline.weight(.black))
+ #endif
+ .foregroundStyle(node.current ? palette.onAccent : palette.text)
+ } else if let symbol = node.symbol {
+ Image(systemName: symbol)
+ #if os(macOS)
+ .font(.title2.weight(.semibold))
+ #else
+ .font(.headline.weight(.semibold))
+ #endif
+ .foregroundStyle(node.current ? palette.onAccent : palette.text)
+ }
+ }
+ .frame(width: nodeSize, height: nodeSize)
+ .shadow(color: .black.opacity(node.locked ? 0 : 0.12), radius: 6, y: 2)
+
+ if let dest = node.destination, !node.locked {
+ NavigationLink(value: dest) { content }
+ .buttonStyle(PressScaleStyle())
+ } else {
+ content.opacity(node.locked ? 0.55 : 1)
+ }
+ }
+
+ private func nodeFill(_ node: MapNode) -> Color {
+ if node.locked { return palette.lockedBg }
+ if node.current || node.done { return palette.accent }
+ return palette.node
+ }
+}
+
+private struct FinishFlagBadge: View {
+ let palette: Palette
+
+ var body: some View {
+ let cell: CGFloat = 4
+ Canvas { ctx, _ in
+ for iy in 0..<2 {
+ for ix in 0..<3 {
+ let dark = ((ix + iy) & 1) == 1
+ let rect = CGRect(x: CGFloat(ix) * cell, y: CGFloat(iy) * cell, width: cell, height: cell)
+ ctx.fill(
+ Path(rect),
+ with: .color(dark ? palette.finishLight : palette.finishStroke)
+ )
+ }
+ }
+ }
+ .frame(width: cell * 3, height: cell * 2)
+ }
+}
diff --git a/ios/Graduately/UI/Screens.swift b/ios/Graduately/UI/Screens.swift
new file mode 100644
index 0000000..d4535b2
--- /dev/null
+++ b/ios/Graduately/UI/Screens.swift
@@ -0,0 +1,3918 @@
+import SwiftUI
+#if os(macOS)
+import AppKit
+#endif
+
+struct PracticeHome: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ macHome.softAppear()
+ #else
+ iosHome.softAppear()
+ #endif
+ }
+
+ #if os(macOS)
+ private var macHome: some View {
+ let sum = summarize(vm.content, vm.progress)
+ let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
+ let open = vm.content.subjects.filter { $0.bool("open") }
+ let soon = vm.content.subjects.filter { !$0.bool("open") }
+ return ScrollView {
+ VStack(alignment: .leading, spacing: 28) {
+ if vm.showChangelog {
+ ChangelogCard(vm: vm)
+ }
+ welcomeCard(sum: sum, pct: pct)
+ VStack(alignment: .leading, spacing: 14) {
+ Text(vm.tr("subjects_title"))
+ .font(.title2.weight(.semibold))
+ LazyVGrid(
+ columns: [GridItem(.adaptive(minimum: MacChrome.homeTileMin), spacing: 16, alignment: .top)],
+ alignment: .leading,
+ spacing: 16
+ ) {
+ ForEach(Array(open.enumerated()), id: \.offset) { _, s in
+ subjectTile(s)
+ }
+ }
+ }
+ if !soon.isEmpty {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(vm.tr("subjects_soon"))
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ FlowLayout(spacing: 10) {
+ ForEach(Array(soon.enumerated()), id: \.offset) { _, s in
+ Label(vm.tr(s.str("key")), systemImage: "lock.fill")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ .background(Color.primary.opacity(0.05), in: Capsule())
+ }
+ }
+ }
+ }
+ }
+ .padding(MacChrome.pageInset)
+ .macPage()
+ }
+ .navigationTitle("Graduately")
+ }
+
+ private func welcomeCard(sum: ProgressSum, pct: Double) -> some View {
+ GlassCard {
+ HStack(alignment: .center, spacing: 32) {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(vm.tr("welcome_title"))
+ .font(.system(size: 34, weight: .bold))
+ .fixedSize(horizontal: false, vertical: true)
+ Text(vm.tr(Self.welcomeBodyKey))
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ ProgressRing(value: pct) {
+ VStack(spacing: 2) {
+ Text(vm.fmt("stats_pct_fmt", Int((pct * 100).rounded(.down))))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .monospacedDigit()
+ Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
+ .font(.subheadline.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 132, height: 132)
+ }
+ .padding(.vertical, 6)
+ }
+ }
+
+ @ViewBuilder
+ private func subjectTile(_ s: J) -> some View {
+ let dest = s.strOrNull("target").flatMap(routeFromPage)
+ if let dest, dest != .subjects, dest != .welcome {
+ NavigationLink(value: dest) {
+ subjectTileBody(s)
+ }
+ .buttonStyle(PressScaleStyle())
+ } else {
+ subjectTileBody(s)
+ }
+ }
+
+ private func subjectTileBody(_ s: J) -> some View {
+ let part = subjectSum(vm, s)
+ let progress = part.totalEx > 0 ? Double(part.doneEx) / Double(part.totalEx) : 0
+ return VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .center) {
+ SubjectIcon(icon: s.str("icon"), open: true, size: 40)
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.right")
+ .font(.body.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
+ Text(vm.tr(s.str("key")))
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ ProgressBar(value: progress)
+ HStack {
+ Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
+ Spacer(minLength: 0)
+ Text(vm.fmt("stats_pct_fmt", Int((progress * 100).rounded(.down))))
+ }
+ .font(.subheadline.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ .padding(20)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .frame(height: MacChrome.homeTileHeight)
+ .softSurface(cornerRadius: 22)
+ .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+ }
+ #endif
+
+ #if os(iOS)
+ private var iosHome: some View {
+ let sum = summarize(vm.content, vm.progress)
+ let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
+ return List {
+ if vm.showChangelog {
+ Section {
+ ChangelogCard(vm: vm)
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ }
+ Section {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(vm.tr("welcome_title"))
+ .font(.title.bold())
+ Text(vm.tr(Self.welcomeBodyKey))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ ProgressView(value: pct)
+ .animation(Motion.settle, value: pct)
+ Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+
+ Section(vm.tr("subjects_title")) {
+ ForEach(Array(vm.content.subjects.filter { $0.bool("open") }.enumerated()), id: \.offset) { _, s in
+ let dest = s.strOrNull("target").flatMap(routeFromPage)
+ if let dest, dest != .subjects, dest != .welcome {
+ NavigationLink(value: dest) {
+ subjectLabel(s)
+ }
+ } else {
+ subjectLabel(s)
+ }
+ }
+ }
+
+ let soon = vm.content.subjects.filter { !$0.bool("open") }
+ if !soon.isEmpty {
+ Section(vm.tr("subjects_soon")) {
+ ForEach(Array(soon.enumerated()), id: \.offset) { _, s in
+ Label {
+ Text(vm.tr(s.str("key")))
+ } icon: {
+ SubjectIcon(icon: s.str("icon"), open: false)
+ }
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .navigationTitle("Graduately")
+ .navigationBarTitleDisplayMode(.large)
+ }
+
+ private func subjectLabel(_ s: J) -> some View {
+ let part = subjectSum(vm, s)
+ return Label {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(vm.tr(s.str("key")))
+ if part.totalEx > 0 {
+ ProgressLine(done: part.doneEx, total: part.totalEx)
+ }
+ }
+ .padding(.vertical, 2)
+ } icon: {
+ SubjectIcon(icon: s.str("icon"), open: true)
+ }
+ }
+ #endif
+
+ private static var welcomeBodyKey: String {
+ #if os(macOS)
+ "welcome_body_macos"
+ #else
+ "welcome_body_ios"
+ #endif
+ }
+}
+
+struct ChangelogCard: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let langKey = vm.lang == .en ? "en" : "cs"
+ let blocks = Array(vm.content.changelog.prefix(3))
+ GlassCard {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Label(vm.tr("changelog_title"), systemImage: "sparkles")
+ .font(.headline)
+ Spacer()
+ if let date = blocks.first?.strOrNull("date") {
+ Text(date)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ }
+ ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in
+ ChangelogLines(index: index, lines: changelogLines(block, langKey))
+ }
+ Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() }
+ #if os(macOS)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ #else
+ .buttonStyle(.glassProminent)
+ .controlSize(.small)
+ #endif
+ }
+ }
+ }
+}
+
+private func changelogLines(_ block: J, _ langKey: String) -> [String] {
+ let preferred = block.strs(langKey)
+ return preferred.isEmpty ? block.strs("cs") : preferred
+}
+
+private struct ChangelogLines: View {
+ let index: Int
+ let lines: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ if index > 0 { Spacer().frame(height: 4) }
+ VStack(alignment: .leading, spacing: 4) { lineList }
+ }
+ }
+
+ @ViewBuilder
+ private var lineList: some View {
+ ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
+ Text("• \(line)")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+struct SettingsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ macSettings
+ #else
+ settingsForm
+ #endif
+ }
+
+ #if os(iOS)
+ private var settingsForm: some View {
+ Form {
+ Section(vm.tr("mode")) {
+ Picker(vm.tr("mode"), selection: modeBinding) {
+ Text(vm.tr("mode_dark")).tag(ColorMode.dark)
+ Text(vm.tr("mode_light")).tag(ColorMode.light)
+ }
+ .pickerStyle(.segmented)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+ Section(vm.tr("theme")) {
+ ForEach(Array(ThemeId.allCases), id: \.self) { id in
+ Button {
+ vm.setTheme(id)
+ } label: {
+ HStack(spacing: 12) {
+ Circle().fill(themePalette(id, vm.mode).tint)
+ .frame(width: {
+ #if os(macOS)
+ 18
+ #else
+ 14
+ #endif
+ }(), height: {
+ #if os(macOS)
+ 18
+ #else
+ 14
+ #endif
+ }())
+ Text(themeNames[id.rawValue])
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ .foregroundStyle(.primary)
+ Spacer()
+ if vm.themeId == id {
+ Image(systemName: "checkmark").fontWeight(.semibold)
+ }
+ }
+ #if os(macOS)
+ .padding(.vertical, 4)
+ #endif
+ }
+ }
+ }
+ Section(vm.tr("language")) {
+ Picker(vm.tr("language"), selection: langBinding) {
+ Text("Čeština").tag(UiLang.cs)
+ Text("English").tag(UiLang.en)
+ }
+ .pickerStyle(.segmented)
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+ Section(vm.tr("updates")) {
+ Text(updateText)
+ #if os(macOS)
+ .font(.body)
+ #endif
+ .foregroundStyle(.secondary)
+ Button(vm.tr("update_check")) { vm.checkUpdate(true) }
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ if vm.update.canInstall {
+ Button(vm.tr("update_install")) { vm.installUpdate() }
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ }
+ LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
+ }
+ }
+ .navigationTitle(vm.tr("settings_title"))
+ .appFormStyle()
+ }
+ #endif
+
+ #if os(macOS)
+ private var macSettings: some View {
+ let themes = Array(ThemeId.allCases)
+ return VStack(alignment: .leading, spacing: 20) {
+ HStack(alignment: .top, spacing: 16) {
+ settingsCard(vm.tr("mode")) {
+ Picker(vm.tr("mode"), selection: modeBinding) {
+ Text(vm.tr("mode_dark")).tag(ColorMode.dark)
+ Text(vm.tr("mode_light")).tag(ColorMode.light)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .controlSize(.large)
+ }
+ settingsCard(vm.tr("language")) {
+ Picker(vm.tr("language"), selection: langBinding) {
+ Text("Čeština").tag(UiLang.cs)
+ Text("English").tag(UiLang.en)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .controlSize(.large)
+ }
+ settingsCard(vm.tr("updates")) {
+ Text(updateText)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button(vm.tr("update_check")) { vm.checkUpdate(true) }
+ .controlSize(.large)
+ if vm.update.canInstall {
+ Button(vm.tr("update_install")) { vm.installUpdate() }
+ .controlSize(.large)
+ }
+ LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
+ }
+ }
+ .layoutPriority(1)
+ Text(vm.tr("theme"))
+ .font(.title2.weight(.semibold))
+ .layoutPriority(1)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12)], spacing: 12) {
+ ForEach(themes, id: \.self) { id in
+ Button {
+ vm.setTheme(id)
+ } label: {
+ HStack(spacing: 12) {
+ Circle().fill(themePalette(id, vm.mode).tint)
+ .frame(width: 18, height: 18)
+ Text(themeNames[id.rawValue])
+ .font(.title3)
+ .foregroundStyle(.primary)
+ Spacer(minLength: 0)
+ if vm.themeId == id {
+ Image(systemName: "checkmark").fontWeight(.semibold)
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
+ .softSurface(cornerRadius: 16)
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ }
+ .padding(MacChrome.pageInset)
+ .frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .navigationTitle(vm.tr("settings_title"))
+ }
+
+ private func settingsCard(_ title: String, @ViewBuilder content: @escaping () -> Content) -> some View {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title).font(.headline)
+ content()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ #endif
+
+ private var modeBinding: Binding {
+ Binding(get: { vm.mode }, set: { vm.applyMode($0) })
+ }
+
+ private var langBinding: Binding {
+ Binding(get: { vm.lang }, set: { vm.applyLang($0) })
+ }
+
+ private var updateText: String {
+ if let arg = vm.update.messageArg {
+ return vm.fmt(vm.update.messageKey, arg)
+ }
+ return vm.tr(vm.update.messageKey)
+ }
+}
+
+struct StatsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ macStats
+ #else
+ iosStats
+ #endif
+ }
+
+ #if os(iOS)
+ private var iosStats: some View {
+ let sum = summarize(vm.content, vm.progress)
+ let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
+ return List {
+ Section {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top) {
+ stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
+ stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
+ stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
+ }
+ ProgressView(value: pct)
+ .animation(Motion.settle, value: pct)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ Section(vm.tr("stats_section")) {
+ ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
+ let open = s.bool("open")
+ let part = subjectSum(vm, s)
+ Label {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text(vm.tr(s.str("key")))
+ #if os(macOS)
+ .font(.title3)
+ #endif
+ Spacer()
+ Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.caption)
+ #endif
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+ if open && part.totalEx > 0 {
+ ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
+ .animation(Motion.settle, value: part.doneEx)
+ }
+ }
+ } icon: {
+ SubjectIcon(icon: s.str("icon"), open: open, size: {
+ #if os(macOS)
+ 32
+ #else
+ 26
+ #endif
+ }())
+ }
+ .foregroundStyle(open ? .primary : .secondary)
+ }
+ }
+ }
+ .navigationTitle(vm.tr("stats_title"))
+ .appListStyle()
+ }
+ #endif
+
+ #if os(macOS)
+ private var macStats: some View {
+ let sum = summarize(vm.content, vm.progress)
+ let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
+ let ranked = rankedSubjects()
+ return VStack(alignment: .leading, spacing: 22) {
+ HStack(spacing: 16) {
+ statMetric(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
+ statMetric(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
+ statMetric(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
+ }
+ Text(vm.tr("stats_section"))
+ .font(.title2.weight(.semibold))
+ GeometryReader { geo in
+ let count = max(ranked.count, 1)
+ let minRow: CGFloat = 54
+ let floorH = minRow * CGFloat(ranked.count)
+ let rowH = min(76, max(minRow, geo.size.height / CGFloat(count)))
+ let table = VStack(spacing: 0) {
+ ForEach(Array(ranked.enumerated()), id: \.offset) { index, item in
+ statRow(item.subject, part: item.part)
+ .frame(height: floorH > geo.size.height + 1 ? minRow : rowH)
+ if index < ranked.count - 1 {
+ Divider().padding(.leading, 68)
+ }
+ }
+ }
+ .softSurface(cornerRadius: 18)
+ if floorH > geo.size.height + 1 {
+ ScrollView { table }
+ } else {
+ table.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ }
+ }
+ }
+ .padding(MacChrome.pageInset)
+ .frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .navigationTitle(vm.tr("stats_title"))
+ }
+
+ private func rankedSubjects() -> [(subject: J, part: ProgressSum)] {
+ let rows = vm.content.subjects.map { subject in
+ (subject: subject, part: subjectSum(vm, subject))
+ }
+ let open = rows.filter { $0.subject.bool("open") }.sorted { lhs, rhs in
+ share(lhs.part) > share(rhs.part)
+ }
+ let locked = rows.filter { !$0.subject.bool("open") }
+ return open + locked
+ }
+
+ private func share(_ part: ProgressSum) -> Double {
+ part.totalEx == 0 ? 0 : Double(part.doneEx) / Double(part.totalEx)
+ }
+
+ private func statMetric(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(value)
+ .font(.system(size: 40, weight: .bold, design: .rounded))
+ .monospacedDigit()
+ .minimumScaleFactor(0.6)
+ .lineLimit(1)
+ Text(label)
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(22)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .softSurface(cornerRadius: 18)
+ }
+
+ private func statRow(_ s: J, part: ProgressSum) -> some View {
+ let open = s.bool("open")
+ let progress = share(part)
+ return HStack(spacing: 16) {
+ SubjectIcon(icon: s.str("icon"), open: open, size: 28)
+ .frame(width: 36)
+ Text(vm.tr(s.str("key")))
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(open ? .primary : .secondary)
+ .lineLimit(1)
+ .frame(width: 240, alignment: .leading)
+ if open && part.totalEx > 0 {
+ statTrack(progress)
+ Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
+ .font(.title3.monospacedDigit())
+ .foregroundStyle(.secondary)
+ .frame(width: 88, alignment: .trailing)
+ } else {
+ statTrack(0)
+ Text(vm.tr("stats_locked"))
+ .font(.body)
+ .foregroundStyle(.tertiary)
+ .frame(width: 88, alignment: .trailing)
+ }
+ }
+ .padding(.horizontal, 18)
+ }
+
+ private func statTrack(_ progress: Double) -> some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.08))
+ Capsule()
+ .fill(Color.accentColor)
+ .frame(width: max(0, geo.size.width * progress))
+ }
+ }
+ .frame(height: 12)
+ .frame(maxWidth: .infinity)
+ }
+ #endif
+
+ private func stat(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label)
+ #if os(macOS)
+ .font(.subheadline)
+ #else
+ .font(.caption)
+ #endif
+ .foregroundStyle(.secondary)
+ Text(value)
+ #if os(macOS)
+ .font(.title2.bold())
+ #else
+ .font(.title3.bold())
+ #endif
+ .monospacedDigit()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+/// Done and total exercises behind one card of a years screen, or nil when there is no count.
+func routeProgress(_ vm: AppModel, _ r: Route) -> (done: Int, total: Int)? {
+ let c = vm.content
+ let p = vm.progress
+ func tally(_ lessons: [J], _ done: (Int) -> Bool) -> (done: Int, total: Int) {
+ (lessons.filter { done($0.int("id")) }.count, lessons.count)
+ }
+ switch r {
+ case .netMap: return tally(c.netLessons, p.netDone)
+ case .net2Map: return tally(c.itLessons("net2")) { p.courseDone("net2", $0) }
+ case .net3Map: return tally(c.itLessons("net3")) { p.courseDone("net3", $0) }
+ case .net4Map: return tally(c.itLessons("net4")) { p.courseDone("net4", $0) }
+ case .hwMap: return tally(c.hw, p.hwDone)
+ case .hw2Map: return tally(c.itLessons("hw2")) { p.courseDone("hw2", $0) }
+ case .hw3Map: return tally(c.itLessons("hw3")) { p.courseDone("hw3", $0) }
+ case .hw4Map: return tally(c.itLessons("hw4")) { p.courseDone("hw4", $0) }
+ case .onMap: return tally(c.on, p.onDone)
+ case .on2Map: return tally(c.on2, p.on2Done)
+ case .on3Map: return tally(c.on3, p.on3Done)
+ case .on4Map: return tally(c.on4, p.on4Done)
+ case .enMap(let year): return tally(c.enYear(year)) { p.enDone(year, $0) }
+ case .deMap(let year): return tally(c.deYear(year)) { p.deDone(year, $0) }
+ case .roadmap:
+ var done = 0
+ var total = 0
+ for u in c.german where u.bool("unlocked") {
+ let n = u.strs("names").count
+ total += n
+ done += (0.. ProgressSum {
+ var sum = ProgressSum()
+ switch s.str("target") {
+ case "dehome":
+ for u in vm.content.german where u.bool("unlocked") {
+ let n = u.strs("names").count
+ sum.totalEx += n
+ sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count
+ }
+ sum.totalEx += (1...4).reduce(0) { $0 + vm.content.deYear($1).count }
+ sum.doneEx += (1...4).reduce(0) { acc, year in
+ acc + vm.content.deYear(year).filter { vm.progress.deDone(year, $0.int("id")) }.count
+ }
+ case "netyears":
+ sum.totalEx = vm.content.netLessons.count
+ + vm.content.net2.count + vm.content.net3.count + vm.content.net4.count
+ sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
+ + ["net2", "net3", "net4"].reduce(0) { acc, course in
+ acc + vm.content.itLessons(course).filter { vm.progress.courseDone(course, $0.int("id")) }.count
+ }
+ case "hwyears", "hwmap":
+ sum.totalEx = vm.content.hw.count
+ + vm.content.hw2.count + vm.content.hw3.count + vm.content.hw4.count
+ sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
+ + ["hw2", "hw3", "hw4"].reduce(0) { acc, course in
+ acc + vm.content.itLessons(course).filter { vm.progress.courseDone(course, $0.int("id")) }.count
+ }
+ case "enyears", "en1map", "en2map", "en3map", "en4map":
+ sum.totalEx = (1...4).reduce(0) { $0 + vm.content.enYear($1).count }
+ sum.doneEx = (1...4).reduce(0) { acc, year in
+ acc + vm.content.enYear(year).filter { vm.progress.enDone(year, $0.int("id")) }.count
+ }
+ case "onyears", "onmap", "on2map", "on3map", "on4map":
+ sum.totalEx = vm.content.on.count + vm.content.on2.count + vm.content.on3.count + vm.content.on4.count
+ sum.doneEx = vm.content.on.filter { vm.progress.onDone($0.int("id")) }.count
+ + vm.content.on2.filter { vm.progress.on2Done($0.int("id")) }.count
+ + vm.content.on3.filter { vm.progress.on3Done($0.int("id")) }.count
+ + vm.content.on4.filter { vm.progress.on4Done($0.int("id")) }.count
+ case "scimap":
+ sum.totalEx = vm.content.chem.count + vm.content.bio.count
+ sum.doneEx = vm.content.chem.filter { vm.progress.chemDone($0.int("id")) }.count
+ + vm.content.bio.filter { vm.progress.bioDone($0.int("id")) }.count
+ case "matyears", "mat0map", "matmap", "mat2map", "mat3map", "mat4map":
+ sum.totalEx = vm.content.mat0.count + vm.content.mat.count + vm.content.mat2.count + vm.content.mat3.count + vm.content.mat4.count
+ sum.doneEx = vm.content.mat0.filter { vm.progress.mat0Done($0.int("id")) }.count
+ + vm.content.mat.filter { vm.progress.matDone($0.int("id")) }.count
+ + vm.content.mat2.filter { vm.progress.mat2Done($0.int("id")) }.count
+ + vm.content.mat3.filter { vm.progress.mat3Done($0.int("id")) }.count
+ + vm.content.mat4.filter { vm.progress.mat4Done($0.int("id")) }.count
+ case "csyears", "csmap", "cs2map", "cs3map", "cs4map":
+ sum.totalEx = vm.content.cs.count + vm.content.cs2.count + vm.content.cs3.count + vm.content.cs4.count
+ sum.doneEx = vm.content.cs.filter { vm.progress.csDone($0.int("id")) }.count
+ + vm.content.cs2.filter { vm.progress.cs2Done($0.int("id")) }.count
+ + vm.content.cs3.filter { vm.progress.cs3Done($0.int("id")) }.count
+ + vm.content.cs4.filter { vm.progress.cs4Done($0.int("id")) }.count
+ case "pgyears", "pgmap", "pg2map", "pg3map", "pg4map":
+ sum.totalEx = vm.content.pg.count + vm.content.pg2.count + vm.content.pg3.count + vm.content.pg4.count
+ sum.doneEx = vm.content.pg.filter { vm.progress.pgDone($0.int("id")) }.count
+ + vm.content.pg2.filter { vm.progress.pg2Done($0.int("id")) }.count
+ + vm.content.pg3.filter { vm.progress.pg3Done($0.int("id")) }.count
+ + vm.content.pg4.filter { vm.progress.pg4Done($0.int("id")) }.count
+ case "swyears", "swmap", "sw2map", "sw3map", "sw4map":
+ sum.totalEx = vm.content.sw.count + vm.content.sw2.count + vm.content.sw3.count + vm.content.sw4.count
+ sum.doneEx = vm.content.sw.filter { vm.progress.swDone($0.int("id")) }.count
+ + vm.content.sw2.filter { vm.progress.sw2Done($0.int("id")) }.count
+ + vm.content.sw3.filter { vm.progress.sw3Done($0.int("id")) }.count
+ + vm.content.sw4.filter { vm.progress.sw4Done($0.int("id")) }.count
+ case "tgyears", "tgmap", "tg2map", "tg3map", "tg4map":
+ sum.totalEx = vm.content.tg.count + vm.content.tg2.count + vm.content.tg3.count + vm.content.tg4.count
+ sum.doneEx = vm.content.tg.filter { vm.progress.tgDone($0.int("id")) }.count
+ + vm.content.tg2.filter { vm.progress.tg2Done($0.int("id")) }.count
+ + vm.content.tg3.filter { vm.progress.tg3Done($0.int("id")) }.count
+ + vm.content.tg4.filter { vm.progress.tg4Done($0.int("id")) }.count
+ case "fyzyears", "fyzmap", "fyz2map", "fyz3map", "fyz4map":
+ sum.totalEx = vm.content.fyz.count + vm.content.fyz2.count + vm.content.fyz3.count + vm.content.fyz4.count
+ sum.doneEx = vm.content.fyz.filter { vm.progress.fyzDone($0.int("id")) }.count
+ + vm.content.fyz2.filter { vm.progress.fyz2Done($0.int("id")) }.count
+ + vm.content.fyz3.filter { vm.progress.fyz3Done($0.int("id")) }.count
+ + vm.content.fyz4.filter { vm.progress.fyz4Done($0.int("id")) }.count
+ case "czechmap":
+ let litCount = vm.content.lit.count + vm.content.lit2.count + vm.content.lit3.count + vm.content.lit4.count
+ let litDone = vm.content.lit.filter { vm.progress.litDone($0.int("id")) }.count
+ + vm.content.lit2.filter { vm.progress.lit2Done($0.int("id")) }.count
+ + vm.content.lit3.filter { vm.progress.lit3Done($0.int("id")) }.count
+ + vm.content.lit4.filter { vm.progress.lit4Done($0.int("id")) }.count
+ sum.totalEx = vm.content.mluvnice.count + litCount
+ sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count + litDone
+ default:
+ break
+ }
+ return sum
+}
+
+struct SearchScreen: View {
+ @ObservedObject var vm: AppModel
+ @State private var query = ""
+ #if os(iOS)
+ @FocusState private var fieldFocused: Bool
+ #endif
+
+ var body: some View {
+ let q = normalizeAnswer(query)
+ let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }
+ #if os(macOS)
+ return macSearch(hits)
+ #else
+ return iosSearch(hits)
+ #endif
+ }
+
+ #if os(macOS)
+ private func macSearch(_ hits: [Hit]) -> some View {
+ ScrollView {
+ if hits.isEmpty {
+ ContentUnavailableView.search
+ .frame(maxWidth: .infinity, minHeight: 360)
+ } else {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 340), spacing: 12)], spacing: 12) {
+ ForEach(hits) { hit in
+ Button {
+ if !hit.locked { vm.openFromSearch(hit.target) }
+ } label: {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(hit.title)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(hit.locked ? .secondary : .primary)
+ .multilineTextAlignment(.leading)
+ if !hit.sub.isEmpty {
+ Text(hit.sub)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.leading)
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
+ .softSurface(cornerRadius: 16)
+ }
+ .buttonStyle(PressScaleStyle())
+ .disabled(hit.locked)
+ }
+ }
+ .padding(MacChrome.pageInset)
+ .macPage()
+ }
+ }
+ .navigationTitle(vm.tr("search"))
+ .searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
+ .onAppear { query = vm.searchQuery }
+ .onChange(of: vm.searchQuery) { _, q in query = q }
+ }
+ #endif
+
+ #if os(iOS)
+ private func iosSearch(_ hits: [Hit]) -> some View {
+ List {
+ if hits.isEmpty {
+ ContentUnavailableView.search
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ } else {
+ ForEach(hits) { hit in
+ Button {
+ #if os(iOS)
+ fieldFocused = false
+ #endif
+ if !hit.locked { vm.openFromSearch(hit.target) }
+ } label: {
+ ListRowLabel(title: hit.title, subtitle: hit.sub)
+ }
+ .disabled(hit.locked)
+ .foregroundStyle(hit.locked ? .secondary : .primary)
+ }
+ }
+ }
+ .navigationTitle(vm.tr("search"))
+ .navigationBarTitleDisplayMode(.inline)
+ .safeAreaBar(edge: .top) {
+ searchField
+ }
+ .onAppear {
+ query = vm.searchQuery
+ DispatchQueue.main.async {
+ fieldFocused = true
+ }
+ }
+ .onChange(of: query) { _, q in
+ vm.searchQuery = q
+ }
+ .onChange(of: vm.searchQuery) { _, q in
+ if q != query { query = q }
+ }
+ .appListStyle()
+ }
+ #endif
+
+ #if os(iOS)
+ private var searchField: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+ TextField(vm.tr("search_placeholder"), text: $query)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.search)
+ .focused($fieldFocused)
+ if !query.isEmpty {
+ Button {
+ query = ""
+ fieldFocused = true
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.tertiary)
+ }
+ .buttonStyle(PressScaleStyle())
+ .accessibilityLabel("Clear")
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ }
+ #endif
+}
+
+private struct Hit: Identifiable {
+ var id: String { target + "|" + title }
+ let title: String
+ let sub: String
+ let target: String
+ let locked: Bool
+ let hay: String
+}
+
+private func buildSearch(_ vm: AppModel) -> [Hit] {
+ var out: [Hit] = []
+ func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") {
+ out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)"))
+ }
+ add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety")
+ add(vm.tr("stats"), vm.tr("search_page"), "stats")
+ add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
+ for s in vm.content.subjects {
+ add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open"))
+ }
+ for u in vm.content.german {
+ let unlocked = u.bool("unlocked")
+ add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch")
+ if unlocked {
+ for (i, name) in u.strs("names").enumerated() {
+ add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)")
+ }
+ if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") }
+ }
+ }
+ for l in vm.content.netLessons {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))")
+ }
+ for course in ["net2", "net3", "net4"] {
+ add(vm.tr("net_year\(course.dropFirst(3))"), vm.tr("search_lesson"), "\(course)map", extra: "rocnik site")
+ for l in vm.content.itLessons(course) {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "\(course)unit\(l.int("id"))", extra: "site")
+ }
+ }
+ add(vm.tr("hw_year1"), vm.tr("search_lesson"), "hwmap", extra: "rocnik")
+ for l in vm.content.hw {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))")
+ }
+ for course in ["hw2", "hw3", "hw4"] {
+ add(vm.tr("hw_year\(course.dropFirst(2))"), vm.tr("search_lesson"), "\(course)map", extra: "rocnik hardware")
+ for l in vm.content.itLessons(course) {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "\(course)unit\(l.int("id"))", extra: "hardware")
+ }
+ }
+ add(vm.tr("on_year1"), vm.tr("search_lesson"), "onmap", extra: "rocnik")
+ add(vm.tr("on_year2"), vm.tr("search_lesson"), "on2map", extra: "rocnik")
+ add(vm.tr("on_year3"), vm.tr("search_lesson"), "on3map", extra: "rocnik")
+ add(vm.tr("on_year4"), vm.tr("search_lesson"), "on4map", extra: "rocnik")
+ for year in 1...4 {
+ add(vm.tr("en_year\(year)"), vm.tr("search_lesson"), "en\(year)map", extra: "anglictina english rocnik")
+ for l in vm.content.enYear(year) {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "en\(year)unit\(l.int("id"))", extra: "anglictina english")
+ }
+ add(vm.tr("de_year\(year)"), vm.tr("search_lesson"), "de\(year)map", extra: "nemcina deutsch rocnik")
+ for l in vm.content.deYear(year) {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "de\(year)unit\(l.int("id"))", extra: "nemcina deutsch")
+ }
+ }
+ for l in vm.content.on {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "onunit\(l.int("id"))", extra: "obcanka")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "onex\(l.int("id"))")
+ }
+ for l in vm.content.on2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on2unit\(l.int("id"))", extra: "obcanka")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on2ex\(l.int("id"))")
+ }
+ for l in vm.content.on3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on3unit\(l.int("id"))", extra: "obcanka")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on3ex\(l.int("id"))")
+ }
+ for l in vm.content.on4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on4unit\(l.int("id"))", extra: "obcanka")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on4ex\(l.int("id"))")
+ }
+ add(vm.tr("Literatura"), vm.tr("search_lesson"), "lityears", extra: "literatura")
+ add(vm.tr("lit_year1"), vm.tr("search_lesson"), "litmap", extra: "rocnik")
+ add(vm.tr("lit_year2"), vm.tr("search_lesson"), "lit2map", extra: "rocnik")
+ add(vm.tr("lit_year3"), vm.tr("search_lesson"), "lit3map", extra: "rocnik")
+ add(vm.tr("lit_year4"), vm.tr("search_lesson"), "lit4map", extra: "rocnik")
+ for l in vm.content.lit {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "litunit\(l.int("id"))", extra: "literatura")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "litex\(l.int("id"))")
+ }
+ for l in vm.content.lit2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit2unit\(l.int("id"))", extra: "literatura")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit2ex\(l.int("id"))")
+ }
+ for l in vm.content.lit3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit3unit\(l.int("id"))", extra: "literatura")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit3ex\(l.int("id"))")
+ }
+ for l in vm.content.lit4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit4unit\(l.int("id"))", extra: "literatura")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit4ex\(l.int("id"))")
+ }
+ add(vm.tr("Matematika"), vm.tr("search_lesson"), "matyears", extra: "matematika")
+ add(vm.tr("mat0_year"), vm.tr("search_lesson"), "mat0map", extra: "zakladka")
+ add(vm.tr("mat_year1"), vm.tr("search_lesson"), "matmap", extra: "rocnik")
+ add(vm.tr("mat_year2"), vm.tr("search_lesson"), "mat2map", extra: "rocnik")
+ add(vm.tr("mat_year3"), vm.tr("search_lesson"), "mat3map", extra: "rocnik")
+ add(vm.tr("mat_year4"), vm.tr("search_lesson"), "mat4map", extra: "rocnik")
+ for l in vm.content.mat0 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat0unit\(l.int("id"))", extra: "matematika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat0ex\(l.int("id"))")
+ }
+ for l in vm.content.mat {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "matunit\(l.int("id"))", extra: "matematika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "matex\(l.int("id"))")
+ }
+ for l in vm.content.mat2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat2unit\(l.int("id"))", extra: "matematika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat2ex\(l.int("id"))")
+ }
+ for l in vm.content.mat3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat3unit\(l.int("id"))", extra: "matematika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat3ex\(l.int("id"))")
+ }
+ for l in vm.content.mat4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat4unit\(l.int("id"))", extra: "matematika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat4ex\(l.int("id"))")
+ }
+ add(vm.tr("Fyzika"), vm.tr("search_lesson"), "fyzyears", extra: "fyzika")
+ add(vm.tr("fyz_year1"), vm.tr("search_lesson"), "fyzmap", extra: "rocnik")
+ add(vm.tr("fyz_year2"), vm.tr("search_lesson"), "fyz2map", extra: "rocnik")
+ add(vm.tr("fyz_year3"), vm.tr("search_lesson"), "fyz3map", extra: "rocnik")
+ add(vm.tr("fyz_year4"), vm.tr("search_lesson"), "fyz4map", extra: "rocnik")
+ for l in vm.content.fyz {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyzunit\(l.int("id"))", extra: "fyzika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyzex\(l.int("id"))")
+ }
+ for l in vm.content.fyz2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz2unit\(l.int("id"))", extra: "fyzika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz2ex\(l.int("id"))")
+ }
+ for l in vm.content.fyz3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz3unit\(l.int("id"))", extra: "fyzika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz3ex\(l.int("id"))")
+ }
+ for l in vm.content.fyz4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz4unit\(l.int("id"))", extra: "fyzika")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz4ex\(l.int("id"))")
+ }
+ add(vm.tr("Programové vybavení"), vm.tr("search_lesson"), "swyears", extra: "office windows excel mysql")
+ add(vm.tr("sw_year1"), vm.tr("search_lesson"), "swmap", extra: "rocnik")
+ add(vm.tr("sw_year2"), vm.tr("search_lesson"), "sw2map", extra: "rocnik")
+ add(vm.tr("sw_year3"), vm.tr("search_lesson"), "sw3map", extra: "rocnik")
+ add(vm.tr("sw_year4"), vm.tr("search_lesson"), "sw4map", extra: "rocnik")
+ for l in vm.content.sw {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "swunit\(l.int("id"))", extra: "windows")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "swex\(l.int("id"))")
+ }
+ for l in vm.content.sw2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "sw2unit\(l.int("id"))", extra: "word excel")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "sw2ex\(l.int("id"))")
+ }
+ for l in vm.content.sw3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "sw3unit\(l.int("id"))", extra: "access mysql")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "sw3ex\(l.int("id"))")
+ }
+ for l in vm.content.sw4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "sw4unit\(l.int("id"))", extra: "wordpress")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "sw4ex\(l.int("id"))")
+ }
+ add(vm.tr("Prezentační grafika"), vm.tr("search_lesson"), "pgyears", extra: "photoshop premiere illustrator")
+ add(vm.tr("pg_year1"), vm.tr("search_lesson"), "pgmap", extra: "rocnik")
+ add(vm.tr("pg_year2"), vm.tr("search_lesson"), "pg2map", extra: "rocnik")
+ add(vm.tr("pg_year3"), vm.tr("search_lesson"), "pg3map", extra: "rocnik")
+ add(vm.tr("pg_year4"), vm.tr("search_lesson"), "pg4map", extra: "rocnik")
+ for l in vm.content.pg {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pgunit\(l.int("id"))", extra: "photoshop")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pgex\(l.int("id"))")
+ }
+ for l in vm.content.pg2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg2unit\(l.int("id"))", extra: "photoshop")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg2ex\(l.int("id"))")
+ }
+ for l in vm.content.pg3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg3unit\(l.int("id"))", extra: "premiere")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg3ex\(l.int("id"))")
+ }
+ for l in vm.content.pg4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg4unit\(l.int("id"))", extra: "illustrator")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg4ex\(l.int("id"))")
+ }
+ add(vm.tr("Technická grafika"), vm.tr("search_lesson"), "tgyears", extra: "autocad grafika")
+ add(vm.tr("tg_year1"), vm.tr("search_lesson"), "tgmap", extra: "rocnik")
+ add(vm.tr("tg_year2"), vm.tr("search_lesson"), "tg2map", extra: "rocnik")
+ add(vm.tr("tg_year3"), vm.tr("search_lesson"), "tg3map", extra: "rocnik")
+ add(vm.tr("tg_year4"), vm.tr("search_lesson"), "tg4map", extra: "rocnik")
+ for l in vm.content.tg {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tgunit\(l.int("id"))", extra: "autocad")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tgex\(l.int("id"))")
+ }
+ for l in vm.content.tg2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg2unit\(l.int("id"))", extra: "autocad")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg2ex\(l.int("id"))")
+ }
+ for l in vm.content.tg3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg3unit\(l.int("id"))", extra: "autocad")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg3ex\(l.int("id"))")
+ }
+ for l in vm.content.tg4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg4unit\(l.int("id"))", extra: "autocad")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg4ex\(l.int("id"))")
+ }
+ add(vm.tr("Programování"), vm.tr("search_lesson"), "csyears", extra: "csharp c#")
+ add(vm.tr("cs_year1"), vm.tr("search_lesson"), "csmap", extra: "rocnik")
+ add(vm.tr("cs_year2"), vm.tr("search_lesson"), "cs2map", extra: "rocnik")
+ add(vm.tr("cs_year3"), vm.tr("search_lesson"), "cs3map", extra: "rocnik")
+ add(vm.tr("cs_year4"), vm.tr("search_lesson"), "cs4map", extra: "rocnik")
+ for l in vm.content.cs {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "csunit\(l.int("id"))", extra: "csharp")
+ }
+ for l in vm.content.cs2 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs2unit\(l.int("id"))", extra: "csharp")
+ }
+ for l in vm.content.cs3 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs3unit\(l.int("id"))", extra: "csharp")
+ }
+ for l in vm.content.cs4 {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs4unit\(l.int("id"))", extra: "csharp")
+ }
+ add(vm.tr("Chemie"), vm.tr("search_lesson"), "chemmap", extra: "chemie")
+ add(vm.tr("Biologie"), vm.tr("search_lesson"), "biomap", extra: "biologie")
+ for l in vm.content.chem {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "chemunit\(l.int("id"))", extra: "chemie")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "chemex\(l.int("id"))")
+ }
+ for l in vm.content.bio {
+ add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "biounit\(l.int("id"))", extra: "biologie")
+ add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "bioex\(l.int("id"))")
+ }
+ add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
+ add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
+ for b in vm.content.books {
+ add(b.str("title"), vm.tr("search_book"), b.str("page"))
+ }
+ return out
+}
+
+struct RoadmapScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ roadmapPath
+ #else
+ roadmapList
+ #endif
+ }
+
+ #if os(macOS)
+ private var roadmapPath: some View {
+ let current = vm.content.german.firstIndex { u in
+ u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
+ }
+ let lit = CGFloat(max((current ?? vm.content.german.count) - 1, 0))
+ return PathMap(
+ nodes: vm.content.german.enumerated().map { i, u in
+ let unlocked = u.bool("unlocked")
+ let names = u.strs("names")
+ let done = unlocked && !names.isEmpty && names.indices.allSatisfy {
+ vm.progress.germanDone(u.int("id"), $0 + 1)
+ }
+ return MapNode(
+ id: "unit-\(i)",
+ label: u.str("title"),
+ locked: !unlocked,
+ done: done,
+ current: i == current,
+ finish: i == vm.content.german.count - 1,
+ destination: unlocked ? .unitMap(u.int("id")) : nil
+ )
+ },
+ palette: vm.palette,
+ litUntil: lit,
+ nodeSize: MacChrome.pathNode,
+ mx: 100,
+ my: 140,
+ spac: 196,
+ gap: 280,
+ wave: 18
+ )
+ .padding(28)
+ .navigationTitle(vm.tr("roadmap_title"))
+ .navigationSubtitle(vm.tr("roadmap_sub"))
+ }
+ #endif
+
+ #if os(iOS)
+ private var roadmapList: some View {
+ List {
+ ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in
+ let unlocked = u.bool("unlocked")
+ let names = u.strs("names")
+ let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
+ if unlocked {
+ NavigationLink(value: Route.unitMap(u.int("id"))) {
+ Label {
+ Text(u.str("title"))
+ } icon: {
+ Image(systemName: done ? "checkmark.circle.fill" : "circle")
+ }
+ }
+ } else {
+ Label(u.str("title"), systemImage: "lock.fill")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle(vm.tr("roadmap_title"))
+ .navigationSubtitle(vm.tr("roadmap_sub"))
+ .appListStyle()
+ }
+ #endif
+}
+
+struct UnitMapScreen: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+
+ var body: some View {
+ if let u = vm.content.germanUnit(unitId) {
+ #if os(macOS)
+ unitPath(u)
+ #else
+ unitList(u)
+ #endif
+ }
+ }
+
+ #if os(macOS)
+ private func unitPath(_ u: J) -> some View {
+ let names = u.strs("names")
+ let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
+ let lit = CGFloat(next ?? names.count)
+ let branch: (index: Int, node: MapNode)? = u.has("branch")
+ ? (
+ 1,
+ MapNode(
+ id: "vocab-\(unitId)",
+ label: vm.tr("Vokabeltraining"),
+ locked: false,
+ done: vm.progress.vocabDone(unitId),
+ current: false,
+ destination: .vocab(unitId),
+ symbol: "character.book.closed.fill"
+ )
+ )
+ : nil
+ return PathMap(
+ nodes: names.enumerated().map { i, name in
+ MapNode(
+ id: "e\(i + 1)",
+ label: name,
+ locked: false,
+ done: vm.progress.germanDone(unitId, i + 1),
+ current: i == next,
+ destination: .germanEx(unitId, i + 1)
+ )
+ },
+ palette: vm.palette,
+ litUntil: lit,
+ nodeSize: MacChrome.pathNode,
+ mx: 100,
+ my: 136,
+ spac: 190,
+ gap: 268,
+ wave: 16,
+ branch: branch
+ )
+ .padding(28)
+ .navigationTitle(u.str("title"))
+ .navigationSubtitle(vm.tr(u.str("sub")))
+ }
+ #endif
+
+ #if os(iOS)
+ private func unitList(_ u: J) -> some View {
+ let names = u.strs("names")
+ return List {
+ ForEach(names.indices, id: \.self) { i in
+ NavigationLink(value: Route.germanEx(unitId, i + 1)) {
+ Label {
+ Text(names[i])
+ } icon: {
+ Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle")
+ }
+ }
+ }
+ if u.has("branch") {
+ NavigationLink(value: Route.vocab(unitId)) {
+ Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed")
+ }
+ }
+ }
+ .navigationTitle(u.str("title"))
+ .navigationSubtitle(vm.tr(u.str("sub")))
+ .appListStyle()
+ }
+ #endif
+}
+
+struct NetYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("net_year1", "net_sub", .netMap),
+ ("net_year2", "net2_sub", .net2Map),
+ ("net_year3", "net3_sub", .net3Map),
+ ("net_year4", "net4_sub", .net4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("net_years_title"))
+ .navigationSubtitle(vm.tr("net_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.netMap) {
+ ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"), progress: routeProgress(vm, Route.netMap))
+ }
+ NavigationLink(value: Route.net2Map) {
+ ListRowLabel(title: vm.tr("net_year2"), subtitle: vm.tr("net2_sub"), progress: routeProgress(vm, Route.net2Map))
+ }
+ NavigationLink(value: Route.net3Map) {
+ ListRowLabel(title: vm.tr("net_year3"), subtitle: vm.tr("net3_sub"), progress: routeProgress(vm, Route.net3Map))
+ }
+ NavigationLink(value: Route.net4Map) {
+ ListRowLabel(title: vm.tr("net_year4"), subtitle: vm.tr("net4_sub"), progress: routeProgress(vm, Route.net4Map))
+ }
+ }
+ .navigationTitle(vm.tr("net_years_title"))
+ .navigationSubtitle(vm.tr("net_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct HwYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("hw_year1", "hw_sub", .hwMap),
+ ("hw_year2", "hw2_sub", .hw2Map),
+ ("hw_year3", "hw3_sub", .hw3Map),
+ ("hw_year4", "hw4_sub", .hw4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Technické vybavení"))
+ .navigationSubtitle(vm.tr("hw_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.hwMap) {
+ ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub"), progress: routeProgress(vm, Route.hwMap))
+ }
+ NavigationLink(value: Route.hw2Map) {
+ ListRowLabel(title: vm.tr("hw_year2"), subtitle: vm.tr("hw2_sub"), progress: routeProgress(vm, Route.hw2Map))
+ }
+ NavigationLink(value: Route.hw3Map) {
+ ListRowLabel(title: vm.tr("hw_year3"), subtitle: vm.tr("hw3_sub"), progress: routeProgress(vm, Route.hw3Map))
+ }
+ NavigationLink(value: Route.hw4Map) {
+ ListRowLabel(title: vm.tr("hw_year4"), subtitle: vm.tr("hw4_sub"), progress: routeProgress(vm, Route.hw4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Technické vybavení"))
+ .navigationSubtitle(vm.tr("hw_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct DeHomeScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let rows: [(String, String, String, Route)] = [
+ ("U", "de_book_title", "de_book_sub", .roadmap),
+ ("1", "de_year1", "de_y1_sub", .deMap(1)),
+ ("2", "de_year2", "de_y2_sub", .deMap(2)),
+ ("3", "de_year3", "de_y3_sub", .deMap(3)),
+ ("4", "de_year4", "de_y4_sub", .deMap(4)),
+ ]
+ MacFillGrid(count: rows.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: rows[i].3) {
+ MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0, progress: routeProgress(vm, rows[i].3))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Deutsch"))
+ .navigationSubtitle(vm.tr("de_home_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.roadmap) {
+ ListRowLabel(title: vm.tr("de_book_title"), subtitle: vm.tr("de_book_sub"), progress: routeProgress(vm, Route.roadmap))
+ }
+ ForEach(1...4, id: \.self) { year in
+ NavigationLink(value: Route.deMap(year)) {
+ ListRowLabel(title: vm.tr("de_year\(year)"), subtitle: vm.tr("de_y\(year)_sub"), progress: routeProgress(vm, Route.deMap(year)))
+ }
+ }
+ }
+ .navigationTitle(vm.tr("Deutsch"))
+ .navigationSubtitle(vm.tr("de_home_sub"))
+ #endif
+ }
+}
+
+struct EnYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("en_year1", "en_y1_sub", .enMap(1)),
+ ("en_year2", "en_y2_sub", .enMap(2)),
+ ("en_year3", "en_y3_sub", .enMap(3)),
+ ("en_year4", "en_y4_sub", .enMap(4)),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("English"))
+ .navigationSubtitle(vm.tr("en_years_sub"))
+ #else
+ List {
+ ForEach(1...4, id: \.self) { year in
+ NavigationLink(value: Route.enMap(year)) {
+ ListRowLabel(title: vm.tr("en_year\(year)"), subtitle: vm.tr("en_y\(year)_sub"), progress: routeProgress(vm, Route.enMap(year)))
+ }
+ }
+ }
+ .navigationTitle(vm.tr("English"))
+ .navigationSubtitle(vm.tr("en_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct OnYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("on_year1", "on_sub", .onMap),
+ ("on_year2", "on2_sub", .on2Map),
+ ("on_year3", "on3_sub", .on3Map),
+ ("on_year4", "on4_sub", .on4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Občanská nauka"))
+ .navigationSubtitle(vm.tr("on_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.onMap) {
+ ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub"), progress: routeProgress(vm, Route.onMap))
+ }
+ NavigationLink(value: Route.on2Map) {
+ ListRowLabel(title: vm.tr("on_year2"), subtitle: vm.tr("on2_sub"), progress: routeProgress(vm, Route.on2Map))
+ }
+ NavigationLink(value: Route.on3Map) {
+ ListRowLabel(title: vm.tr("on_year3"), subtitle: vm.tr("on3_sub"), progress: routeProgress(vm, Route.on3Map))
+ }
+ NavigationLink(value: Route.on4Map) {
+ ListRowLabel(title: vm.tr("on_year4"), subtitle: vm.tr("on4_sub"), progress: routeProgress(vm, Route.on4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Občanská nauka"))
+ .navigationSubtitle(vm.tr("on_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct LessonRow: Identifiable {
+ let id: String
+ let title: String
+ let done: Bool
+ let destination: Route
+}
+
+struct LessonListScreen: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ let subtitle: String
+ let rows: [LessonRow]
+
+ var body: some View {
+ #if os(macOS)
+ lessonPath
+ #else
+ lessonList
+ #endif
+ }
+
+ #if os(macOS)
+ private var lessonPath: some View {
+ let next = rows.firstIndex { !$0.done }
+ return PathMap(
+ nodes: rows.enumerated().map { i, row in
+ MapNode(
+ id: row.id,
+ label: row.title,
+ locked: false,
+ done: row.done,
+ current: i == next,
+ destination: row.destination
+ )
+ },
+ palette: vm.palette,
+ litUntil: CGFloat(next ?? rows.count),
+ nodeSize: MacChrome.pathNode,
+ mx: 98,
+ my: 134,
+ spac: 186,
+ gap: 272,
+ wave: 16
+ )
+ .padding(28)
+ .navigationTitle(title)
+ .navigationSubtitle(subtitle)
+ }
+ #endif
+
+ #if os(iOS)
+ private var lessonList: some View {
+ List(rows) { row in
+ NavigationLink(value: row.destination) {
+ Label {
+ Text(row.title)
+ } icon: {
+ Image(systemName: row.done ? "checkmark.circle.fill" : "circle")
+ }
+ }
+ }
+ .navigationTitle(title)
+ .navigationSubtitle(subtitle)
+ .appListStyle()
+ }
+ #endif
+}
+
+struct SlidesScreen: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ let subtitle: String
+ let slides: [J]
+ let next: Route
+ @State private var idx = 0
+
+ var body: some View {
+ Group {
+ #if os(iOS)
+ TabView(selection: $idx) {
+ ForEach(slides.indices, id: \.self) { i in
+ slidePage(slides[i]).tag(i)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ #else
+ if slides.indices.contains(idx) {
+ slidePage(slides[idx])
+ .id(idx)
+ .transition(.opacity)
+ }
+ #endif
+ }
+ #if os(macOS)
+ .animation(Motion.settle, value: idx)
+ #endif
+ .detailScreen(title, subtitle: subtitle)
+ .glassBottomBar {
+ VStack(spacing: 10) {
+ HStack(spacing: 6) {
+ ForEach(slides.indices, id: \.self) { i in
+ Capsule()
+ .fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
+ .frame(width: i == idx ? 18 : 6, height: 6)
+ }
+ }
+ .animation(Motion.settle, value: idx)
+ HStack(spacing: 10) {
+ if idx > 0 {
+ Button {
+ withAnimation { idx -= 1 }
+ } label: {
+ Image(systemName: "chevron.left")
+ .frame(minWidth: 24)
+ }
+ #if os(macOS)
+ .buttonStyle(.bordered)
+ #else
+ .buttonStyle(.glass)
+ #endif
+ .controlSize(.large)
+ }
+ if idx < slides.count - 1 {
+ PrimaryButton(label: vm.tr("net_slide_next")) {
+ withAnimation { idx += 1 }
+ }
+ } else {
+ PrimaryButton(label: vm.tr("net_slide_start")) {
+ vm.go(next)
+ }
+ }
+ }
+ }
+ .padding(.horizontal)
+ .padding(.top, 8)
+ .padding(.bottom, 4)
+ #if os(macOS)
+ .macPage(MacChrome.actionWidth)
+ #endif
+ }
+ }
+
+ private func slidePage(_ slide: J) -> some View {
+ ScrollView {
+ SlideBody(slide: slide)
+ .padding(.horizontal, slideInset)
+ .padding(.top, 22)
+ .padding(.bottom, 28)
+ .frame(maxWidth: 860)
+ .frame(maxWidth: .infinity, alignment: .top)
+ }
+ }
+
+ private var slideInset: CGFloat {
+ #if os(macOS)
+ MacChrome.pageInset
+ #else
+ 16
+ #endif
+ }
+}
+
+/// One explanation slide: label, title, tip and numbered points.
+struct SlideBody: View {
+ let slide: J
+
+ var body: some View {
+ slideStack(slide)
+ }
+
+ private func slideStack(_ slide: J) -> some View {
+ VStack(alignment: .leading, spacing: 18) {
+ Text(slide.str("kicker"))
+ #if os(macOS)
+ .font(.subheadline.weight(.semibold))
+ #else
+ .font(.caption.weight(.semibold))
+ #endif
+ .foregroundStyle(Color.accentColor)
+ Text(slide.str("title"))
+ #if os(macOS)
+ .font(.title.bold())
+ #else
+ .font(.title2.bold())
+ #endif
+ .fixedSize(horizontal: false, vertical: true)
+ if let tip = slide.strOrNull("tip") {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "lightbulb.fill")
+ .foregroundStyle(Color.accentColor)
+ .padding(.top, 2)
+ Text(tip)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.subheadline)
+ #endif
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ slideLines(slide.strs("lines"))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func slideLines(_ lines: [String]) -> some View {
+ let points = studyPoints(lines)
+ return VStack(alignment: .leading, spacing: 10) {
+ ForEach(Array(points.enumerated()), id: \.offset) { _, point in
+ HStack(alignment: .top, spacing: 14) {
+ Text(point.0)
+ .font(.callout.weight(.bold))
+ .foregroundStyle(Color.accentColor)
+ .frame(minWidth: 28, minHeight: 28)
+ .background(Color.accentColor.opacity(0.14), in: Circle())
+ Text(point.1)
+ #if os(macOS)
+ .font(.title3)
+ #else
+ .font(.body)
+ #endif
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 3)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.primary.opacity(0.045))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+ }
+ }
+
+ /// Lines that end in the middle of a sentence stay one point. A leading
+ /// "1. " is the point number, not part of the sentence.
+ private func studyPoints(_ lines: [String]) -> [(String, String)] {
+ var chunks: [String] = []
+ var buffer = ""
+ func flush() {
+ let text = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !text.isEmpty { chunks.append(text) }
+ buffer = ""
+ }
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { continue }
+ if buffer.isEmpty {
+ buffer = trimmed
+ } else if continuesStudyPoint(buffer) {
+ buffer += " " + trimmed
+ } else {
+ flush()
+ buffer = trimmed
+ }
+ }
+ flush()
+ return chunks.enumerated().map { index, chunk in
+ let pattern = #"^(\d+)\.\s+(.*)$"#
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: chunk, range: NSRange(chunk.startIndex..., in: chunk)),
+ let number = Range(match.range(at: 1), in: chunk),
+ let rest = Range(match.range(at: 2), in: chunk),
+ !continuesStudyPoint(String(chunk[rest])) {
+ return (String(chunk[number]), String(chunk[rest]))
+ }
+ return (String(index + 1), chunk)
+ }
+ }
+
+ private func continuesStudyPoint(_ text: String) -> Bool {
+ guard let last = text.last else { return false }
+ return last == "," || last == ":" || last == ";" || last == "–" || last == "-" || last == "("
+ }
+}
+
+struct LitYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("lit_year1", "lit_sub", .litMap),
+ ("lit_year2", "lit2_sub", .lit2Map),
+ ("lit_year3", "lit3_sub", .lit3Map),
+ ("lit_year4", "lit4_sub", .lit4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Literatura"))
+ .navigationSubtitle(vm.tr("lit_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.litMap) {
+ ListRowLabel(title: vm.tr("lit_year1"), subtitle: vm.tr("lit_sub"), progress: routeProgress(vm, Route.litMap))
+ }
+ NavigationLink(value: Route.lit2Map) {
+ ListRowLabel(title: vm.tr("lit_year2"), subtitle: vm.tr("lit2_sub"), progress: routeProgress(vm, Route.lit2Map))
+ }
+ NavigationLink(value: Route.lit3Map) {
+ ListRowLabel(title: vm.tr("lit_year3"), subtitle: vm.tr("lit3_sub"), progress: routeProgress(vm, Route.lit3Map))
+ }
+ NavigationLink(value: Route.lit4Map) {
+ ListRowLabel(title: vm.tr("lit_year4"), subtitle: vm.tr("lit4_sub"), progress: routeProgress(vm, Route.lit4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Literatura"))
+ .navigationSubtitle(vm.tr("lit_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct MatYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route, String)] = [
+ ("mat0_year", "mat0_sub", .mat0Map, "ZŠ"),
+ ("mat_year1", "mat_sub", .matMap, "1."),
+ ("mat_year2", "mat2_sub", .mat2Map, "2."),
+ ("mat_year3", "mat3_sub", .mat3Map, "3."),
+ ("mat_year4", "mat4_sub", .mat4Map, "4."),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3, progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Matematika"))
+ .navigationSubtitle(vm.tr("mat_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.mat0Map) {
+ ListRowLabel(title: vm.tr("mat0_year"), subtitle: vm.tr("mat0_sub"), progress: routeProgress(vm, Route.mat0Map))
+ }
+ NavigationLink(value: Route.matMap) {
+ ListRowLabel(title: vm.tr("mat_year1"), subtitle: vm.tr("mat_sub"), progress: routeProgress(vm, Route.matMap))
+ }
+ NavigationLink(value: Route.mat2Map) {
+ ListRowLabel(title: vm.tr("mat_year2"), subtitle: vm.tr("mat2_sub"), progress: routeProgress(vm, Route.mat2Map))
+ }
+ NavigationLink(value: Route.mat3Map) {
+ ListRowLabel(title: vm.tr("mat_year3"), subtitle: vm.tr("mat3_sub"), progress: routeProgress(vm, Route.mat3Map))
+ }
+ NavigationLink(value: Route.mat4Map) {
+ ListRowLabel(title: vm.tr("mat_year4"), subtitle: vm.tr("mat4_sub"), progress: routeProgress(vm, Route.mat4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Matematika"))
+ .navigationSubtitle(vm.tr("mat_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct CsYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("cs_year1", "cs_sub", .csMap),
+ ("cs_year2", "cs2_sub", .cs2Map),
+ ("cs_year3", "cs3_sub", .cs3Map),
+ ("cs_year4", "cs4_sub", .cs4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Programování"))
+ .navigationSubtitle(vm.tr("cs_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.csMap) {
+ ListRowLabel(title: vm.tr("cs_year1"), subtitle: vm.tr("cs_sub"), progress: routeProgress(vm, Route.csMap))
+ }
+ NavigationLink(value: Route.cs2Map) {
+ ListRowLabel(title: vm.tr("cs_year2"), subtitle: vm.tr("cs2_sub"), progress: routeProgress(vm, Route.cs2Map))
+ }
+ NavigationLink(value: Route.cs3Map) {
+ ListRowLabel(title: vm.tr("cs_year3"), subtitle: vm.tr("cs3_sub"), progress: routeProgress(vm, Route.cs3Map))
+ }
+ NavigationLink(value: Route.cs4Map) {
+ ListRowLabel(title: vm.tr("cs_year4"), subtitle: vm.tr("cs4_sub"), progress: routeProgress(vm, Route.cs4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Programování"))
+ .navigationSubtitle(vm.tr("cs_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct SwYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("sw_year1", "sw_sub", .swMap),
+ ("sw_year2", "sw2_sub", .sw2Map),
+ ("sw_year3", "sw3_sub", .sw3Map),
+ ("sw_year4", "sw4_sub", .sw4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Programové vybavení"))
+ .navigationSubtitle(vm.tr("sw_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.swMap) {
+ ListRowLabel(title: vm.tr("sw_year1"), subtitle: vm.tr("sw_sub"), progress: routeProgress(vm, Route.swMap))
+ }
+ NavigationLink(value: Route.sw2Map) {
+ ListRowLabel(title: vm.tr("sw_year2"), subtitle: vm.tr("sw2_sub"), progress: routeProgress(vm, Route.sw2Map))
+ }
+ NavigationLink(value: Route.sw3Map) {
+ ListRowLabel(title: vm.tr("sw_year3"), subtitle: vm.tr("sw3_sub"), progress: routeProgress(vm, Route.sw3Map))
+ }
+ NavigationLink(value: Route.sw4Map) {
+ ListRowLabel(title: vm.tr("sw_year4"), subtitle: vm.tr("sw4_sub"), progress: routeProgress(vm, Route.sw4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Programové vybavení"))
+ .navigationSubtitle(vm.tr("sw_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct PgYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("pg_year1", "pg_sub", .pgMap),
+ ("pg_year2", "pg2_sub", .pg2Map),
+ ("pg_year3", "pg3_sub", .pg3Map),
+ ("pg_year4", "pg4_sub", .pg4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Prezentační grafika"))
+ .navigationSubtitle(vm.tr("pg_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.pgMap) {
+ ListRowLabel(title: vm.tr("pg_year1"), subtitle: vm.tr("pg_sub"), progress: routeProgress(vm, Route.pgMap))
+ }
+ NavigationLink(value: Route.pg2Map) {
+ ListRowLabel(title: vm.tr("pg_year2"), subtitle: vm.tr("pg2_sub"), progress: routeProgress(vm, Route.pg2Map))
+ }
+ NavigationLink(value: Route.pg3Map) {
+ ListRowLabel(title: vm.tr("pg_year3"), subtitle: vm.tr("pg3_sub"), progress: routeProgress(vm, Route.pg3Map))
+ }
+ NavigationLink(value: Route.pg4Map) {
+ ListRowLabel(title: vm.tr("pg_year4"), subtitle: vm.tr("pg4_sub"), progress: routeProgress(vm, Route.pg4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Prezentační grafika"))
+ .navigationSubtitle(vm.tr("pg_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct TgYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("tg_year1", "tg_sub", .tgMap),
+ ("tg_year2", "tg2_sub", .tg2Map),
+ ("tg_year3", "tg3_sub", .tg3Map),
+ ("tg_year4", "tg4_sub", .tg4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Technická grafika"))
+ .navigationSubtitle(vm.tr("tg_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.tgMap) {
+ ListRowLabel(title: vm.tr("tg_year1"), subtitle: vm.tr("tg_sub"), progress: routeProgress(vm, Route.tgMap))
+ }
+ NavigationLink(value: Route.tg2Map) {
+ ListRowLabel(title: vm.tr("tg_year2"), subtitle: vm.tr("tg2_sub"), progress: routeProgress(vm, Route.tg2Map))
+ }
+ NavigationLink(value: Route.tg3Map) {
+ ListRowLabel(title: vm.tr("tg_year3"), subtitle: vm.tr("tg3_sub"), progress: routeProgress(vm, Route.tg3Map))
+ }
+ NavigationLink(value: Route.tg4Map) {
+ ListRowLabel(title: vm.tr("tg_year4"), subtitle: vm.tr("tg4_sub"), progress: routeProgress(vm, Route.tg4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Technická grafika"))
+ .navigationSubtitle(vm.tr("tg_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct FyzYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ let years: [(String, String, Route)] = [
+ ("fyz_year1", "fyz_sub", .fyzMap),
+ ("fyz_year2", "fyz2_sub", .fyz2Map),
+ ("fyz_year3", "fyz3_sub", .fyz3Map),
+ ("fyz_year4", "fyz4_sub", .fyz4Map),
+ ]
+ MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
+ NavigationLink(value: years[i].2) {
+ MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ .navigationTitle(vm.tr("Fyzika"))
+ .navigationSubtitle(vm.tr("fyz_years_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.fyzMap) {
+ ListRowLabel(title: vm.tr("fyz_year1"), subtitle: vm.tr("fyz_sub"), progress: routeProgress(vm, Route.fyzMap))
+ }
+ NavigationLink(value: Route.fyz2Map) {
+ ListRowLabel(title: vm.tr("fyz_year2"), subtitle: vm.tr("fyz2_sub"), progress: routeProgress(vm, Route.fyz2Map))
+ }
+ NavigationLink(value: Route.fyz3Map) {
+ ListRowLabel(title: vm.tr("fyz_year3"), subtitle: vm.tr("fyz3_sub"), progress: routeProgress(vm, Route.fyz3Map))
+ }
+ NavigationLink(value: Route.fyz4Map) {
+ ListRowLabel(title: vm.tr("fyz_year4"), subtitle: vm.tr("fyz4_sub"), progress: routeProgress(vm, Route.fyz4Map))
+ }
+ }
+ .navigationTitle(vm.tr("Fyzika"))
+ .navigationSubtitle(vm.tr("fyz_years_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct SciMapScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ MacFillGrid(count: 2, minWidth: 260, minHeight: 200) { i in
+ if i == 0 {
+ NavigationLink(value: Route.chemMap) {
+ MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill", progress: routeProgress(vm, .chemMap))
+ }
+ .buttonStyle(PressScaleStyle())
+ } else {
+ NavigationLink(value: Route.bioMap) {
+ MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill", progress: routeProgress(vm, .bioMap))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ .navigationTitle(vm.tr("Základy přírodních věd"))
+ .navigationSubtitle(vm.tr("sci_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.chemMap) {
+ Label {
+ ListRowLabel(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), progress: routeProgress(vm, .chemMap))
+ } icon: {
+ Image(systemName: "flask.fill")
+ }
+ }
+ NavigationLink(value: Route.bioMap) {
+ Label {
+ ListRowLabel(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), progress: routeProgress(vm, .bioMap))
+ } icon: {
+ Image(systemName: "leaf.fill")
+ }
+ }
+ }
+ .navigationTitle(vm.tr("Základy přírodních věd"))
+ .navigationSubtitle(vm.tr("sci_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct CzechMapScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ MacFillGrid(count: 3, minWidth: 260, minHeight: 200) { i in
+ switch i {
+ case 1:
+ NavigationLink(value: Route.mluvnice) {
+ MacLinkCard(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), systemImage: "textformat", progress: routeProgress(vm, .mluvnice))
+ }
+ .buttonStyle(PressScaleStyle())
+ case 2:
+ NavigationLink(value: Route.readingList) {
+ MacLinkCard(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"), systemImage: "books.vertical")
+ }
+ .buttonStyle(PressScaleStyle())
+ default:
+ NavigationLink(value: Route.litYears) {
+ MacLinkCard(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), systemImage: "book.closed", progress: routeProgress(vm, .litYears))
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ .navigationTitle(vm.tr("Český jazyk a literatura"))
+ .navigationSubtitle(vm.tr("czech_sub"))
+ #else
+ List {
+ NavigationLink(value: Route.litYears) {
+ Label {
+ ListRowLabel(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), progress: routeProgress(vm, .litYears))
+ } icon: {
+ Image(systemName: "book.closed")
+ }
+ }
+ NavigationLink(value: Route.mluvnice) {
+ Label {
+ ListRowLabel(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), progress: routeProgress(vm, .mluvnice))
+ } icon: {
+ Image(systemName: "textformat")
+ }
+ }
+ NavigationLink(value: Route.readingList) {
+ Label {
+ ListRowLabel(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
+ } icon: {
+ Image(systemName: "books.vertical")
+ }
+ }
+ }
+ .navigationTitle(vm.tr("Český jazyk a literatura"))
+ .navigationSubtitle(vm.tr("czech_sub"))
+ .appListStyle()
+ #endif
+ }
+}
+
+struct BookListScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ #if os(macOS)
+ macBooks
+ #else
+ iosBooks
+ #endif
+ }
+
+ #if os(macOS)
+ private var macBooks: some View {
+ ScrollView {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 16)], spacing: 16) {
+ ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
+ let parts = bookTitleParts(b.str("title"))
+ let quizDone = !b.arr("quiz").isEmpty && vm.progress.bookQuiz(b.str("id"))
+ let plotDone = !b.arr("plot").isEmpty && vm.progress.bookPlot(b.str("id"))
+ NavigationLink(value: Route.book(b.str("id"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ Image(systemName: "book.fill")
+ .font(.title2)
+ .foregroundStyle(.tint)
+ Text(parts.title)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ if let author = parts.author {
+ Text(author)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ HStack(spacing: 8) {
+ let genre = b.str("genre")
+ Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ if quizDone || plotDone {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ .accessibilityLabel(vm.tr("book_done"))
+ }
+ }
+ }
+ .padding(18)
+ .frame(maxWidth: .infinity, minHeight: 168, alignment: .topLeading)
+ .softSurface(cornerRadius: 18)
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ .padding(MacChrome.pageInset)
+ }
+ .navigationTitle(vm.tr("Maturitní četba"))
+ .navigationSubtitle(vm.tr("reading_sub"))
+ }
+ #endif
+
+ #if os(iOS)
+ private var iosBooks: some View {
+ List {
+ ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
+ let parts = bookTitleParts(b.str("title"))
+ let quizDone = !b.arr("quiz").isEmpty && vm.progress.bookQuiz(b.str("id"))
+ let plotDone = !b.arr("plot").isEmpty && vm.progress.bookPlot(b.str("id"))
+ NavigationLink(value: Route.book(b.str("id"))) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "book.fill")
+ #if os(macOS)
+ .font(.title2)
+ #else
+ .font(.title3)
+ #endif
+ .foregroundStyle(.tint)
+ .frame(width: 28)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(parts.title)
+ #if os(macOS)
+ .font(.title3.weight(.semibold))
+ #else
+ .font(.body.weight(.semibold))
+ #endif
+ if let author = parts.author {
+ Text(author)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.subheadline)
+ #endif
+ .foregroundStyle(.secondary)
+ }
+ HStack(spacing: 8) {
+ let genre = b.str("genre")
+ Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
+ #if os(macOS)
+ .font(.subheadline)
+ #else
+ .font(.caption)
+ #endif
+ .foregroundStyle(.secondary)
+ if quizDone || plotDone {
+ Label(vm.tr("book_done"), systemImage: "checkmark.circle.fill")
+ #if os(macOS)
+ .font(.subheadline.weight(.semibold))
+ #else
+ .font(.caption.weight(.semibold))
+ #endif
+ .foregroundStyle(.green)
+ }
+ }
+ }
+ }
+ #if os(macOS)
+ .padding(.vertical, 4)
+ #endif
+ }
+ }
+ }
+ .navigationTitle(vm.tr("Maturitní četba"))
+ .navigationSubtitle(vm.tr("reading_sub"))
+ .appListStyle()
+ }
+ #endif
+}
+
+struct BookScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+
+ var body: some View {
+ if let b = vm.content.book(id) {
+ let hasQuiz = !b.arr("quiz").isEmpty
+ let hasPlot = !b.arr("plot").isEmpty
+ let notes = b.arr("notes")
+ let quizDone = vm.progress.bookQuiz(id)
+ let plotDone = vm.progress.bookPlot(id)
+ let parts = bookTitleParts(b.str("title"))
+ ScrollView {
+ VStack(alignment: .leading, spacing: 28) {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 10) {
+ if let author = parts.author {
+ Text(author)
+ #if os(macOS)
+ .font(.title3)
+ #else
+ .font(.subheadline)
+ #endif
+ .foregroundStyle(.secondary)
+ }
+ if !b.str("genre").isEmpty {
+ Text(b.str("genre"))
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.tint)
+ }
+ Text(vm.tr("book_study_hint"))
+ #if os(macOS)
+ .font(.title3)
+ #else
+ .font(.body)
+ #endif
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ if hasQuiz || hasPlot {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(vm.tr("book_practice"))
+ #if os(macOS)
+ .font(.title.weight(.semibold))
+ #else
+ .font(.title2.weight(.semibold))
+ #endif
+ #if os(macOS)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 16)], spacing: 16) {
+ if hasQuiz {
+ NavigationLink(value: Route.bookQuiz(id)) {
+ BookActionCard(
+ title: vm.tr("book_quiz_heading"),
+ subtitle: vm.tr("lit_quiz_sub"),
+ systemImage: "questionmark.circle.fill",
+ done: quizDone,
+ doneLabel: vm.tr("book_done")
+ )
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ if hasPlot {
+ NavigationLink(value: Route.bookPlot(id)) {
+ BookActionCard(
+ title: vm.tr("lit_plot_title"),
+ subtitle: vm.tr("lit_plot_sub"),
+ systemImage: "list.number",
+ done: plotDone,
+ doneLabel: vm.tr("book_done")
+ )
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ }
+ #else
+ if hasQuiz {
+ NavigationLink(value: Route.bookQuiz(id)) {
+ BookActionCard(
+ title: vm.tr("book_quiz_heading"),
+ subtitle: vm.tr("lit_quiz_sub"),
+ systemImage: "questionmark.circle.fill",
+ done: quizDone,
+ doneLabel: vm.tr("book_done")
+ )
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ if hasPlot {
+ NavigationLink(value: Route.bookPlot(id)) {
+ BookActionCard(
+ title: vm.tr("lit_plot_title"),
+ subtitle: vm.tr("lit_plot_sub"),
+ systemImage: "list.number",
+ done: plotDone,
+ doneLabel: vm.tr("book_done")
+ )
+ }
+ .buttonStyle(PressScaleStyle())
+ }
+ #endif
+ }
+ }
+
+ if !notes.isEmpty {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(vm.tr("book_notes"))
+ #if os(macOS)
+ .font(.title.weight(.semibold))
+ #else
+ .font(.title2.weight(.semibold))
+ #endif
+ #if os(macOS)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 360), spacing: 16)], alignment: .leading, spacing: 16) {
+ ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
+ BookNoteCard(note: note)
+ }
+ }
+ #else
+ ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
+ BookNoteCard(note: note)
+ }
+ #endif
+ }
+ }
+ }
+ #if os(macOS)
+ .padding(MacChrome.pageInset)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ #else
+ .padding()
+ .padding(.bottom, 24)
+ #endif
+ }
+ .navigationTitle(parts.title)
+ .navigationSubtitle(parts.author ?? vm.tr(b.str("subKey")))
+ }
+ }
+}
+
+private struct BookActionCard: View {
+ let title: String
+ let subtitle: String
+ let systemImage: String
+ let done: Bool
+ let doneLabel: String
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 16) {
+ Image(systemName: systemImage)
+ #if os(macOS)
+ .font(.largeTitle)
+ #else
+ .font(.title)
+ #endif
+ .foregroundStyle(.tint)
+ .frame(width: 44)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ #if os(macOS)
+ .font(.title3.weight(.semibold))
+ #else
+ .font(.headline)
+ #endif
+ .foregroundStyle(.primary)
+ Text(subtitle)
+ #if os(macOS)
+ .font(.body)
+ #else
+ .font(.subheadline)
+ #endif
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ if done {
+ Image(systemName: "checkmark.circle.fill")
+ #if os(macOS)
+ .font(.title2)
+ #else
+ .font(.title3)
+ #endif
+ .foregroundStyle(.green)
+ .accessibilityLabel(doneLabel)
+ } else {
+ Image(systemName: "chevron.right")
+ .font(.body.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
+ }
+ #if os(macOS)
+ .padding(20)
+ #else
+ .padding(16)
+ #endif
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ .softSurface(cornerRadius: 18)
+ }
+}
+
+private struct BookNoteCard: View {
+ let note: J
+
+ var body: some View {
+ GlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Label(note.str("title"), systemImage: noteSymbol(note.str("icon")))
+ #if os(macOS)
+ .font(.title2.weight(.semibold))
+ #else
+ .font(.headline)
+ #endif
+ VStack(alignment: .leading, spacing: 10) {
+ ForEach(Array(note.strs("lines").enumerated()), id: \.offset) { _, line in
+ HStack(alignment: .top, spacing: 10) {
+ Text("•")
+ .foregroundStyle(.secondary)
+ Text(line)
+ #if os(macOS)
+ .font(.title3)
+ #else
+ .font(.body)
+ #endif
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private func bookTitleParts(_ title: String) -> (author: String?, title: String) {
+ let separators = [" – ", " — ", " - "]
+ for sep in separators {
+ if let range = title.range(of: sep) {
+ let author = String(title[.. String {
+ switch icon {
+ case "book": return "book.fill"
+ case "globe": return "globe.europe.africa.fill"
+ case "people": return "person.2.fill"
+ case "bulb": return "lightbulb.fill"
+ default: return "text.alignleft"
+ }
+}
+
+struct RouteDestination: View {
+ @ObservedObject var vm: AppModel
+ let route: Route
+
+ var body: some View {
+ switch route {
+ case .welcome, .subjects:
+ PracticeHome(vm: vm)
+ case .stats:
+ StatsScreen(vm: vm)
+ case .deHome:
+ DeHomeScreen(vm: vm)
+ case .deMap(let year):
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("de_year\(year)"),
+ subtitle: vm.tr("de_y\(year)_sub"),
+ rows: vm.content.deYear(year).map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "d\(year)\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.deDone(year, id),
+ destination: .deLesson(year, id)
+ )
+ }
+ )
+ case .deLesson(let year, let id):
+ if let l = vm.content.deLesson(year, id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .deEx(year, id))
+ }
+ case .deEx(let year, let id):
+ EngExScreen(vm: vm, year: year, id: id, voice: "de-DE", lesson: { $0.content.deLesson($1, $2) }, mark: { $0.markDe($1, $2) })
+ case .roadmap:
+ RoadmapScreen(vm: vm)
+ case .unitMap(let id):
+ UnitMapScreen(vm: vm, unitId: id)
+ case .germanEx(let unit, let ex):
+ GermanExercise(vm: vm, unitId: unit, ex: ex)
+ case .vocab(let id):
+ VocabExercise(vm: vm, unitId: id)
+ case .netYears:
+ NetYearsScreen(vm: vm)
+ case .netMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("net_year1"),
+ subtitle: vm.tr("net_sub"),
+ rows: vm.content.netLessons.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "n\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.netDone(id),
+ destination: .netLesson(id)
+ )
+ }
+ )
+ case .netLesson(let id):
+ if let l = vm.content.netLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
+ }
+ case .netEx(let id):
+ NetQuizScreen(vm: vm, id: id)
+ case .net2Map:
+ ItYearList(vm: vm, course: "net2", titleKey: "net_year2", subKey: "net2_sub") { .net2Lesson($0) }
+ case .net2Lesson(let id):
+ ItSlides(vm: vm, course: "net2", id: id, next: .net2Ex(id))
+ case .net2Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net2", id)) { vm.markCourse("net2", id) }
+ case .net3Map:
+ ItYearList(vm: vm, course: "net3", titleKey: "net_year3", subKey: "net3_sub") { .net3Lesson($0) }
+ case .net3Lesson(let id):
+ ItSlides(vm: vm, course: "net3", id: id, next: .net3Ex(id))
+ case .net3Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net3", id)) { vm.markCourse("net3", id) }
+ case .net4Map:
+ ItYearList(vm: vm, course: "net4", titleKey: "net_year4", subKey: "net4_sub") { .net4Lesson($0) }
+ case .net4Lesson(let id):
+ ItSlides(vm: vm, course: "net4", id: id, next: .net4Ex(id))
+ case .net4Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net4", id)) { vm.markCourse("net4", id) }
+ case .hwYears:
+ HwYearsScreen(vm: vm)
+ case .hwMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("hw_year1"),
+ subtitle: vm.tr("hw_sub"),
+ rows: vm.content.hw.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "h\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.hwDone(id),
+ destination: .hwLesson(id)
+ )
+ }
+ )
+ case .hwLesson(let id):
+ if let l = vm.content.hwLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
+ }
+ case .hwEx(let id):
+ HwQuizScreen(vm: vm, id: id)
+ case .hw2Map:
+ ItYearList(vm: vm, course: "hw2", titleKey: "hw_year2", subKey: "hw2_sub") { .hw2Lesson($0) }
+ case .hw2Lesson(let id):
+ ItSlides(vm: vm, course: "hw2", id: id, next: .hw2Ex(id))
+ case .hw2Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw2", id)) { vm.markCourse("hw2", id) }
+ case .hw3Map:
+ ItYearList(vm: vm, course: "hw3", titleKey: "hw_year3", subKey: "hw3_sub") { .hw3Lesson($0) }
+ case .hw3Lesson(let id):
+ ItSlides(vm: vm, course: "hw3", id: id, next: .hw3Ex(id))
+ case .hw3Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw3", id)) { vm.markCourse("hw3", id) }
+ case .hw4Map:
+ ItYearList(vm: vm, course: "hw4", titleKey: "hw_year4", subKey: "hw4_sub") { .hw4Lesson($0) }
+ case .hw4Lesson(let id):
+ ItSlides(vm: vm, course: "hw4", id: id, next: .hw4Ex(id))
+ case .hw4Ex(let id):
+ ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw4", id)) { vm.markCourse("hw4", id) }
+ case .onYears:
+ OnYearsScreen(vm: vm)
+ case .enYears:
+ EnYearsScreen(vm: vm)
+ case .enMap(let year):
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("en_year\(year)"),
+ subtitle: vm.tr("en_y\(year)_sub"),
+ rows: vm.content.enYear(year).map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "e\(year)\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.enDone(year, id),
+ destination: .enLesson(year, id)
+ )
+ }
+ )
+ case .enLesson(let year, let id):
+ if let l = vm.content.enLesson(year, id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .enEx(year, id))
+ }
+ case .enEx(let year, let id):
+ EngExScreen(vm: vm, year: year, id: id)
+ case .onMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("on_year1"),
+ subtitle: vm.tr("on_sub"),
+ rows: vm.content.on.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "o\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.onDone(id),
+ destination: .onLesson(id)
+ )
+ }
+ )
+ case .onLesson(let id):
+ if let l = vm.content.onLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .onEx(id))
+ }
+ case .onEx(let id):
+ OnQuizScreen(vm: vm, id: id)
+ case .on2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("on_year2"),
+ subtitle: vm.tr("on2_sub"),
+ rows: vm.content.on2.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "o2\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.on2Done(id),
+ destination: .on2Lesson(id)
+ )
+ }
+ )
+ case .on2Lesson(let id):
+ if let l = vm.content.on2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on2Ex(id))
+ }
+ case .on2Ex(let id):
+ On2QuizScreen(vm: vm, id: id)
+ case .on3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("on_year3"),
+ subtitle: vm.tr("on3_sub"),
+ rows: vm.content.on3.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "o3\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.on3Done(id),
+ destination: .on3Lesson(id)
+ )
+ }
+ )
+ case .on3Lesson(let id):
+ if let l = vm.content.on3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on3Ex(id))
+ }
+ case .on3Ex(let id):
+ On3QuizScreen(vm: vm, id: id)
+ case .on4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("on_year4"),
+ subtitle: vm.tr("on4_sub"),
+ rows: vm.content.on4.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "o4\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.on4Done(id),
+ destination: .on4Lesson(id)
+ )
+ }
+ )
+ case .on4Lesson(let id):
+ if let l = vm.content.on4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on4Ex(id))
+ }
+ case .on4Ex(let id):
+ On4QuizScreen(vm: vm, id: id)
+ case .czechMap:
+ CzechMapScreen(vm: vm)
+ case .litYears:
+ LitYearsScreen(vm: vm)
+ case .litMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("lit_year1"),
+ subtitle: vm.tr("lit_sub"),
+ rows: vm.content.lit.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "l\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.litDone(id),
+ destination: .litLesson(id)
+ )
+ }
+ )
+ case .litLesson(let id):
+ if let l = vm.content.litLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .litEx(id))
+ }
+ case .litEx(let id):
+ LitYearQuizScreen(vm: vm, year: 1, id: id)
+ case .lit2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("lit_year2"),
+ subtitle: vm.tr("lit2_sub"),
+ rows: vm.content.lit2.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "l2\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.lit2Done(id),
+ destination: .lit2Lesson(id)
+ )
+ }
+ )
+ case .lit2Lesson(let id):
+ if let l = vm.content.lit2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit2Ex(id))
+ }
+ case .lit2Ex(let id):
+ LitYearQuizScreen(vm: vm, year: 2, id: id)
+ case .lit3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("lit_year3"),
+ subtitle: vm.tr("lit3_sub"),
+ rows: vm.content.lit3.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "l3\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.lit3Done(id),
+ destination: .lit3Lesson(id)
+ )
+ }
+ )
+ case .lit3Lesson(let id):
+ if let l = vm.content.lit3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit3Ex(id))
+ }
+ case .lit3Ex(let id):
+ LitYearQuizScreen(vm: vm, year: 3, id: id)
+ case .lit4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("lit_year4"),
+ subtitle: vm.tr("lit4_sub"),
+ rows: vm.content.lit4.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "l4\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.lit4Done(id),
+ destination: .lit4Lesson(id)
+ )
+ }
+ )
+ case .lit4Lesson(let id):
+ if let l = vm.content.lit4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit4Ex(id))
+ }
+ case .lit4Ex(let id):
+ LitYearQuizScreen(vm: vm, year: 4, id: id)
+ case .sciMap:
+ SciMapScreen(vm: vm)
+ case .chemMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("Chemie"),
+ subtitle: vm.tr("chem_sub"),
+ rows: vm.content.chem.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "c\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.chemDone(id),
+ destination: .chemLesson(id)
+ )
+ }
+ )
+ case .chemLesson(let id):
+ if let l = vm.content.chemLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .chemEx(id))
+ }
+ case .chemEx(let id):
+ SciQuizScreen(vm: vm, area: "chem", id: id)
+ case .bioMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("Biologie"),
+ subtitle: vm.tr("bio_sub"),
+ rows: vm.content.bio.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "b\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.bioDone(id),
+ destination: .bioLesson(id)
+ )
+ }
+ )
+ case .bioLesson(let id):
+ if let l = vm.content.bioLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .bioEx(id))
+ }
+ case .bioEx(let id):
+ SciQuizScreen(vm: vm, area: "bio", id: id)
+ case .matYears:
+ MatYearsScreen(vm: vm)
+ case .mat0Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("mat0_year"),
+ subtitle: vm.tr("mat0_sub"),
+ rows: vm.content.mat0.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "m0\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat0Done(id), destination: .mat0Lesson(id))
+ }
+ )
+ case .mat0Lesson(let id):
+ if let l = vm.content.mat0Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat0Ex(id))
+ }
+ case .mat0Ex(let id):
+ MathPracticeScreen(vm: vm, year: 0, id: id)
+ case .matMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("mat_year1"),
+ subtitle: vm.tr("mat_sub"),
+ rows: vm.content.mat.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "m1\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.matDone(id), destination: .matLesson(id))
+ }
+ )
+ case .matLesson(let id):
+ if let l = vm.content.matLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .matEx(id))
+ }
+ case .matEx(let id):
+ MathPracticeScreen(vm: vm, year: 1, id: id)
+ case .mat2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("mat_year2"),
+ subtitle: vm.tr("mat2_sub"),
+ rows: vm.content.mat2.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "m2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat2Done(id), destination: .mat2Lesson(id))
+ }
+ )
+ case .mat2Lesson(let id):
+ if let l = vm.content.mat2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat2Ex(id))
+ }
+ case .mat2Ex(let id):
+ MathPracticeScreen(vm: vm, year: 2, id: id)
+ case .mat3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("mat_year3"),
+ subtitle: vm.tr("mat3_sub"),
+ rows: vm.content.mat3.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "m3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat3Done(id), destination: .mat3Lesson(id))
+ }
+ )
+ case .mat3Lesson(let id):
+ if let l = vm.content.mat3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat3Ex(id))
+ }
+ case .mat3Ex(let id):
+ MathPracticeScreen(vm: vm, year: 3, id: id)
+ case .mat4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("mat_year4"),
+ subtitle: vm.tr("mat4_sub"),
+ rows: vm.content.mat4.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "m4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat4Done(id), destination: .mat4Lesson(id))
+ }
+ )
+ case .mat4Lesson(let id):
+ if let l = vm.content.mat4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat4Ex(id))
+ }
+ case .mat4Ex(let id):
+ MathPracticeScreen(vm: vm, year: 4, id: id)
+ case .fyzYears:
+ FyzYearsScreen(vm: vm)
+ case .fyzMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("fyz_year1"),
+ subtitle: vm.tr("fyz_sub"),
+ rows: vm.content.fyz.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "f\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.fyzDone(id),
+ destination: .fyzLesson(id)
+ )
+ }
+ )
+ case .fyzLesson(let id):
+ if let l = vm.content.fyzLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyzEx(id))
+ }
+ case .fyzEx(let id):
+ FyzQuizScreen(vm: vm, year: 1, id: id)
+ case .fyz2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("fyz_year2"),
+ subtitle: vm.tr("fyz2_sub"),
+ rows: vm.content.fyz2.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "f2\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.fyz2Done(id),
+ destination: .fyz2Lesson(id)
+ )
+ }
+ )
+ case .fyz2Lesson(let id):
+ if let l = vm.content.fyz2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz2Ex(id))
+ }
+ case .fyz2Ex(let id):
+ FyzQuizScreen(vm: vm, year: 2, id: id)
+ case .fyz3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("fyz_year3"),
+ subtitle: vm.tr("fyz3_sub"),
+ rows: vm.content.fyz3.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "f3\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.fyz3Done(id),
+ destination: .fyz3Lesson(id)
+ )
+ }
+ )
+ case .fyz3Lesson(let id):
+ if let l = vm.content.fyz3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz3Ex(id))
+ }
+ case .fyz3Ex(let id):
+ FyzQuizScreen(vm: vm, year: 3, id: id)
+ case .fyz4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("fyz_year4"),
+ subtitle: vm.tr("fyz4_sub"),
+ rows: vm.content.fyz4.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "f4\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.fyz4Done(id),
+ destination: .fyz4Lesson(id)
+ )
+ }
+ )
+ case .fyz4Lesson(let id):
+ if let l = vm.content.fyz4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz4Ex(id))
+ }
+ case .fyz4Ex(let id):
+ FyzQuizScreen(vm: vm, year: 4, id: id)
+ case .csYears:
+ CsYearsScreen(vm: vm)
+ case .csMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("cs_year1"),
+ subtitle: vm.tr("cs_sub"),
+ rows: vm.content.cs.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "c\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.csDone(id), destination: .csLesson(id))
+ }
+ )
+ case .csLesson(let id):
+ if let l = vm.content.csLesson(id) {
+ CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs(id) })
+ }
+ case .cs2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("cs_year2"),
+ subtitle: vm.tr("cs2_sub"),
+ rows: vm.content.cs2.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "c2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs2Done(id), destination: .cs2Lesson(id))
+ }
+ )
+ case .cs2Lesson(let id):
+ if let l = vm.content.cs2Lesson(id) {
+ CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs2(id) })
+ }
+ case .cs3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("cs_year3"),
+ subtitle: vm.tr("cs3_sub"),
+ rows: vm.content.cs3.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "c3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs3Done(id), destination: .cs3Lesson(id))
+ }
+ )
+ case .cs3Lesson(let id):
+ if let l = vm.content.cs3Lesson(id) {
+ CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs3(id) })
+ }
+ case .cs4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("cs_year4"),
+ subtitle: vm.tr("cs4_sub"),
+ rows: vm.content.cs4.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "c4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs4Done(id), destination: .cs4Lesson(id))
+ }
+ )
+ case .cs4Lesson(let id):
+ if let l = vm.content.cs4Lesson(id) {
+ CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs4(id) })
+ }
+ case .swYears:
+ SwYearsScreen(vm: vm)
+ case .swMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("sw_year1"),
+ subtitle: vm.tr("sw_sub"),
+ rows: vm.content.sw.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "s\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.swDone(id), destination: .swLesson(id))
+ }
+ )
+ case .swLesson(let id):
+ if let l = vm.content.swLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .swEx(id))
+ }
+ case .swEx(let id):
+ SwQuizScreen(vm: vm, year: 1, id: id)
+ case .sw2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("sw_year2"),
+ subtitle: vm.tr("sw2_sub"),
+ rows: vm.content.sw2.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "s2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.sw2Done(id), destination: .sw2Lesson(id))
+ }
+ )
+ case .sw2Lesson(let id):
+ if let l = vm.content.sw2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .sw2Ex(id))
+ }
+ case .sw2Ex(let id):
+ SwQuizScreen(vm: vm, year: 2, id: id)
+ case .sw3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("sw_year3"),
+ subtitle: vm.tr("sw3_sub"),
+ rows: vm.content.sw3.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "s3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.sw3Done(id), destination: .sw3Lesson(id))
+ }
+ )
+ case .sw3Lesson(let id):
+ if let l = vm.content.sw3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .sw3Ex(id))
+ }
+ case .sw3Ex(let id):
+ SwQuizScreen(vm: vm, year: 3, id: id)
+ case .sw4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("sw_year4"),
+ subtitle: vm.tr("sw4_sub"),
+ rows: vm.content.sw4.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "s4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.sw4Done(id), destination: .sw4Lesson(id))
+ }
+ )
+ case .sw4Lesson(let id):
+ if let l = vm.content.sw4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .sw4Ex(id))
+ }
+ case .sw4Ex(let id):
+ SwQuizScreen(vm: vm, year: 4, id: id)
+ case .pgYears:
+ PgYearsScreen(vm: vm)
+ case .pgMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("pg_year1"),
+ subtitle: vm.tr("pg_sub"),
+ rows: vm.content.pg.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "p\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pgDone(id), destination: .pgLesson(id))
+ }
+ )
+ case .pgLesson(let id):
+ if let l = vm.content.pgLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pgEx(id))
+ }
+ case .pgEx(let id):
+ PgQuizScreen(vm: vm, year: 1, id: id)
+ case .pg2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("pg_year2"),
+ subtitle: vm.tr("pg2_sub"),
+ rows: vm.content.pg2.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "p2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg2Done(id), destination: .pg2Lesson(id))
+ }
+ )
+ case .pg2Lesson(let id):
+ if let l = vm.content.pg2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg2Ex(id))
+ }
+ case .pg2Ex(let id):
+ PgQuizScreen(vm: vm, year: 2, id: id)
+ case .pg3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("pg_year3"),
+ subtitle: vm.tr("pg3_sub"),
+ rows: vm.content.pg3.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "p3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg3Done(id), destination: .pg3Lesson(id))
+ }
+ )
+ case .pg3Lesson(let id):
+ if let l = vm.content.pg3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg3Ex(id))
+ }
+ case .pg3Ex(let id):
+ PgQuizScreen(vm: vm, year: 3, id: id)
+ case .pg4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("pg_year4"),
+ subtitle: vm.tr("pg4_sub"),
+ rows: vm.content.pg4.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "p4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg4Done(id), destination: .pg4Lesson(id))
+ }
+ )
+ case .pg4Lesson(let id):
+ if let l = vm.content.pg4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg4Ex(id))
+ }
+ case .pg4Ex(let id):
+ PgQuizScreen(vm: vm, year: 4, id: id)
+ case .tgYears:
+ TgYearsScreen(vm: vm)
+ case .tgMap:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("tg_year1"),
+ subtitle: vm.tr("tg_sub"),
+ rows: vm.content.tg.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "t\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tgDone(id), destination: .tgLesson(id))
+ }
+ )
+ case .tgLesson(let id):
+ if let l = vm.content.tgLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tgEx(id))
+ }
+ case .tgEx(let id):
+ TgQuizScreen(vm: vm, year: 1, id: id)
+ case .tg2Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("tg_year2"),
+ subtitle: vm.tr("tg2_sub"),
+ rows: vm.content.tg2.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "t2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg2Done(id), destination: .tg2Lesson(id))
+ }
+ )
+ case .tg2Lesson(let id):
+ if let l = vm.content.tg2Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg2Ex(id))
+ }
+ case .tg2Ex(let id):
+ TgQuizScreen(vm: vm, year: 2, id: id)
+ case .tg3Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("tg_year3"),
+ subtitle: vm.tr("tg3_sub"),
+ rows: vm.content.tg3.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "t3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg3Done(id), destination: .tg3Lesson(id))
+ }
+ )
+ case .tg3Lesson(let id):
+ if let l = vm.content.tg3Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg3Ex(id))
+ }
+ case .tg3Ex(let id):
+ TgQuizScreen(vm: vm, year: 3, id: id)
+ case .tg4Map:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("tg_year4"),
+ subtitle: vm.tr("tg4_sub"),
+ rows: vm.content.tg4.map { l in
+ let id = l.int("id")
+ return LessonRow(id: "t4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg4Done(id), destination: .tg4Lesson(id))
+ }
+ )
+ case .tg4Lesson(let id):
+ if let l = vm.content.tg4Lesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg4Ex(id))
+ }
+ case .tg4Ex(let id):
+ TgQuizScreen(vm: vm, year: 4, id: id)
+ case .mluvnice:
+ LessonListScreen(
+ vm: vm,
+ title: vm.tr("Mluvnice"),
+ subtitle: vm.tr("mluv_sub"),
+ rows: vm.content.mluvnice.map { m in
+ let n = m.int("id")
+ return LessonRow(
+ id: "m\(n)",
+ title: m.str("name"),
+ done: vm.progress.mluvDone(n),
+ destination: .mluvEx(n)
+ )
+ }
+ )
+ case .mluvEx(let n):
+ MluvExercise(vm: vm, n: n)
+ case .readingList:
+ BookListScreen(vm: vm)
+ case .book(let id):
+ BookScreen(vm: vm, id: id)
+ case .bookQuiz(let id):
+ LitQuizScreen(vm: vm, id: id)
+ case .bookPlot(let id):
+ PlotScreen(vm: vm, id: id)
+ }
+ }
+}
+
+struct CodeLessonScreen: View {
+ @ObservedObject var vm: AppModel
+ let lesson: J
+ let onSuccess: () -> Void
+ @State private var code = ""
+ @State private var stdin = ""
+ @State private var output = ""
+ @State private var status = ""
+
+ private var running: Bool { status == "running" }
+ private var needsInput: Bool { code.contains("ReadLine") }
+
+ var body: some View {
+ GeometryReader { geo in
+ if geo.size.width >= 900 {
+ HStack(alignment: .top, spacing: 28) {
+ ScrollView {
+ explanation
+ .padding(.vertical, 4)
+ }
+ .frame(width: min(560, geo.size.width * 0.4))
+ workspace(height: geo.size.height)
+ }
+ .padding(.horizontal, 28)
+ .padding(.vertical, 20)
+ .frame(maxWidth: 1640)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ explanation
+ editorCard.frame(height: 320)
+ if needsInput { inputField }
+ outputCard.frame(height: 220)
+ }
+ .padding(16)
+ }
+ }
+ }
+ .detailScreen(vm.tr(lesson.str("titleKey")), subtitle: vm.tr(lesson.str("subKey")))
+ .onAppear {
+ if code.isEmpty { code = lesson.str("code") }
+ }
+ }
+
+ private var explanation: some View {
+ VStack(alignment: .leading, spacing: 34) {
+ ForEach(Array(lesson.arr("slides").enumerated()), id: \.offset) { _, slide in
+ SlideBody(slide: slide)
+ }
+ }
+ }
+
+ private func workspace(height: CGFloat) -> some View {
+ VStack(spacing: 16) {
+ editorCard
+ .frame(maxHeight: .infinity)
+ if needsInput { inputField }
+ outputCard
+ .frame(height: max(180, height * 0.32))
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var editorCard: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 10) {
+ Image(systemName: "chevron.left.forwardslash.chevron.right")
+ .foregroundStyle(Color.accentColor)
+ Text("Program.cs")
+ .font(.headline.monospaced())
+ Spacer(minLength: 8)
+ Button {
+ code = lesson.str("code")
+ output = ""
+ status = ""
+ } label: {
+ Label(vm.tr("cs_reset"), systemImage: "arrow.counterclockwise")
+ }
+ .buttonStyle(.bordered)
+ .disabled(running)
+ Button { run() } label: {
+ Label(vm.tr("cs_run"), systemImage: "play.fill")
+ .padding(.horizontal, 4)
+ }
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.return, modifiers: .command)
+ .help("⌘↩")
+ .disabled(running)
+ }
+ #if os(macOS)
+ .controlSize(.large)
+ #endif
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ Divider().opacity(0.5)
+ editorField
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(.separator.opacity(0.6), lineWidth: 0.5)
+ }
+ }
+
+ @ViewBuilder
+ private var editorField: some View {
+ #if os(macOS)
+ CodeEditor(text: $code)
+ #else
+ TextEditor(text: $code)
+ .font(.system(size: 15, design: .monospaced))
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .scrollContentBackground(.hidden)
+ .padding(8)
+ #endif
+ }
+
+ private var inputField: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label(vm.tr("cs_stdin"), systemImage: "keyboard")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ TextField("", text: $stdin, axis: .vertical)
+ .font(.system(size: 15, design: .monospaced))
+ .lineLimit(1...4)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ private var outputCard: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 10) {
+ Image(systemName: "terminal")
+ .foregroundStyle(.secondary)
+ Text(vm.tr("cs_output"))
+ .font(.headline)
+ Spacer(minLength: 8)
+ statusBadge
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 44)
+ Divider().opacity(0.5)
+ ScrollView {
+ Text(output.isEmpty ? vm.tr("cs_empty") : output)
+ .font(.system(size: 15, design: .monospaced))
+ .foregroundStyle(output.isEmpty || running ? .secondary : .primary)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .textSelection(.enabled)
+ .padding(14)
+ }
+ }
+ .background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(.separator.opacity(0.6), lineWidth: 0.5)
+ }
+ }
+
+ @ViewBuilder
+ private var statusBadge: some View {
+ switch status {
+ case "running":
+ ProgressView().controlSize(.small)
+ case "ok":
+ Label(vm.tr("cs_done"), systemImage: "checkmark.circle.fill")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(vm.palette.success)
+ case "fail", "timeout":
+ Label(vm.tr("cs_failed"), systemImage: "xmark.octagon.fill")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(vm.palette.error)
+ default:
+ EmptyView()
+ }
+ }
+
+ private var codeFill: AnyShapeStyle {
+ #if os(macOS)
+ AnyShapeStyle(.background.secondary)
+ #else
+ AnyShapeStyle(Color(.secondarySystemBackground))
+ #endif
+ }
+
+ private func run() {
+ guard !running else { return }
+ status = "running"
+ output = vm.tr("cs_running")
+ CSharpRunner.run(code, stdin: needsInput ? stdin : "") { result, text in
+ status = result
+ switch result {
+ case "unavailable":
+ output = vm.tr("cs_unavailable")
+ case "timeout":
+ output = (text + "\n\n" + vm.tr("cs_timeout")).trimmingCharacters(in: .whitespacesAndNewlines)
+ default:
+ output = text.isEmpty ? vm.tr("cs_empty") : text
+ }
+ if result == "ok" { onSuccess() }
+ }
+ }
+}
+
+#if os(macOS)
+/// Plain-text editor for C#: monospaced, coloured, and without smart quotes or
+/// autocorrect, which would turn "text" into “text” and break the program.
+struct CodeEditor: NSViewRepresentable {
+ @Binding var text: String
+
+ func makeCoordinator() -> Coordinator { Coordinator(text: $text) }
+
+ func makeNSView(context: Context) -> NSScrollView {
+ let scroll = NSTextView.scrollableTextView()
+ scroll.drawsBackground = false
+ scroll.autohidesScrollers = true
+ guard let view = scroll.documentView as? NSTextView else { return scroll }
+ view.delegate = context.coordinator
+ view.isRichText = false
+ view.allowsUndo = true
+ view.isAutomaticQuoteSubstitutionEnabled = false
+ view.isAutomaticDashSubstitutionEnabled = false
+ view.isAutomaticTextReplacementEnabled = false
+ view.isAutomaticSpellingCorrectionEnabled = false
+ view.isContinuousSpellCheckingEnabled = false
+ view.isGrammarCheckingEnabled = false
+ view.isAutomaticLinkDetectionEnabled = false
+ view.smartInsertDeleteEnabled = false
+ view.drawsBackground = false
+ view.font = CSharpStyle.font
+ view.textContainerInset = NSSize(width: 10, height: 12)
+ view.typingAttributes = CSharpStyle.base
+ view.string = text
+ CSharpStyle.highlight(view.textStorage)
+ return scroll
+ }
+
+ func updateNSView(_ scroll: NSScrollView, context: Context) {
+ context.coordinator.text = $text
+ guard let view = scroll.documentView as? NSTextView, view.string != text else { return }
+ view.string = text
+ CSharpStyle.highlight(view.textStorage)
+ }
+
+ final class Coordinator: NSObject, NSTextViewDelegate {
+ var text: Binding
+
+ init(text: Binding) {
+ self.text = text
+ }
+
+ func textDidChange(_ notification: Notification) {
+ guard let view = notification.object as? NSTextView else { return }
+ text.wrappedValue = view.string
+ CSharpStyle.highlight(view.textStorage)
+ view.typingAttributes = CSharpStyle.base
+ }
+ }
+}
+
+enum CSharpStyle {
+ static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular)
+ static var base: [NSAttributedString.Key: Any] { [.font: font, .foregroundColor: NSColor.labelColor] }
+
+ private static func adaptive(_ light: Int, _ dark: Int) -> NSColor {
+ func color(_ hex: Int) -> NSColor {
+ NSColor(
+ srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
+ green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255,
+ alpha: 1
+ )
+ }
+ return NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? color(dark) : color(light)
+ }
+ }
+
+ private static let keywords = [
+ "abstract", "as", "async", "await", "base", "bool", "break", "case", "catch", "char", "class",
+ "const", "continue", "decimal", "default", "do", "double", "else", "enum", "false", "finally",
+ "float", "for", "foreach", "get", "if", "in", "int", "interface", "internal", "is", "long",
+ "namespace", "new", "null", "object", "out", "override", "private", "protected", "public",
+ "readonly", "record", "ref", "return", "sealed", "set", "static", "string", "struct", "switch",
+ "this", "throw", "true", "try", "using", "var", "virtual", "void", "while",
+ ]
+
+ private static let rules: [(NSRegularExpression, NSColor)] = {
+ func re(_ pattern: String) -> NSRegularExpression {
+ try! NSRegularExpression(pattern: pattern)
+ }
+ return [
+ (re("\\b(?:" + keywords.joined(separator: "|") + ")\\b"), adaptive(0x9B2393, 0xFF7AB2)),
+ (re("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"), adaptive(0x0B4F79, 0x5DD8FF)),
+ (re("\\b\\d+(?:\\.\\d+)?\\b"), adaptive(0x1C00CF, 0xD9C97C)),
+ (re("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"), adaptive(0xC41A16, 0xFC6A5D)),
+ (re("//[^\\n]*|/\\*[\\s\\S]*?\\*/"), adaptive(0x5D6C79, 0x7F8C98)),
+ ]
+ }()
+
+ static func highlight(_ storage: NSTextStorage?) {
+ guard let storage else { return }
+ let text = storage.string
+ let full = NSRange(location: 0, length: (text as NSString).length)
+ storage.beginEditing()
+ storage.setAttributes(base, range: full)
+ for (pattern, color) in rules {
+ pattern.enumerateMatches(in: text, range: full) { match, _, _ in
+ if let range = match?.range {
+ storage.addAttribute(.foregroundColor, value: color, range: range)
+ }
+ }
+ }
+ storage.endEditing()
+ }
+}
+#endif
+
+enum CSharpRunner {
+ static func run(_ source: String, stdin: String, done: @escaping (String, String) -> Void) {
+ #if os(macOS)
+ DispatchQueue.global(qos: .userInitiated).async {
+ let result = runMac(source, stdin: stdin)
+ DispatchQueue.main.async { done(result.0, result.1) }
+ }
+ #else
+ done("unavailable", "")
+ #endif
+ }
+
+ #if os(macOS)
+ private static func runMac(_ source: String, stdin: String) -> (String, String) {
+ guard source.count <= 100_000 else { return ("fail", "Program je příliš dlouhý.") }
+ guard let dotnet = findDotnet() else { return ("unavailable", "") }
+ let dir = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".graduately/csharp-lab")
+ let csproj = dir.appendingPathComponent("Lab.csproj")
+ if !FileManager.default.fileExists(atPath: csproj.path) {
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let framework = targetFramework(dotnet)
+ let xml = """
+
+
+ Exe
+ \(framework)
+ enable
+ disable
+ latest
+
+
+ """
+ try? xml.write(to: csproj, atomically: true, encoding: .utf8)
+ let restored = command(dotnet, ["restore", dir.path], dir, "", 90)
+ if restored.0 != "ok" { return restored }
+ }
+ do {
+ try source.write(to: dir.appendingPathComponent("Program.cs"), atomically: true, encoding: .utf8)
+ } catch {
+ return ("fail", error.localizedDescription)
+ }
+ var args = ["run", "--project", dir.path, "--verbosity", "quiet"]
+ if FileManager.default.fileExists(atPath: dir.appendingPathComponent("obj").path) {
+ args.append("--no-restore")
+ }
+ return command(dotnet, args, dir, stdin, 20)
+ }
+
+ private static func command(_ dotnet: String, _ args: [String], _ dir: URL, _ stdin: String, _ seconds: Double) -> (String, String) {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: dotnet)
+ proc.arguments = args
+ proc.currentDirectoryURL = dir
+ var env = ProcessInfo.processInfo.environment
+ let root = (dotnet as NSString).deletingLastPathComponent
+ env["PATH"] = [root, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].joined(separator: ":")
+ env["DOTNET_ROOT"] = root
+ env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
+ env["DOTNET_NOLOGO"] = "1"
+ env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
+ proc.environment = env
+ let out = Pipe()
+ let input = Pipe()
+ proc.standardOutput = out
+ proc.standardError = out
+ proc.standardInput = input
+ var collected = Data()
+ let lock = NSLock()
+ out.fileHandleForReading.readabilityHandler = { handle in
+ let chunk = handle.availableData
+ guard !chunk.isEmpty else { return }
+ lock.lock()
+ collected.append(chunk)
+ lock.unlock()
+ }
+ do { try proc.run() } catch { return ("fail", error.localizedDescription) }
+ if let data = stdin.data(using: .utf8) { input.fileHandleForWriting.write(data) }
+ try? input.fileHandleForWriting.close()
+ let group = DispatchGroup()
+ group.enter()
+ proc.terminationHandler = { _ in group.leave() }
+ if group.wait(timeout: .now() + seconds) == .timedOut {
+ proc.terminate()
+ kill(proc.processIdentifier, SIGKILL)
+ out.fileHandleForReading.readabilityHandler = nil
+ return ("timeout", clip(collected, lock))
+ }
+ out.fileHandleForReading.readabilityHandler = nil
+ let rest = out.fileHandleForReading.readDataToEndOfFile()
+ lock.lock()
+ collected.append(rest)
+ lock.unlock()
+ let text = clip(collected, lock)
+ return proc.terminationStatus == 0 ? ("ok", text) : ("fail", text)
+ }
+
+ private static func clip(_ data: Data, _ lock: NSLock) -> String {
+ lock.lock()
+ let copy = data
+ lock.unlock()
+ let text = String(data: copy, encoding: .utf8) ?? ""
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.count <= 16_000 ? trimmed : String(trimmed.prefix(16_000)) + "\n…"
+ }
+
+ private static func targetFramework(_ dotnet: String) -> String {
+ let version = command(dotnet, ["--version"], URL(fileURLWithPath: NSTemporaryDirectory()), "", 20)
+ let major = version.1.split(separator: ".").first.map(String.init) ?? ""
+ return major.allSatisfy(\.isNumber) && !major.isEmpty ? "net\(major).0" : "net8.0"
+ }
+
+ private static func findDotnet() -> String? {
+ let names = [
+ "/usr/local/share/dotnet/dotnet",
+ "/opt/homebrew/share/dotnet/dotnet",
+ "/usr/local/bin/dotnet",
+ NSHomeDirectory() + "/.dotnet/dotnet",
+ ]
+ return names.first { FileManager.default.isExecutableFile(atPath: $0) }
+ }
+ #endif
+}
diff --git a/ios/Graduately/Update/Updater.swift b/ios/Graduately/Update/Updater.swift
new file mode 100644
index 0000000..29f8a8a
--- /dev/null
+++ b/ios/Graduately/Update/Updater.swift
@@ -0,0 +1,120 @@
+import Foundation
+import UIKit
+
+struct UpdateState {
+ var status = "idle"
+ var remote: String?
+ var messageKey = "update_current"
+ var messageArg: String? = String(AppConfig.commit.prefix(7))
+ var canInstall = false
+}
+
+final class Updater {
+ private let repo = AppConfig.updateRepo
+ private let branch = AppConfig.updateBranch
+
+ func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
+ if AppConfig.commit == "dev" {
+ emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
+ return
+ }
+ emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let remote: String
+ // VERSION-ios names the commit whose IPA is really in the
+ // branch; it is missing only when the iOS job failed or on
+ // older branches, where the branch-wide commit is used.
+ if let tip = self.atomTip(atom),
+ let marker = try? self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION-ios").trimmingCharacters(in: .whitespacesAndNewlines),
+ marker.count >= 7 {
+ remote = marker
+ } else if let sha = self.atomMainSha(atom) {
+ remote = sha
+ } else if let tip = self.atomTip(atom) {
+ remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION").trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ throw URLError(.cannotParseResponse)
+ }
+ let local = AppConfig.commit
+ if self.sameCommit(remote, local) {
+ self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
+ } else {
+ self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
+ }
+ }
+ }
+
+ func install(cb: @escaping (UpdateState) -> Void) {
+ emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let tip = self.atomTip(atom) ?? ""
+ let page = "https://github.com/\(self.repo)/raw/main/installers/graduately-ios.html"
+ DispatchQueue.main.async {
+ if let url = URL(string: page) {
+ UIApplication.shared.open(url)
+ }
+ }
+ self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_ios_sideload", messageArg: String(tip.prefix(7))))
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
+ }
+ }
+ }
+
+ private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
+ DispatchQueue.main.async { cb(state) }
+ }
+
+ private func atomTip(_ atom: String) -> String? {
+ if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
+ let s = String(atom[m])
+ return s.split(separator: "/").last.map(String.init)
+ }
+ if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ return nil
+ }
+
+ private func atomMainSha(_ atom: String) -> String? {
+ guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
+ let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
+ let range = Range(match.range(at: 1), in: atom)
+ else { return nil }
+ return String(atom[range])
+ }
+
+ private func sameCommit(_ a: String, _ b: String) -> Bool {
+ let n = min(a.count, b.count)
+ return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
+ }
+
+ private func get(_ url: String) throws -> String {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ var req = URLRequest(url: u, timeoutInterval: 15)
+ req.setValue("Graduately-ios", forHTTPHeaderField: "User-Agent")
+ req.setValue("*/*", forHTTPHeaderField: "Accept")
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ URLSession.shared.dataTask(with: req) { data, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let data, let text = String(data: data, encoding: .utf8) {
+ result = .success(text)
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }.resume()
+ _ = sem.wait(timeout: .now() + 20)
+ return try result.get()
+ }
+}
diff --git a/macos/Graduately.xcodeproj/project.pbxproj b/macos/Graduately.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..42b58cc
--- /dev/null
+++ b/macos/Graduately.xcodeproj/project.pbxproj
@@ -0,0 +1,450 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ B101000000000000000001 /* GraduatelyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000001 /* GraduatelyApp.swift */; };
+ B101000000000000000002 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000002 /* Platform.swift */; };
+ B101000000000000000003 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000003 /* Updater.swift */; };
+ B101000000000000000004 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000004 /* AppModel.swift */; };
+ B101000000000000000005 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000005 /* Route.swift */; };
+ B101000000000000000006 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000006 /* JSON.swift */; };
+ B101000000000000000007 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000007 /* Content.swift */; };
+ B101000000000000000008 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000008 /* Theme.swift */; };
+ B101000000000000000009 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000009 /* Answers.swift */; };
+ B101000000000000000010 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000010 /* ProgressStore.swift */; };
+ B101000000000000000011 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000011 /* Components.swift */; };
+ B101000000000000000012 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000012 /* Icons.swift */; };
+ B101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000013 /* Screens.swift */; };
+ B101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000014 /* Exercises.swift */; };
+ B101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000015 /* ExercisesMore.swift */; };
+ B101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B201000000000000000016 /* Assets.xcassets */; };
+ B101000000000000000018 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B201000000000000000020 /* Localizable.strings */; };
+ B101000000000000000017 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000019 /* PathMap.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ B201000000000000000001 /* GraduatelyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraduatelyApp.swift; sourceTree = ""; };
+ B201000000000000000002 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Platform.swift; sourceTree = ""; };
+ B201000000000000000003 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; };
+ B201000000000000000004 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppModel.swift; path = ../ios/Graduately/AppModel.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000005 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Route.swift; path = ../ios/Graduately/Route.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000006 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = JSON.swift; path = ../ios/Graduately/Data/JSON.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000007 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Content.swift; path = ../ios/Graduately/Data/Content.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000008 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Theme.swift; path = ../ios/Graduately/Data/Theme.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000009 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Answers.swift; path = ../ios/Graduately/Data/Answers.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000010 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ProgressStore.swift; path = ../ios/Graduately/Data/ProgressStore.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000011 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Components.swift; path = ../ios/Graduately/UI/Components.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000012 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Icons.swift; path = ../ios/Graduately/UI/Icons.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Screens.swift; path = ../ios/Graduately/UI/Screens.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Exercises.swift; path = ../ios/Graduately/UI/Exercises.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExercisesMore.swift; path = ../ios/Graduately/UI/ExercisesMore.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ B201000000000000000018 /* Graduately.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Graduately.entitlements; sourceTree = ""; };
+ B201000000000000000019 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PathMap.swift; path = ../ios/Graduately/UI/PathMap.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000021 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; };
+ B201000000000000000022 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; };
+ B301000000000000000001 /* Graduately.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduately.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ B401000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ B501000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ B501000000000000000002 /* Graduately */,
+ B501000000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ B501000000000000000002 /* Graduately */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000001 /* GraduatelyApp.swift */,
+ B201000000000000000002 /* Platform.swift */,
+ B201000000000000000004 /* AppModel.swift */,
+ B201000000000000000005 /* Route.swift */,
+ B501000000000000000003 /* Data */,
+ B501000000000000000004 /* Update */,
+ B501000000000000000005 /* UI */,
+ B201000000000000000016 /* Assets.xcassets */,
+ B201000000000000000020 /* Localizable.strings */,
+ B201000000000000000017 /* Info.plist */,
+ B201000000000000000018 /* Graduately.entitlements */,
+ );
+ path = Graduately;
+ sourceTree = "";
+ };
+ B501000000000000000003 /* Data */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000006 /* JSON.swift */,
+ B201000000000000000007 /* Content.swift */,
+ B201000000000000000008 /* Theme.swift */,
+ B201000000000000000009 /* Answers.swift */,
+ B201000000000000000010 /* ProgressStore.swift */,
+ );
+ name = Data;
+ sourceTree = "";
+ };
+ B501000000000000000004 /* Update */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000003 /* Updater.swift */,
+ );
+ path = Update;
+ sourceTree = "";
+ };
+ B501000000000000000005 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000011 /* Components.swift */,
+ B201000000000000000012 /* Icons.swift */,
+ B201000000000000000019 /* PathMap.swift */,
+ B201000000000000000013 /* Screens.swift */,
+ B201000000000000000014 /* Exercises.swift */,
+ B201000000000000000015 /* ExercisesMore.swift */,
+ );
+ name = UI;
+ sourceTree = "";
+ };
+ B501000000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ B301000000000000000001 /* Graduately.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXVariantGroup section */
+ B201000000000000000020 /* Localizable.strings */ = {
+ isa = PBXVariantGroup;
+ children = (
+ B201000000000000000021 /* en */,
+ B201000000000000000022 /* cs */,
+ );
+ name = Localizable.strings;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin PBXNativeTarget section */
+ B601000000000000000001 /* Graduately */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B801000000000000000002 /* Build configuration list for PBXNativeTarget "Graduately" */;
+ buildPhases = (
+ B701000000000000000001 /* Sources */,
+ B401000000000000000001 /* Frameworks */,
+ B701000000000000000002 /* Resources */,
+ B701000000000000000004 /* Build content */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Graduately;
+ productName = Graduately;
+ productReference = B301000000000000000001 /* Graduately.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ B001000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 2700;
+ TargetAttributes = {
+ B601000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ };
+ };
+ buildConfigurationList = B801000000000000000001 /* Build configuration list for PBXProject "Graduately" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ cs,
+ Base,
+ );
+ mainGroup = B501000000000000000001;
+ productRefGroup = B501000000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ B601000000000000000001 /* Graduately */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ B701000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B101000000000000000016 /* Assets.xcassets in Resources */,
+ B101000000000000000018 /* Localizable.strings in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ B701000000000000000004 /* Build content */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Build content";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/content.json",
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/content.json",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "# Build content.json from the Markdown in content/ and drop it into the app\n# bundle. Nothing generated is kept in the repository, so a fresh clone opens\n# and runs in Xcode without extra steps.\nset -eu\nextract=\"${SRCROOT}/../scripts/build-content.py\"\nif [ ! -f \"$extract\" ]; then\n echo \"error: ${extract} not found. Keep macos/ inside the Graduately checkout.\" >&2\n exit 1\nfi\nout=\"${DERIVED_FILE_DIR}/content.json\"\nmkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"$extract\" --out \"$out\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"$out\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content.json\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ B701000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B101000000000000000001 /* GraduatelyApp.swift in Sources */,
+ B101000000000000000002 /* Platform.swift in Sources */,
+ B101000000000000000003 /* Updater.swift in Sources */,
+ B101000000000000000004 /* AppModel.swift in Sources */,
+ B101000000000000000005 /* Route.swift in Sources */,
+ B101000000000000000006 /* JSON.swift in Sources */,
+ B101000000000000000007 /* Content.swift in Sources */,
+ B101000000000000000008 /* Theme.swift in Sources */,
+ B101000000000000000009 /* Answers.swift in Sources */,
+ B101000000000000000010 /* ProgressStore.swift in Sources */,
+ B101000000000000000011 /* Components.swift in Sources */,
+ B101000000000000000012 /* Icons.swift in Sources */,
+ B101000000000000000017 /* PathMap.swift in Sources */,
+ B101000000000000000013 /* Screens.swift in Sources */,
+ B101000000000000000014 /* Exercises.swift in Sources */,
+ B101000000000000000015 /* ExercisesMore.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ B901000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ B901000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ B901000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Graduately/Graduately.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Graduately/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Graduately;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_NSHighResolutionCapable = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "GPL-3.0";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.2;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita.macos;
+ PRODUCT_NAME = Graduately;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ B901000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Graduately/Graduately.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Graduately/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Graduately;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_NSHighResolutionCapable = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "GPL-3.0";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.2;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita.macos;
+ PRODUCT_NAME = Graduately;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ B801000000000000000001 /* Build configuration list for PBXProject "Graduately" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B901000000000000000001 /* Debug */,
+ B901000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B801000000000000000002 /* Build configuration list for PBXNativeTarget "Graduately" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B901000000000000000003 /* Debug */,
+ B901000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = B001000000000000000001 /* Project object */;
+}
diff --git a/macos/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/macos/Graduately.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/macos/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme b/macos/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme
new file mode 100644
index 0000000..58bd4d5
--- /dev/null
+++ b/macos/Graduately.xcodeproj/xcshareddata/xcschemes/Graduately.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/macos/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json b/macos/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/macos/Graduately/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..700d5cf
--- /dev/null
+++ b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
+ { "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
+ { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
+ { "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
+ { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
+ { "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
+ { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
+ { "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
+ { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
+ { "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644
index 0000000..c7f6be9
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644
index 0000000..f01e78a
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644
index 0000000..4c3e221
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644
index 0000000..dff719a
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644
index 0000000..f01e78a
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644
index 0000000..c86d6ea
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644
index 0000000..dff719a
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644
index 0000000..6f0fa6c
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644
index 0000000..c86d6ea
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644
index 0000000..9fbf69f
Binary files /dev/null and b/macos/Graduately/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/macos/Graduately/Assets.xcassets/Contents.json b/macos/Graduately/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/macos/Graduately/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Graduately/Graduately.entitlements b/macos/Graduately/Graduately.entitlements
new file mode 100644
index 0000000..08ba3a3
--- /dev/null
+++ b/macos/Graduately/Graduately.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
diff --git a/macos/Graduately/GraduatelyApp.swift b/macos/Graduately/GraduatelyApp.swift
new file mode 100644
index 0000000..45a44b0
--- /dev/null
+++ b/macos/Graduately/GraduatelyApp.swift
@@ -0,0 +1,170 @@
+import SwiftUI
+
+@main
+struct GraduatelyApp: App {
+ @StateObject private var vm = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ MacRoot(vm: vm)
+ }
+ .defaultSize(width: 1280, height: 840)
+ .windowResizability(.contentMinSize)
+ .windowToolbarStyle(.unified)
+ .windowStyle(.automatic)
+ .commands {
+ CommandGroup(replacing: .newItem) {}
+ CommandGroup(after: .sidebar) {
+ Button(vm.tr("search_home")) {
+ vm.showPracticeRoot()
+ }
+ .keyboardShortcut("1", modifiers: [.command])
+ Button(vm.tr("stats")) {
+ vm.tab = .progress
+ }
+ .keyboardShortcut("2", modifiers: [.command])
+ Button(vm.tr("search")) {
+ vm.tab = .search
+ }
+ .keyboardShortcut("k", modifiers: [.command])
+ Button(vm.tr("settings")) {
+ vm.tab = .settings
+ }
+ .keyboardShortcut("3", modifiers: [.command])
+ }
+ }
+
+ Settings {
+ SettingsScreen(vm: vm)
+ .formStyle(.grouped)
+ .frame(minWidth: 480, minHeight: 520)
+ .tint(vm.palette.tint)
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ }
+ }
+}
+
+struct MacRoot: View {
+ @ObservedObject var vm: AppModel
+ @State private var progressPath = NavigationPath()
+ @State private var settingsPath = NavigationPath()
+ @State private var searchPath = NavigationPath()
+
+ var body: some View {
+ tabs
+ .tint(vm.palette.tint)
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ .overlay(alignment: .top) {
+ if showsUpdateAccessory {
+ UpdateBanner(vm: vm)
+ .padding(16)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(Motion.settle, value: showsUpdateAccessory)
+ .onAppear { vm.checkUpdate(false) }
+ .frame(minWidth: 980, minHeight: 640)
+ }
+
+ private var showsUpdateAccessory: Bool {
+ vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged"
+ }
+
+ private var tabs: some View {
+ TabView(selection: $vm.tab) {
+ Tab(vm.tr("search_home"), systemImage: "square.grid.2x2.fill", value: AppTab.practice) {
+ NavigationStack(path: $vm.practicePath) {
+ PracticeHome(vm: vm)
+ .navigationDestination(for: Route.self) { route in
+ RouteDestination(vm: vm, route: route)
+ }
+ }
+ }
+ Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) {
+ NavigationStack(path: $progressPath) {
+ StatsScreen(vm: vm)
+ }
+ }
+ Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) {
+ NavigationStack(path: $settingsPath) {
+ SettingsScreen(vm: vm)
+ }
+ }
+ Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
+ NavigationStack(path: $searchPath) {
+ SearchScreen(vm: vm)
+ }
+ }
+ }
+ .tabViewStyle(.sidebarAdaptable)
+ .toolbar {
+ MacWindowToolbar(vm: vm)
+ }
+ }
+}
+
+private struct MacWindowToolbar: ToolbarContent {
+ @ObservedObject var vm: AppModel
+
+ var body: some ToolbarContent {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button {
+ vm.tab = .search
+ } label: {
+ Label(vm.tr("search"), systemImage: "magnifyingglass")
+ }
+ .controlSize(.large)
+ .help(vm.tr("search"))
+
+ Button {
+ vm.tab = .progress
+ } label: {
+ Label(vm.tr("stats"), systemImage: "chart.bar.fill")
+ }
+ .controlSize(.large)
+ .help(vm.tr("stats"))
+ }
+
+ ToolbarSpacer(.fixed, placement: .primaryAction)
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ vm.tab = .settings
+ } label: {
+ Label(vm.tr("settings"), systemImage: "gearshape")
+ }
+ .controlSize(.large)
+ .help(vm.tr("settings"))
+ }
+ }
+}
+
+private struct UpdateBanner: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "arrow.down.app.fill")
+ .font(.title3)
+ Text(bannerText)
+ .font(.body)
+ Spacer()
+ if vm.update.canInstall {
+ Button(vm.tr("update_install")) { vm.installUpdate() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .softSurface(cornerRadius: 16)
+ .frame(maxWidth: 640)
+ }
+
+ private var bannerText: String {
+ if let arg = vm.update.messageArg {
+ return vm.fmt(vm.update.messageKey, arg)
+ }
+ return vm.tr(vm.update.messageKey)
+ }
+}
diff --git a/macos/Graduately/Info.plist b/macos/Graduately/Info.plist
new file mode 100644
index 0000000..4787884
--- /dev/null
+++ b/macos/Graduately/Info.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ GITCommit
+ $(COMMIT)
+ UPDATE_BRANCH
+ builds
+ UPDATE_REPO
+ pepaskopec-blip/Graduately
+
+
diff --git a/macos/Graduately/Platform.swift b/macos/Graduately/Platform.swift
new file mode 100644
index 0000000..cc9c1e2
--- /dev/null
+++ b/macos/Graduately/Platform.swift
@@ -0,0 +1,38 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+extension NSColor {
+ static var tertiarySystemFill: NSColor {
+ NSColor.quaternaryLabelColor.withAlphaComponent(0.16)
+ }
+
+ static var secondarySystemGroupedBackground: NSColor {
+ .controlBackgroundColor
+ }
+
+ static var separator: NSColor { .separatorColor }
+}
+
+enum TextInputAutocapitalization {
+ case never
+}
+
+enum UIKeyboardType {
+ case decimalPad
+}
+
+enum MacNavBarTitleMode {
+ case large
+ case inline
+}
+
+extension View {
+ func textInputAutocapitalization(_ style: TextInputAutocapitalization) -> some View { self }
+
+ func keyboardType(_ type: UIKeyboardType) -> some View { self }
+
+ func navigationBarTitleDisplayMode(_ mode: MacNavBarTitleMode) -> some View { self }
+}
+
+#endif
diff --git a/macos/Graduately/Update/Updater.swift b/macos/Graduately/Update/Updater.swift
new file mode 100644
index 0000000..0b081cb
--- /dev/null
+++ b/macos/Graduately/Update/Updater.swift
@@ -0,0 +1,233 @@
+import AppKit
+import Foundation
+
+struct UpdateState {
+ var status = "idle"
+ var remote: String?
+ var messageKey = "update_current"
+ var messageArg: String? = String(AppConfig.commit.prefix(7))
+ var canInstall = false
+}
+
+final class Updater {
+ private let repo = AppConfig.updateRepo
+ private let branch = AppConfig.updateBranch
+
+ func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
+ if AppConfig.commit == "dev" {
+ emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
+ return
+ }
+ emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let remote: String
+ if let tip = self.atomTip(atom),
+ let marker = try? self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION-macos")
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ marker.count >= 7 {
+ remote = marker
+ } else if let sha = self.atomMainSha(atom) {
+ remote = sha
+ } else if let tip = self.atomTip(atom) {
+ remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ throw URLError(.cannotParseResponse)
+ }
+ let local = AppConfig.commit
+ if self.sameCommit(remote, local) {
+ self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
+ } else {
+ self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
+ }
+ }
+ }
+
+ func install(cb: @escaping (UpdateState) -> Void) {
+ emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let tip = self.atomTip(atom) ?? ""
+ guard !tip.isEmpty else { throw URLError(.cannotParseResponse) }
+ let zipName = Self.assetName()
+ let url = "https://raw.githubusercontent.com/\(self.repo)/\(tip)/\(zipName)"
+ let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
+ let staging = cache.appendingPathComponent("graduately-update-\(ProcessInfo.processInfo.processIdentifier)")
+ try? FileManager.default.removeItem(at: staging)
+ try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
+ let archive = staging.appendingPathComponent(zipName)
+ try self.download(url, to: archive)
+ let unpacked = staging.appendingPathComponent("new")
+ try FileManager.default.createDirectory(at: unpacked, withIntermediateDirectories: true)
+ try self.run("/usr/bin/ditto", ["-x", "-k", archive.path, unpacked.path])
+ guard let source = self.findApp(in: unpacked) else {
+ throw URLError(.cannotParseResponse)
+ }
+ guard let target = Self.runningAppURL() else {
+ throw URLError(.cannotOpenFile)
+ }
+ let script = self.writeSwapScript()
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/bin/sh")
+ proc.arguments = [script.path, "\(ProcessInfo.processInfo.processIdentifier)", target.path, source.path, staging.path]
+ try proc.run()
+ self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_staged", messageArg: String(tip.prefix(7))))
+ DispatchQueue.main.async {
+ NSApp.terminate(nil)
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
+ }
+ }
+ }
+
+ private static func assetName() -> String {
+ #if arch(arm64)
+ return "graduately-macos-arm64.zip"
+ #else
+ return "graduately-macos-x86_64.zip"
+ #endif
+ }
+
+ private static func runningAppURL() -> URL? {
+ let bundle = Bundle.main.bundleURL
+ return bundle.pathExtension == "app" ? bundle : nil
+ }
+
+ private func findApp(in root: URL) -> URL? {
+ let keys: [URLResourceKey] = [.isDirectoryKey]
+ guard let en = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { return nil }
+ for case let url as URL in en where url.pathExtension == "app" {
+ return url
+ }
+ return nil
+ }
+
+ private func writeSwapScript() -> URL {
+ let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("graduately-swift-update.sh")
+ let body = """
+ #!/bin/sh
+ if [ -z "$MATURITA_UPDATE_DETACHED" ]; then
+ MATURITA_UPDATE_DETACHED=1
+ export MATURITA_UPDATE_DETACHED
+ nohup /bin/sh "$0" "$@" >/dev/null 2>&1 &
+ exit 0
+ fi
+ pid=$1; target=$2; source=$3; staging=$4
+ n=0
+ while kill -0 "$pid" 2>/dev/null && [ $n -lt 600 ]; do
+ sleep 0.2; n=$((n+1))
+ done
+ place=$target
+ if /usr/bin/ditto "$source" "$target" 2>/dev/null; then
+ xattr -cr "$target" 2>/dev/null
+ else
+ alt="$HOME/Applications/$(basename "$target")"
+ mkdir -p "$HOME/Applications"
+ if /usr/bin/ditto "$source" "$alt" 2>/dev/null; then
+ xattr -cr "$alt" 2>/dev/null
+ place=$alt
+ fi
+ fi
+ open -n "$place"
+ rm -rf "$staging"
+ rm -f "$0"
+ """
+ try? body.write(to: url, atomically: true, encoding: .utf8)
+ try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url
+ }
+
+ private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
+ DispatchQueue.main.async { cb(state) }
+ }
+
+ private func atomTip(_ atom: String) -> String? {
+ if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ return nil
+ }
+
+ private func atomMainSha(_ atom: String) -> String? {
+ guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
+ let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
+ let range = Range(match.range(at: 1), in: atom)
+ else { return nil }
+ return String(atom[range])
+ }
+
+ private func sameCommit(_ a: String, _ b: String) -> Bool {
+ let n = min(a.count, b.count)
+ return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
+ }
+
+ private func get(_ url: String) throws -> String {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ var req = URLRequest(url: u, timeoutInterval: 15)
+ req.setValue("Graduately-macos", forHTTPHeaderField: "User-Agent")
+ req.setValue("*/*", forHTTPHeaderField: "Accept")
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ URLSession.shared.dataTask(with: req) { data, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let data, let text = String(data: data, encoding: .utf8) {
+ result = .success(text)
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }.resume()
+ _ = sem.wait(timeout: .now() + 20)
+ return try result.get()
+ }
+
+ private func download(_ url: String, to dest: URL) throws {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ let task = URLSession.shared.downloadTask(with: u) { file, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let file {
+ do {
+ try? FileManager.default.removeItem(at: dest)
+ try FileManager.default.moveItem(at: file, to: dest)
+ result = .success(())
+ } catch {
+ result = .failure(error)
+ }
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }
+ task.resume()
+ _ = sem.wait(timeout: .now() + 1800)
+ try result.get()
+ }
+
+ private func run(_ launch: String, _ args: [String]) throws {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: launch)
+ proc.arguments = args
+ try proc.run()
+ proc.waitUntilExit()
+ if proc.terminationStatus != 0 {
+ throw URLError(.cannotParseResponse)
+ }
+ }
+}
diff --git a/macos/Graduately/cs.lproj/Localizable.strings b/macos/Graduately/cs.lproj/Localizable.strings
new file mode 100644
index 0000000..a080c4c
--- /dev/null
+++ b/macos/Graduately/cs.lproj/Localizable.strings
@@ -0,0 +1 @@
+/* Označí češtinu jako podporovaný jazyk, aby nabídky macOS zůstaly česky na českém systému. */
diff --git a/macos/Graduately/en.lproj/Localizable.strings b/macos/Graduately/en.lproj/Localizable.strings
new file mode 100644
index 0000000..3c0e8ca
--- /dev/null
+++ b/macos/Graduately/en.lproj/Localizable.strings
@@ -0,0 +1 @@
+/* Marks English as a supported language so macOS menus follow an English system. */
diff --git a/maturita-android.apk b/maturita-android.apk
deleted file mode 100644
index b571264..0000000
Binary files a/maturita-android.apk and /dev/null differ
diff --git a/maturita-installer-android.zip b/maturita-installer-android.zip
deleted file mode 100644
index a38e7d9..0000000
Binary files a/maturita-installer-android.zip and /dev/null differ
diff --git a/maturita-installer-ios.zip b/maturita-installer-ios.zip
deleted file mode 100644
index ec608cd..0000000
Binary files a/maturita-installer-ios.zip and /dev/null differ
diff --git a/maturita-installer-linux.sh b/maturita-installer-linux.sh
deleted file mode 100755
index b31c863..0000000
--- a/maturita-installer-linux.sh
+++ /dev/null
@@ -1,92 +0,0 @@
-#!/bin/sh
-# Graduately installer for Linux.
-#
-# Carries no application of its own. Fastly caches branch-named URLs on
-# raw.githubusercontent.com, so this script looks up the tip of `builds`
-# and downloads that commit — not a stale AppImage from the last five minutes.
-#
-# Double-click the file (or run: sh maturita-installer-linux.sh).
-# The app updates itself from then on.
-
-set -eu
-
-REPO="pepaskopec-blip/Graduately"
-BRANCH="builds"
-ASSET="maturita-linux-x86_64.AppImage"
-
-DEST_DIR="$HOME/Applications"
-DEST="$DEST_DIR/maturita.AppImage"
-DESKTOP_DIR="$HOME/.local/share/applications"
-ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
-
-say() { printf '%s\n' "$*"; }
-die() {
- printf '\nError: %s\n' "$*" >&2
- printf 'Press Enter to close. '
- read -r _ || true
- exit 1
-}
-
-builds_sha() {
- curl -fsSL --max-time 20 \
- "https://github.com/$REPO/commits/$BRANCH.atom" |
- sed -n 's/.*Commit\/\([0-9a-f]\{40\}\).*/\1/p' |
- head -n 1
-}
-
-say "Installing Graduately"
-say "---------------------"
-
-arch=$(uname -m)
-if [ "$arch" != "x86_64" ]; then
- die "only x86_64 builds are published, but this machine reports '$arch'.
-Build from source instead: https://github.com/$REPO"
-fi
-
-command -v curl >/dev/null 2>&1 || die "curl was not found. Install it first."
-
-mkdir -p "$DEST_DIR"
-
-tmp=$(mktemp -d)
-trap 'rm -rf "$tmp"' EXIT INT TERM
-
-say "Looking up the latest build..."
-sha=$(builds_sha)
-[ ${#sha} -eq 40 ] || die "could not find the latest build on GitHub."
-URL="https://raw.githubusercontent.com/$REPO/$sha/$ASSET"
-
-say "Downloading build $(printf '%.7s' "$sha")..."
-curl -fL --progress-bar --max-time 1800 -o "$tmp/$ASSET" "$URL" ||
- die "the download failed. Check your internet connection."
-
-say "Installing to $DEST"
-chmod +x "$tmp/$ASSET"
-mv -f "$tmp/$ASSET" "$DEST" || die "could not write to $DEST_DIR."
-
-if (cd "$tmp" && "$DEST" --appimage-extract \
- 'usr/share/icons/hicolor/512x512/apps/maturita.png' >/dev/null 2>&1); then
- icon="$tmp/squashfs-root/usr/share/icons/hicolor/512x512/apps/maturita.png"
- if [ -f "$icon" ]; then
- mkdir -p "$ICON_DIR"
- cp -f "$icon" "$ICON_DIR/maturita.png"
- fi
-fi
-
-mkdir -p "$DESKTOP_DIR"
-cat > "$DESKTOP_DIR/maturita.desktop" </dev/null 2>&1 &&
- update-desktop-database "$DESKTOP_DIR" >/dev/null 2>&1 || true
-
-say ""
-say "Done. Starting the app..."
-"$DEST" >/dev/null 2>&1 &
diff --git a/maturita-installer-linux.zip b/maturita-installer-linux.zip
deleted file mode 100644
index af483b4..0000000
Binary files a/maturita-installer-linux.zip and /dev/null differ
diff --git a/maturita-installer-macos.zip b/maturita-installer-macos.zip
deleted file mode 100644
index 28c6e79..0000000
Binary files a/maturita-installer-macos.zip and /dev/null differ
diff --git a/maturita-installer-windows.cmd b/maturita-installer-windows.cmd
deleted file mode 100644
index 9a185e5..0000000
--- a/maturita-installer-windows.cmd
+++ /dev/null
@@ -1,82 +0,0 @@
-@echo off
-rem Graduately installer for Windows.
-rem
-rem Carries no application of its own. Fastly caches branch-named URLs on
-rem raw.githubusercontent.com, so this script looks up the tip of `builds`
-rem and downloads that commit — not a stale zip from the last five minutes.
-rem
-rem Double-click the file to run it. The app updates itself from then on.
-
-setlocal EnableDelayedExpansion
-set "REPO=pepaskopec-blip/Graduately"
-set "BRANCH=builds"
-set "ASSET=maturita-windows-x64.zip"
-set "DEST=%LOCALAPPDATA%\Programs\Graduately"
-
-echo Installing Graduately
-echo ---------------------
-
-where curl >nul 2>&1 || goto :no_tools
-where tar >nul 2>&1 || goto :no_tools
-
-set "TMP_DIR=%TEMP%\maturita-installer"
-rmdir /s /q "%TMP_DIR%" 2>nul
-mkdir "%TMP_DIR%" || goto :fail_tmp
-
-echo Looking up the latest build...
-set "SHA="
-for /f "usebackq delims=" %%S in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$r = & curl.exe -fsSL --max-time 20 'https://github.com/%REPO%/commits/%BRANCH%.atom'; if ($r -match 'Commit/([0-9a-f]{40})') { $Matches[1] }"`) do set "SHA=%%S"
-if not defined SHA goto :fail_download
-set "URL=https://raw.githubusercontent.com/%REPO%/%SHA%/%ASSET%"
-
-echo Downloading build %SHA:~0,7%...
-curl -fL --progress-bar --max-time 1800 -o "%TMP_DIR%\%ASSET%" "%URL%" || goto :fail_download
-
-echo Installing to %DEST%
-if not exist "%DEST%" mkdir "%DEST%" || goto :fail_dest
-
-tar -xf "%TMP_DIR%\%ASSET%" -C "%DEST%" || goto :fail_extract
-
-set "LNK=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Graduately.lnk"
-powershell -NoProfile -ExecutionPolicy Bypass -Command ^
- "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%LNK%');" ^
- "$s.TargetPath='%DEST%\maturita.exe';$s.WorkingDirectory='%DEST%';$s.Save()" >nul 2>&1
-
-rmdir /s /q "%TMP_DIR%" 2>nul
-
-echo.
-echo Done. Starting the app...
-start "" "%DEST%\maturita.exe"
-exit /b 0
-
-:no_tools
-echo.
-echo Error: this installer needs curl and tar, which come with Windows 10
-echo version 1803 and later. Please update Windows, or download the zip
-echo manually from https://github.com/%REPO%/tree/%BRANCH%
-pause
-exit /b 1
-
-:fail_tmp
-echo.
-echo Error: could not create a temporary folder in %TEMP%.
-pause
-exit /b 1
-
-:fail_download
-echo.
-echo Error: the download failed. Check your internet connection.
-pause
-exit /b 1
-
-:fail_dest
-echo.
-echo Error: could not create %DEST%.
-pause
-exit /b 1
-
-:fail_extract
-echo.
-echo Error: the archive could not be unpacked.
-pause
-exit /b 1
diff --git a/maturita-installer-windows.zip b/maturita-installer-windows.zip
deleted file mode 100644
index 5ef36d9..0000000
Binary files a/maturita-installer-windows.zip and /dev/null differ
diff --git a/maturita-ios.ipa b/maturita-ios.ipa
deleted file mode 100644
index 573f605..0000000
Binary files a/maturita-ios.ipa and /dev/null differ
diff --git a/maturita-linux-x86_64.AppImage b/maturita-linux-x86_64.AppImage
deleted file mode 100644
index 707d47e..0000000
Binary files a/maturita-linux-x86_64.AppImage and /dev/null differ
diff --git a/maturita-macos-arm64.zip b/maturita-macos-arm64.zip
deleted file mode 100644
index 8244109..0000000
Binary files a/maturita-macos-arm64.zip and /dev/null differ
diff --git a/maturita-windows-x64.zip b/maturita-windows-x64.zip
deleted file mode 100644
index ef50bd1..0000000
Binary files a/maturita-windows-x64.zip and /dev/null differ
diff --git a/scripts/build-content.py b/scripts/build-content.py
new file mode 100644
index 0000000..ca44c03
--- /dev/null
+++ b/scripts/build-content.py
@@ -0,0 +1,1195 @@
+#!/usr/bin/env python3
+"""Build content.json from the Markdown files in content/.
+
+The format is described in content/README.md. Every platform reads the
+generated JSON; nothing under content/ is read at run time.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+import unicodedata
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+def _force_utf8_stdio() -> None:
+ """Windows defaults stdout to cp1252, which cannot print Czech logs."""
+ for stream in (sys.stdout, sys.stderr):
+ reconfigure = getattr(stream, "reconfigure", None)
+ if reconfigure is None:
+ continue
+ try:
+ reconfigure(encoding="utf-8", errors="replace")
+ except (OSError, ValueError):
+ pass
+
+
+_force_utf8_stdio()
+
+ROOT = Path(__file__).resolve().parents[1]
+CONTENT = ROOT / "content"
+OUTS = [ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json"]
+
+
+# ---------------------------------------------------------------------------
+# Errors
+# ---------------------------------------------------------------------------
+
+class ContentError(Exception):
+ pass
+
+
+ERRORS: list[str] = []
+WARNINGS: list[str] = []
+
+
+def rel(path: Path) -> str:
+ try:
+ return str(path.relative_to(ROOT))
+ except ValueError:
+ return str(path)
+
+
+def fail(path: Path, line: int | None, msg: str) -> None:
+ where = f"{rel(path)}:{line}" if line else rel(path)
+ ERRORS.append(f"{where}: {msg}")
+
+
+def warn(path: Path, line: int | None, msg: str) -> None:
+ where = f"{rel(path)}:{line}" if line else rel(path)
+ WARNINGS.append(f"{where}: {msg}")
+
+
+# ---------------------------------------------------------------------------
+# Markdown subset
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Node:
+ kind: str # h1 h2 h3 bullet num quote answer table para
+ text: str
+ line: int
+ checked: bool = False
+ rows: list[list[str]] = field(default_factory=list)
+ lines: list[str] = field(default_factory=list)
+
+
+@dataclass
+class Doc:
+ path: Path
+ meta: dict[str, str]
+ meta_lines: dict[str, int]
+ nodes: list[Node]
+
+
+BULLET = re.compile(r"^- (\[([ xX])\] )?(.*)$")
+NUMBERED = re.compile(r"^(\d+)\. (.*)$")
+TABLE_SEP = re.compile(r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
+
+
+def parse_doc(path: Path) -> Doc:
+ raw = path.read_text(encoding="utf-8").replace("\r\n", "\n")
+ lines = raw.split("\n")
+ meta: dict[str, str] = {}
+ meta_lines: dict[str, int] = {}
+ i = 0
+ if lines and lines[0].strip() == "---":
+ i = 1
+ while i < len(lines) and lines[i].strip() != "---":
+ ln = lines[i]
+ if ln.strip() and not ln.lstrip().startswith("#"):
+ key, sep, value = ln.partition(":")
+ if not sep:
+ fail(path, i + 1, f"v hlavičce chybí dvojtečka: {ln!r}")
+ else:
+ meta[key.strip()] = value.strip()
+ meta_lines[key.strip()] = i + 1
+ i += 1
+ if i >= len(lines):
+ fail(path, 1, "hlavička začíná --- , ale nikde nekončí")
+ i += 1
+
+ nodes: list[Node] = []
+ para: Node | None = None
+ table: Node | None = None
+ in_comment = False
+ indexes = iter(range(i, len(lines)))
+ for n in indexes:
+ ln = lines[n].rstrip()
+ no = n + 1
+ if ln.startswith("```"):
+ para = table = None
+ body: list[str] = []
+ for n2 in indexes:
+ raw_line = lines[n2]
+ if raw_line.rstrip().startswith("```"):
+ break
+ body.append(raw_line)
+ nodes.append(Node("code", "\n".join(body).strip("\n"), no))
+ continue
+ if in_comment:
+ if "-->" in ln:
+ in_comment = False
+ continue
+ if ln.lstrip().startswith("" not in ln:
+ in_comment = True
+ para = table = None
+ continue
+ if not ln.strip():
+ para = table = None
+ continue
+ if ln.startswith("|"):
+ para = None
+ if table is None:
+ table = Node("table", "", no)
+ nodes.append(table)
+ if not TABLE_SEP.match(ln):
+ table.rows.append(split_row(ln))
+ continue
+ table = None
+ if ln.startswith("\\"):
+ text = ln[1:]
+ if para is None:
+ para = Node("para", "", no)
+ nodes.append(para)
+ para.lines.append(text)
+ continue
+ m = re.match(r"^(#{1,3}) (.*)$", ln) or re.match(r"^(#{1,3})$", ln)
+ if m:
+ para = None
+ nodes.append(Node(f"h{len(m.group(1))}", (m.group(2) if m.lastindex and m.lastindex > 1 else "").strip(), no))
+ continue
+ m = BULLET.match(ln)
+ if m:
+ para = None
+ nodes.append(Node("bullet", m.group(3), no, checked=bool(m.group(2)) and m.group(2).lower() == "x"))
+ continue
+ m = NUMBERED.match(ln)
+ if m:
+ para = None
+ nodes.append(Node("num", m.group(2), no))
+ continue
+ if ln.startswith("> ") or ln == ">":
+ para = None
+ nodes.append(Node("quote", ln[2:], no))
+ continue
+ if ln.startswith("= ") or ln == "=":
+ para = None
+ nodes.append(Node("answer", ln[2:], no))
+ continue
+ if para is None:
+ para = Node("para", "", no)
+ nodes.append(para)
+ para.lines.append(ln)
+ return Doc(path, meta, meta_lines, nodes)
+
+
+def split_row(line: str) -> list[str]:
+ s = line.strip()
+ if s.startswith("|"):
+ s = s[1:]
+ if s.endswith("|") and not s.endswith("\\|"):
+ s = s[:-1]
+ cells: list[str] = []
+ cur: list[str] = []
+ i = 0
+ while i < len(s):
+ c = s[i]
+ if c == "\\" and i + 1 < len(s):
+ cur.append(s[i:i + 2])
+ i += 2
+ continue
+ if c == "|":
+ cells.append("".join(cur).strip())
+ cur = []
+ else:
+ cur.append(c)
+ i += 1
+ cells.append("".join(cur).strip())
+ return cells
+
+
+def unesc(s: str) -> str:
+ out = []
+ i = 0
+ while i < len(s):
+ if s[i] == "\\" and i + 1 < len(s):
+ out.append("\n" if s[i + 1] == "n" else s[i + 1])
+ i += 2
+ continue
+ out.append(s[i])
+ i += 1
+ return "".join(out)
+
+
+def ungap(s: str) -> tuple[list[str], list[str]]:
+ """Split 'a [x] b [y] c' into segments ['a ', ' b ', ' c'] and answers ['x', 'y']."""
+ segs: list[str] = []
+ answers: list[str] = []
+ cur: list[str] = []
+ in_gap = False
+ i = 0
+ while i < len(s):
+ c = s[i]
+ if c == "\\" and i + 1 < len(s):
+ cur.append("\n" if s[i + 1] == "n" else s[i + 1])
+ i += 2
+ continue
+ if c == "[" and not in_gap:
+ segs.append("".join(cur))
+ cur = []
+ in_gap = True
+ elif c == "]" and in_gap:
+ answers.append("".join(cur))
+ cur = []
+ in_gap = False
+ else:
+ cur.append(c)
+ i += 1
+ if in_gap:
+ raise ContentError(f"neuzavřená mezera [ ... ] v {s!r}")
+ segs.append("".join(cur))
+ return segs, answers
+
+
+def norm(s: str) -> str:
+ s = unicodedata.normalize("NFKD", s.replace("ß", "ss"))
+ s = "".join(c for c in s if not unicodedata.combining(c))
+ return re.sub(r"\s+", " ", s).strip().lower()
+
+
+def slugify(s: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", norm(s)).strip("-")
+
+
+# ---------------------------------------------------------------------------
+# Structure helpers
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Part:
+ title: str
+ line: int
+ nodes: list[Node]
+
+ def items(self) -> list["Part"]:
+ """Split on ## headings. Nodes before the first ## are dropped."""
+ out: list[Part] = []
+ for nd in self.nodes:
+ if nd.kind == "h2":
+ out.append(Part(nd.text, nd.line, []))
+ elif out:
+ out[-1].nodes.append(nd)
+ return out
+
+ def head(self) -> list[Node]:
+ out = []
+ for nd in self.nodes:
+ if nd.kind == "h2":
+ break
+ out.append(nd)
+ return out
+
+ def subparts(self) -> list["Part"]:
+ out: list[Part] = []
+ for nd in self.nodes:
+ if nd.kind == "h3":
+ out.append(Part(nd.text, nd.line, []))
+ elif out:
+ out[-1].nodes.append(nd)
+ return out
+
+
+def parts(doc: Doc) -> list[Part]:
+ out: list[Part] = []
+ for nd in doc.nodes:
+ if nd.kind == "h1":
+ out.append(Part(nd.text, nd.line, []))
+ elif out:
+ out[-1].nodes.append(nd)
+ elif nd.kind != "h1":
+ fail(doc.path, nd.line, "text před první sekcí (# Název) se nepoužije")
+ return out
+
+
+def part(doc: Doc, *names: str) -> Part | None:
+ wanted = {norm(n) for n in names}
+ for p in parts(doc):
+ if norm(p.title) in wanted:
+ return p
+ return None
+
+
+def text_of(nodes: list[Node]) -> str | None:
+ paras = ["\n".join(nd.lines) for nd in nodes if nd.kind == "para"]
+ return "\n\n".join(paras) if paras else None
+
+
+def bullets(nodes: list[Node]) -> list[str]:
+ return [nd.text for nd in nodes if nd.kind == "bullet"]
+
+
+def numbered(nodes: list[Node]) -> list[str]:
+ return [nd.text for nd in nodes if nd.kind == "num"]
+
+
+def quote(nodes: list[Node]) -> str | None:
+ q = [nd.text for nd in nodes if nd.kind == "quote"]
+ return "\n".join(q) if q else None
+
+
+def answer(nodes: list[Node]) -> str | None:
+ a = [nd.text for nd in nodes if nd.kind == "answer"]
+ return "\n".join(a) if a else None
+
+
+def table(doc: Doc, nodes: list[Node], cols: int, where: str) -> list[list[str]]:
+ tables = [nd for nd in nodes if nd.kind == "table"]
+ if not tables:
+ return []
+ t = tables[0]
+ rows = t.rows[1:]
+ for k, r in enumerate(rows):
+ if len(r) != cols:
+ fail(doc.path, t.line + k + 2,
+ f"{where}: řádek tabulky má {len(r)} sloupců, čekám {cols} "
+ "(svislítko uvnitř textu se píše jako \\|)")
+ return [r + [""] * (cols - len(r)) for r in rows]
+
+
+def flag(doc: Doc, key: str) -> bool | None:
+ if key not in doc.meta:
+ return None
+ v = norm(doc.meta[key])
+ if v in ("ano", "true", "yes", "1"):
+ return True
+ if v in ("ne", "false", "no", "0"):
+ return False
+ fail(doc.path, doc.meta_lines.get(key), f"{key}: čekám ano nebo ne, ne {doc.meta[key]!r}")
+ return None
+
+
+def need(doc: Doc, key: str) -> str:
+ v = doc.meta.get(key)
+ if v is None:
+ fail(doc.path, None, f"v hlavičce chybí {key}:")
+ return ""
+ return v
+
+
+def opt_int(s: str) -> int | None:
+ s = s.strip()
+ return int(s) if re.fullmatch(r"-?\d+", s) else None
+
+
+# ---------------------------------------------------------------------------
+# Shared readers
+# ---------------------------------------------------------------------------
+
+def read_quiz(doc: Doc, p: Part | None, expl_key: str | None = "expl") -> list[dict]:
+ if p is None:
+ return []
+ out = []
+ for it in p.items():
+ opts = [nd for nd in it.nodes if nd.kind == "bullet"]
+ if len(opts) < 2:
+ fail(doc.path, it.line, f"otázka {it.title!r} má méně než dvě možnosti")
+ right = [k for k, nd in enumerate(opts) if nd.checked]
+ if len(right) != 1:
+ fail(doc.path, it.line, f"otázka {it.title!r} musí mít právě jednu správnou možnost (- [x] …)")
+ q = {"prompt": it.title, "options": [nd.text for nd in opts],
+ "correct": right[0] if right else 0}
+ ex = quote(it.nodes)
+ if ex is not None and expl_key:
+ q[expl_key] = ex
+ out.append(q)
+ return out
+
+
+def read_slides(doc: Doc, p: Part | None) -> list[dict]:
+ if p is None:
+ return []
+ items = p.items()
+ out = []
+ for k, it in enumerate(items):
+ label = None
+ for nd in it.nodes:
+ if nd.kind == "para":
+ m = re.fullmatch(r"\*(.+)\*", nd.lines[0])
+ if m:
+ label = m.group(1)
+ break
+ if label is None:
+ fail(doc.path, it.line, f"snímek {it.title!r}: pod nadpisem chybí téma (*Téma*)")
+ label = ""
+ out.append({
+ "kicker": f"{k + 1} / {len(items)} • {label}",
+ "title": it.title,
+ "tip": quote(it.nodes),
+ "lines": bullets(it.nodes),
+ })
+ return out
+
+
+def read_typed(doc: Doc, p: Part | None, meaning_key: str = "meaning") -> list[dict]:
+ if p is None:
+ return []
+ out = []
+ for it in p.items():
+ a = answer(it.nodes)
+ if a is None:
+ fail(doc.path, it.line, f"úloha {it.title!r} nemá odpověď (= …)")
+ out.append({"prompt": it.title, "answers": a or "", meaning_key: quote(it.nodes)})
+ return out
+
+
+# ---------------------------------------------------------------------------
+# i18n
+# ---------------------------------------------------------------------------
+
+class I18n:
+ def __init__(self) -> None:
+ self.map: dict[str, dict] = {}
+ self.origin: dict[str, str] = {}
+
+ def put(self, key: str, cs: str | None, en: str | None, src: Path) -> str:
+ if key in self.map and self.origin[key] != rel(src):
+ prev = self.map[key]
+ if (prev["cs"], prev["en"]) != (cs, en):
+ warn(src, None, f"klíč {key!r} už definuje {self.origin[key]}")
+ self.map[key] = {"cs": cs, "en": en}
+ self.origin[key] = rel(src)
+ return key
+
+ def text(self, cs: str, en: str | None, src: Path) -> None:
+ """Translation of a content string: the Czech text itself is the key."""
+ if cs in self.map:
+ prev = self.map[cs]
+ if prev["en"] and en and prev["en"] != en:
+ warn(src, None, f"text {cs[:40]!r} má jiný překlad v {self.origin[cs]}")
+ if prev["en"] and not en:
+ return
+ self.map[cs] = {"cs": cs, "en": en}
+ self.origin[cs] = rel(src)
+
+
+I18N = I18n()
+
+
+def en_doc(path: Path) -> Doc | None:
+ p = path.with_name(path.stem + ".en.md")
+ return parse_doc(p) if p.exists() else None
+
+
+def load_translations(doc: Doc | None) -> None:
+ if doc is None:
+ return
+ p = part(doc, "Překlady", "Translations")
+ if p is None:
+ return
+ for r in table(doc, p.nodes, 2, "Překlady"):
+ cs, en = unesc(r[0]), unesc(r[1])
+ if cs:
+ I18N.text(cs, en or None, doc.path)
+
+
+def keyed(key: str, doc: Doc, en: Doc | None, field_name: str, required: bool = True) -> str | None:
+ cs = doc.meta.get(field_name)
+ if cs is None:
+ if required:
+ fail(doc.path, None, f"v hlavičce chybí {field_name}:")
+ return None
+ e = en.meta.get(field_name) if en else None
+ return I18N.put(key, cs or None, e or None, doc.path)
+
+
+# ---------------------------------------------------------------------------
+# Progress keys
+# ---------------------------------------------------------------------------
+
+PROGRESS: dict[str, str] = {}
+LEGACY: dict[str, str] = {}
+IDS: dict[str, Path] = {}
+
+
+def lesson_id(doc: Doc, address: str) -> str:
+ lid = need(doc, "id")
+ if lid in IDS:
+ fail(doc.path, doc.meta_lines.get("id"), f"id {lid!r} už má {rel(IDS[lid])}")
+ IDS[lid] = doc.path
+ PROGRESS[address] = lid
+ old = doc.meta.get("puvodni")
+ if old:
+ for o in old.split(","):
+ LEGACY[o.strip()] = lid
+ return lid
+
+
+# ---------------------------------------------------------------------------
+# Lesson kinds
+# ---------------------------------------------------------------------------
+
+def by_order(p: Path) -> tuple[int, str]:
+ m = re.match(r"^(\d+)-", p.name)
+ return (int(m.group(1)) if m else 1 << 30, p.name)
+
+
+def lesson_files(directory: Path) -> list[Path]:
+ if not directory.is_dir():
+ fail(directory, None, "složka chybí")
+ return []
+ files = sorted((p for p in directory.glob("*.md")
+ if not p.name.endswith(".en.md") and not p.name.startswith("_")), key=by_order)
+ for p in files:
+ if not re.match(r"^\d+-", p.name):
+ fail(p, None, "název souboru musí začínat pořadím, např. 03-nazev.md")
+ return files
+
+
+def code_of(p: Part | None) -> str:
+ if p is None:
+ return ""
+ for nd in p.nodes:
+ if nd.kind == "code":
+ return nd.text
+ return ""
+
+
+def code_lessons(directory: Path, prefix: str) -> list[dict]:
+ """Lessons with an explanation and a C# program the student can edit and run."""
+ out = []
+ for i, path in enumerate(lesson_files(directory), start=1):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ lid = lesson_id(doc, f"{prefix}.{i}")
+ code = code_of(part(doc, "Kód", "Code"))
+ if not code.strip():
+ fail(path, None, "lekce nemá ukázkový kód v sekci # Kód")
+ out.append({
+ "id": i,
+ "key": lid,
+ "titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
+ "subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
+ "slides": read_slides(doc, part(doc, "Výklad")),
+ "code": code,
+ })
+ return out
+
+
+def std_lessons(directory: Path, prefix: str) -> list[dict]:
+ """Lessons with slides and a choice quiz (IT, civics, literature, sciences, physics)."""
+ out = []
+ for i, path in enumerate(lesson_files(directory), start=1):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ lid = lesson_id(doc, f"{prefix}.{i}")
+ ex_key = "net_ex_title" if prefix == "net" and i == 1 else f"{prefix}_ex{i}_title"
+ lesson = {
+ "id": i,
+ "key": lid,
+ "titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
+ "subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
+ "exTitleKey": keyed(ex_key, doc, en, "nazev_cviceni"),
+ "quizHeadKey": keyed(f"{prefix}_quiz{i}_head", doc, en, "nadpis_kvizu", required=False),
+ "slides": read_slides(doc, part(doc, "Výklad")),
+ "quiz": read_quiz(doc, part(doc, "Kvíz")),
+ }
+ out.append(lesson)
+ if prefix == "net" and i == 1:
+ NET_TASKS.extend(read_net_tasks(doc, part(doc, "Úlohy na podsítě")))
+ return out
+
+
+NET_TASKS: list[tuple[dict, list[dict]]] = []
+
+
+def read_net_tasks(doc: Doc, p: Part | None) -> list[tuple[dict, list[dict]]]:
+ if p is None:
+ return []
+ out = []
+ for it in p.items():
+ head = []
+ for nd in it.nodes:
+ if nd.kind == "h3":
+ break
+ head.append(nd)
+ sub = {norm(s.title): s for s in it.subparts()}
+ sol = sub.get(norm("Řešení"))
+ chk = sub.get(norm("Kontrola"))
+ rows = []
+ for r in table(doc, chk.nodes if chk else [], 5, "Kontrola"):
+ pfx = opt_int(r[0])
+ rows.append({"pfx": pfx if pfx is not None else -1, "net": r[1] or None,
+ "bcast": r[2] or None, "lo": r[3] or None, "hi": r[4] or None})
+ out.append(({"prompt": text_of(head) or "",
+ "solution": text_of(sol.nodes) if sol else ""}, rows))
+ return out
+
+
+def math_lessons(directory: Path, prefix: str) -> list[dict]:
+ out = []
+ for i, path in enumerate(lesson_files(directory), start=1):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ lid = lesson_id(doc, f"{prefix}.{i}")
+ problems = []
+ for it in (part(doc, "Příklady").items() if part(doc, "Příklady") else []):
+ a = answer(it.nodes)
+ if a is None:
+ fail(doc.path, it.line, f"příklad {it.title!r} nemá výsledek (= …)")
+ problems.append({"prompt": it.title, "answer": a or "", "hint": quote(it.nodes) or ""})
+ out.append({
+ "id": i,
+ "key": lid,
+ "titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
+ "subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
+ "exTitleKey": keyed(f"{prefix}_ex{i}_title", doc, en, "nazev_cviceni"),
+ "quizHeadKey": keyed(f"{prefix}_quiz{i}_head", doc, en, "nadpis_kvizu"),
+ "slides": read_slides(doc, part(doc, "Výklad")),
+ "problems": problems,
+ })
+ return out
+
+
+def skill_lessons(directory: Path, tag: str, year: int) -> list[dict]:
+ """English and German years: slides, reading, listening, gaps, writing, quiz."""
+ out = []
+ for i, path in enumerate(lesson_files(directory), start=1):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ lid = lesson_id(doc, f"{tag}.{year}.{i}")
+ read = part(doc, "Čtení")
+ listen = part(doc, "Poslech")
+ write = part(doc, "Psaní")
+ writing = None
+ if write is not None:
+ sub = {norm(s.title): s for s in write.items()}
+ model = sub.get(norm("Vzorová odpověď"))
+ keys = sub.get(norm("Musí obsahovat"))
+ words = sub.get(norm("Minimum slov"))
+ n = opt_int(text_of(words.nodes) or "") if words else None
+ writing = {
+ "prompt": text_of(write.head()) or "",
+ "model": text_of(model.nodes) if model else "",
+ "keys": "|".join(bullets(keys.nodes)) if keys else "",
+ "minWords": n if n is not None else 20,
+ }
+ out.append({
+ "id": i,
+ "key": lid,
+ "titleKey": keyed(f"{tag}_y{year}_u{i}", doc, en, "nazev"),
+ "subKey": keyed(f"{tag}_y{year}_u{i}_sub", doc, en, "popis"),
+ "slides": read_slides(doc, part(doc, "Výklad")),
+ "reading": text_of(read.head()) if read else None,
+ "readQuiz": read_quiz(doc, read),
+ "listening": text_of(listen.head()) if listen else None,
+ "listenQuiz": read_quiz(doc, listen),
+ "gaps": read_typed(doc, part(doc, "Doplňování")),
+ "writing": writing,
+ "quiz": read_quiz(doc, part(doc, "Kvíz")),
+ })
+ return out
+
+
+MLUV_TYPES = {"doplnovani": "typed", "vyber": "choice", "reseni": "reveal"}
+
+
+def mluvnice(directory: Path) -> list[dict]:
+ out = []
+ for n, path in enumerate(lesson_files(directory), start=1):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ lid = lesson_id(doc, f"mluv.{n}")
+ kind = MLUV_TYPES.get(norm(need(doc, "typ")))
+ if kind is None:
+ fail(path, doc.meta_lines.get("typ"), f"typ musí být jeden z: {', '.join(MLUV_TYPES)}")
+ kind = "typed"
+ item = {"id": n, "key": lid, "name": need(doc, "zkratka"), "type": kind,
+ "title": need(doc, "nazev"), "sub": need(doc, "popis"),
+ "note": doc.meta.get("poznamka") or None}
+ p = part(doc, "Úlohy")
+ its = p.items() if p else []
+ if kind == "typed":
+ rows = []
+ for it in its:
+ hints, answers = [], []
+ for b in bullets(it.nodes):
+ h, sep, a = b.partition(" = ")
+ if not sep:
+ fail(path, it.line, f"mezera {b!r} musí mít tvar „nápověda = odpověď“")
+ hints.append(h)
+ answers.append(a)
+ rows.append({"prompt": it.title, "shown": quote(it.nodes) or "",
+ "hints": hints, "answers": answers})
+ item["items"] = rows
+ elif kind == "reveal":
+ item["items"] = [{"prompt": it.title, "solution": answer(it.nodes) or ""} for it in its]
+ else:
+ qs = read_quiz(doc, p, expl_key=None)
+ item["questions"] = qs
+ expls = [quote(it.nodes) for it in its]
+ if any(e is not None for e in expls):
+ item["expls"] = [e or "" for e in expls]
+ out.append(item)
+ return out
+
+
+BOOK_ICONS = ["book", "globe", "people", "bulb"]
+
+
+def books(directory: Path) -> list[dict]:
+ out = []
+ for path in lesson_files(directory):
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ bid = need(doc, "id")
+ if bid in IDS:
+ fail(path, doc.meta_lines.get("id"), f"id {bid!r} už má {rel(IDS[bid])}")
+ IDS[bid] = path
+ PROGRESS[f"book.{bid}.quiz"] = f"{bid}.kviz"
+ PROGRESS[f"book.{bid}.plot"] = f"{bid}.dej"
+ LEGACY[f"book.{bid}.quiz"] = f"{bid}.kviz"
+ LEGACY[f"book.{bid}.plot"] = f"{bid}.dej"
+ author, title = need(doc, "autor"), need(doc, "nazev")
+ notes, quiz, plot, plot_meaning = [], [], [], []
+ for p in parts(doc):
+ t = norm(p.title)
+ if t == norm("Kvíz"):
+ quiz = read_quiz(doc, p)
+ elif t == norm("Děj"):
+ for it in p.items():
+ plot.append({"prompt": it.title, "words": numbered(it.nodes)})
+ m = quote(it.nodes)
+ if m is not None:
+ plot_meaning.append(m)
+ else:
+ icon = BOOK_ICONS[len(notes)] if len(notes) < len(BOOK_ICONS) else "book"
+ notes.append({"title": p.title, "icon": icon, "lines": bullets(p.nodes)})
+
+ def label(field_name: str, default: str) -> str:
+ if field_name in doc.meta:
+ return keyed(f"book_{slugify(bid)}_{field_name}", doc, en, field_name) or default
+ return default
+
+ out.append({
+ "id": bid,
+ "title": f"{author} – {title}",
+ "subKey": label("popis", "cetba1984_sub" if quiz else "cetba_entry_sub"),
+ "page": doc.meta.get("stranka") or f"cetba_{bid.replace('-', '_')}",
+ "genre": need(doc, "zanr"),
+ "quizTitle": label("kviz_nadpis", "book_quiz_heading") if quiz else "",
+ "quizSub": label("kviz_popis", "lit_quiz_sub") if quiz else "",
+ "plotTitle": label("dej_nadpis", "lit_plot_title") if plot else "",
+ "plotSub": label("dej_popis", "lit_plot_sub") if plot else "",
+ "notes": notes,
+ "quiz": quiz,
+ "plot": plot,
+ "plotMeaning": plot_meaning,
+ })
+ return out
+
+
+# ---------------------------------------------------------------------------
+# German textbook
+# ---------------------------------------------------------------------------
+
+def gap_rows(doc: Doc, rows: list[list[str]], col: int, where: str) -> list[tuple[str, str, str]]:
+ out = []
+ for r in rows:
+ try:
+ segs, ans = ungap(r[col])
+ except ContentError as e:
+ fail(doc.path, None, f"{where}: {e}")
+ segs, ans = [r[col]], [""]
+ if len(ans) != 1:
+ fail(doc.path, None, f"{where}: věta {r[col]!r} musí mít právě jednu mezeru [ … ]")
+ ans = (ans + [""])[:1]
+ segs = (segs + ["", ""])[:2]
+ out.append((segs[0], ans[0], segs[1]))
+ return out
+
+
+def seg_rows(doc: Doc, text: str, pad: int, where: str) -> tuple[list[str], list[str]]:
+ try:
+ segs, ans = ungap(text)
+ except ContentError as e:
+ fail(doc.path, None, f"{where}: {e}")
+ return [text], []
+ while len(segs) < pad:
+ segs.append("")
+ return segs, ans
+
+
+def blist(doc: Doc, name: str) -> list[str]:
+ p = part(doc, name)
+ return bullets(p.nodes) if p else []
+
+
+def rows_of(doc: Doc, name: str, cols: int) -> list[list[str]]:
+ p = part(doc, name)
+ return table(doc, p.nodes, cols, name) if p else []
+
+
+def nullable(s: str) -> str | None:
+ return unesc(s) if s != "" else None
+
+
+def german_exercise(doc: Doc, kind: str) -> dict:
+ ex: dict = {"type": kind}
+ pool = lambda: blist(doc, "Nabídka")
+ meanings = lambda: blist(doc, "Překlad")
+ if kind == "dialog":
+ dialogues = []
+ p = part(doc, "Rozhovory")
+ for it in (p.items() if p else []):
+ rows = []
+ for r in table(doc, it.nodes, 2, it.title):
+ (b, a, af), = gap_rows(doc, [r], 1, it.title)
+ rows.append({"speaker": unesc(r[0]), "before": b, "answer": a, "after": af})
+ dialogues.append({"name": it.title, "rows": rows})
+ ex.update(pool=pool(), rows=[], meanings=meanings(), dialogues=dialogues)
+ elif kind == "assembly":
+ items = [{"prompt": None, "words": b.split(" / ")} for b in blist(doc, "Věty")]
+ items += [{"prompt": nullable(r[0]), "words": unesc(r[1]).split(" / ")}
+ for r in rows_of(doc, "Úlohy", 2)]
+ ex.update(items=items, meanings=meanings())
+ elif kind == "choice":
+ ex.update(questions=read_quiz(doc, part(doc, "Otázky"), expl_key=None), meanings=meanings())
+ elif kind == "free":
+ ex["questions"] = [{"question": unesc(r[0]), "qCs": unesc(r[1]), "sample": unesc(r[2]),
+ "aCs": unesc(r[3])} for r in rows_of(doc, "Otázky", 4)]
+ elif kind == "number":
+ ex.update(pool=pool(), rows=[{"digits": unesc(r[0]), "answer": unesc(r[1])}
+ for r in rows_of(doc, "Úlohy", 2)], meanings=meanings())
+ elif kind == "count":
+ ex.update(pool=pool(), rows=[{"emoji": unesc(r[0]), "count": opt_int(r[1]) or 0,
+ "noun": unesc(r[2]), "answer": unesc(r[3])}
+ for r in rows_of(doc, "Úlohy", 4)], meanings=meanings())
+ elif kind in ("seq", "verb"):
+ rows = [{"before": b, "after": af, "answer": a}
+ for b, a, af in gap_rows(doc, rows_of(doc, "Úlohy", 1), 0, "Úlohy")]
+ ex.update(pool=pool(), rows=rows, meanings=meanings())
+ if part(doc, "Glosář") is not None:
+ ex["gloss"] = blist(doc, "Glosář")
+ elif kind == "verb_sections":
+ sections = []
+ p = part(doc, "Oddíly")
+ for it in (p.items() if p else []):
+ rows = [{"before": b, "after": af, "answer": a}
+ for b, a, af in gap_rows(doc, table(doc, it.nodes, 1, it.title), 0, it.title)]
+ sections.append({"title": it.title, "rows": rows, "meanings": bullets(it.nodes)})
+ ex.update(pool=pool(), rows=[], meanings=[], sections=sections)
+ elif kind == "assign":
+ groups = blist(doc, "Skupiny")
+ items = []
+ for r in rows_of(doc, "Slova", 3):
+ g = unesc(r[2])
+ if g not in groups:
+ fail(doc.path, None, f"skupina {g!r} není v seznamu # Skupiny")
+ items.append({"emoji": nullable(r[0]), "label": unesc(r[1]),
+ "group": groups.index(g) if g in groups else 0})
+ ex.update(items=items, groups=groups, meanings=meanings())
+ elif kind == "verbclue":
+ rs = rows_of(doc, "Úlohy", 2)
+ rows = [{"emoji": unesc(r[0]), "before": b, "after": af, "answer": a}
+ for r, (b, a, af) in zip(rs, gap_rows(doc, rs, 1, "Úlohy"))]
+ ex.update(pool=pool(), rows=rows, meanings=meanings())
+ elif kind == "typed":
+ ex["rows"] = [{"prompt": unesc(r[0]), "answers": unesc(r[1]), "meaning": nullable(r[2])}
+ for r in rows_of(doc, "Úlohy", 3)]
+ p = part(doc, "Nabídka")
+ if p is not None:
+ ex["bank"] = text_of(p.nodes)
+ elif kind == "profile":
+ ex["questions"] = [{"stem": unesc(r[0]), "sample": unesc(r[1])}
+ for r in rows_of(doc, "Otázky", 2)]
+ elif kind == "hangman":
+ rs = rows_of(doc, "Slova", 2)
+ letters = text_of(part(doc, "Písmena").nodes) if part(doc, "Písmena") else ""
+ ex.update(words=[unesc(r[0]) for r in rs], tips=[unesc(r[1]) for r in rs],
+ letters=(letters or "").split())
+ elif kind == "ordne":
+ ex.update(pool=[], rows=[{"mid": unesc(r[1]), "fw": unesc(r[0]), "ans": unesc(r[2])}
+ for r in rows_of(doc, "Úlohy", 3)],
+ meanings=meanings(), fwPool=blist(doc, "Tázací slova"),
+ ansPool=blist(doc, "Odpovědi"))
+ elif kind in ("fill", "letters"):
+ pad = 4 if kind == "fill" else 5
+ rows = []
+ for r in rows_of(doc, "Úlohy", 3):
+ segs, ans = seg_rows(doc, r[1], pad, "Úlohy")
+ rows.append({"num": nullable(r[0]), "segs": segs, "answers": ans, "mean": nullable(r[2])})
+ ex["rows"] = rows
+ if kind == "fill":
+ ex.update(pool=pool(), meanings=meanings())
+ elif kind == "ex2":
+ items = []
+ p = part(doc, "Úlohy")
+ for it in (p.items() if p else []):
+ rows = []
+ for r in table(doc, it.nodes, 2, it.title):
+ segs, ans = seg_rows(doc, r[1], 0, it.title)
+ rows.append({"num": nullable(r[0]), "segs": segs, "answers": ans})
+ items.append({"czech": it.title, "rows": rows})
+ ex.update(pool=pool(), items=items)
+ elif kind == "letters_gap":
+ rows = []
+ p = part(doc, "Věty")
+ for it in (p.items() if p else []):
+ segs, ans = seg_rows(doc, it.title, 0, "Věty")
+ words, gmean = [], []
+ for b in bullets(it.nodes):
+ w, _, m = b.partition(" = ")
+ words.append(w)
+ gmean.append(m)
+ rows.append({"segs": segs, "answers": ans, "czech": quote(it.nodes),
+ "words": words, "gmean": gmean})
+ ex["rows"] = rows
+ elif kind == "table":
+ p = part(doc, "Tabulka")
+ t = [nd for nd in (p.nodes if p else []) if nd.kind == "table"]
+ rows = t[0].rows if t else [[""]]
+ ex.update(persons=[unesc(c) for c in rows[0][1:]],
+ nouns=[unesc(r[0]) for r in rows[1:]],
+ answers=[[unesc(c) for c in r[1:]] for r in rows[1:]])
+ elif kind == "kw":
+ ex["rows"] = [{"prompt": unesc(r[0]), "answers": unesc(r[1]), "mean": nullable(r[2]),
+ "german": nullable(r[3])} for r in rows_of(doc, "Úlohy", 4)]
+ elif kind == "vocab":
+ sections = []
+ p = part(doc, "Slovíčka")
+ for it in (p.items() if p else []):
+ sections.append({"header": it.title, "rows": [
+ {"prompt": unesc(r[0]), "answers": unesc(r[1]), "meaning": nullable(r[2])}
+ for r in table(doc, it.nodes, 3, it.title)]})
+ ex["sections"] = sections
+ else:
+ fail(doc.path, doc.meta_lines.get("typ"), f"neznámý typ cvičení {kind!r}")
+ for fm, js in (("ukazka", "sample"),):
+ if fm in doc.meta:
+ ex[js] = doc.meta[fm]
+ for fm, js in (("vyznam_predem", "preMeaning"), ("preklad_predem", "transUpfront"),
+ ("odhalit_nemcinu", "revealGerman"), ("ukazat_nemcinu", "showGerman"),
+ ("poznamka_k_odpovedim", "answersNote")):
+ v = flag(doc, fm)
+ if v is not None:
+ ex[js] = v
+ return ex
+
+
+def german_textbook(directory: Path) -> list[dict]:
+ units = []
+ unit_dirs = sorted((p for p in directory.iterdir() if p.is_dir()), key=by_order) \
+ if directory.is_dir() else []
+ for u, udir in enumerate(unit_dirs):
+ info_path = udir / "_lekce.md"
+ if not info_path.exists():
+ fail(udir, None, "chybí _lekce.md")
+ continue
+ info, info_en = parse_doc(info_path), en_doc(info_path)
+ load_translations(info_en)
+ n = u + 1
+ title = need(info, "nazev")
+ if flag(info, "zamceno"):
+ units.append({"id": u, "title": title, "page": None, "tag": None, "sub": None,
+ "unlocked": False, "names": [], "exercises": {}})
+ continue
+ exercises: dict[str, dict] = {}
+ names: list[str] = []
+ vocab = None
+ files = sorted((p for p in udir.glob("*.md")
+ if not p.name.endswith(".en.md") and not p.name.startswith("_")), key=by_order)
+ k = 0
+ for path in files:
+ doc, en = parse_doc(path), en_doc(path)
+ load_translations(en)
+ kind = need(doc, "typ")
+ if kind == "vocab":
+ lesson_id(doc, f"g.{u}.vocab")
+ vocab = german_exercise(doc, kind)
+ vocab = {"type": "vocab", "title": need(doc, "nazev"),
+ "sub": keyed(f"u{n}vocab_sub", doc, en, "popis"),
+ "sections": vocab["sections"]}
+ continue
+ if not re.match(r"^\d+-", path.name):
+ fail(path, None, "název souboru musí začínat pořadím, např. 03-nazev.md")
+ k += 1
+ lesson_id(doc, f"g.{u}.{k}")
+ ex = german_exercise(doc, kind)
+ ex["title"] = need(doc, "nazev")
+ ex["sub"] = keyed(f"u{n}e{k}_sub", doc, en, "popis")
+ if "tip" in doc.meta:
+ ex["tip"] = keyed(f"u{n}e{k}_tip", doc, en, "tip")
+ exercises[str(k)] = ex
+ names.append(ex["title"])
+ unit = {"id": u, "title": title, "page": f"unit{n}", "tag": f"u{n}",
+ "sub": keyed(f"unit{n}_sub", info, info_en, "popis"), "unlocked": True,
+ "names": names, "branch": f"u{n}vocab", "exercises": exercises}
+ if vocab is not None:
+ unit["vocab"] = vocab
+ units.append(unit)
+ return units
+
+
+# ---------------------------------------------------------------------------
+# App-level files
+# ---------------------------------------------------------------------------
+
+def ui_strings(path: Path) -> None:
+ doc = parse_doc(path)
+ for t in (nd for nd in doc.nodes if nd.kind == "table"):
+ for r in table(doc, [t], 3, "Texty"):
+ key = unesc(r[0])
+ if not key:
+ continue
+ if key in I18N.map:
+ warn(path, None, f"klíč {key!r} je v textech dvakrát")
+ I18N.put(key, nullable(r[1]), nullable(r[2]), path)
+
+
+def subjects(path: Path) -> list[dict]:
+ doc = parse_doc(path)
+ out = []
+ p = part(doc, "Předměty")
+ for r in table(doc, p.nodes if p else [], 6, "Předměty"):
+ key = unesc(r[0])
+ if r[1] or r[2]:
+ I18N.put(key, nullable(r[1]), nullable(r[2]), path)
+ out.append({"key": key, "open": flag_cell(r[5]), "target": nullable(r[4]),
+ "icon": unesc(r[3])})
+ return out
+
+
+def flag_cell(s: str) -> bool:
+ return norm(s) in ("ano", "true", "yes", "1")
+
+
+def parse_changelog(path: Path) -> list[dict]:
+ if not path.is_file():
+ return []
+ entries: list[dict] = []
+ current = None
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("---"):
+ current = {"date": line[3:].strip(), "cs": [], "en": []}
+ entries.append(current)
+ continue
+ if current is None:
+ continue
+ if line.startswith("cs:"):
+ current["cs"].append(line[3:].strip())
+ elif line.startswith("en:"):
+ current["en"].append(line[3:].strip())
+ return [e for e in entries if e["cs"] or e["en"]]
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+STD = [
+ ("pocitacove-site/2-rocnik", "net2"), ("pocitacove-site/3-rocnik", "net3"),
+ ("pocitacove-site/4-rocnik", "net4"),
+ ("technicke-vybaveni/1-rocnik", "hw"), ("technicke-vybaveni/2-rocnik", "hw2"),
+ ("technicke-vybaveni/3-rocnik", "hw3"), ("technicke-vybaveni/4-rocnik", "hw4"),
+ ("obcanska-nauka/1-rocnik", "on"), ("obcanska-nauka/2-rocnik", "on2"),
+ ("obcanska-nauka/3-rocnik", "on3"), ("obcanska-nauka/4-rocnik", "on4"),
+ ("cestina/literatura/1-rocnik", "lit"), ("cestina/literatura/2-rocnik", "lit2"),
+ ("cestina/literatura/3-rocnik", "lit3"), ("cestina/literatura/4-rocnik", "lit4"),
+ ("prirodni-vedy/chemie", "chem"), ("prirodni-vedy/biologie", "bio"),
+ ("fyzika/1-rocnik", "fyz"), ("fyzika/2-rocnik", "fyz2"),
+ ("fyzika/3-rocnik", "fyz3"), ("fyzika/4-rocnik", "fyz4"),
+ ("technicka-grafika/1-rocnik", "tg"), ("technicka-grafika/2-rocnik", "tg2"),
+ ("technicka-grafika/3-rocnik", "tg3"), ("technicka-grafika/4-rocnik", "tg4"),
+ ("prezentacni-grafika/1-rocnik", "pg"), ("prezentacni-grafika/2-rocnik", "pg2"),
+ ("prezentacni-grafika/3-rocnik", "pg3"), ("prezentacni-grafika/4-rocnik", "pg4"),
+ ("programove-vybaveni/1-rocnik", "sw"), ("programove-vybaveni/2-rocnik", "sw2"),
+ ("programove-vybaveni/3-rocnik", "sw3"), ("programove-vybaveni/4-rocnik", "sw4"),
+]
+CODE = [
+ ("programovani/1-rocnik", "cs"), ("programovani/2-rocnik", "cs2"),
+ ("programovani/3-rocnik", "cs3"), ("programovani/4-rocnik", "cs4"),
+]
+MATH = [
+ ("matematika/0-opakovani", "mat0"), ("matematika/1-rocnik", "mat"),
+ ("matematika/2-rocnik", "mat2"), ("matematika/3-rocnik", "mat3"),
+ ("matematika/4-rocnik", "mat4"),
+]
+SKILLS = [("anglictina", "en"), ("nemcina", "de")]
+
+
+def build(content: Path = CONTENT) -> dict:
+ ui_strings(content / "aplikace" / "texty.md")
+ payload: dict = {"i18n": None}
+ payload["subjects"] = subjects(content / "aplikace" / "predmety.md")
+ payload["german"] = german_textbook(content / "nemcina" / "ucebnice")
+ net = std_lessons(content / "pocitacove-site" / "1-rocnik", "net")
+ payload["net"] = {
+ "lessons": net,
+ "tasks": [t for t, _ in NET_TASKS],
+ "answers": [a for _, a in NET_TASKS],
+ }
+ for d, key in STD:
+ payload[key] = std_lessons(content / d, key)
+ for d, key in MATH:
+ payload[key] = math_lessons(content / d, key)
+ for d, key in CODE:
+ payload[key] = code_lessons(content / d, key)
+ for d, tag in SKILLS:
+ for year in range(1, 5):
+ payload[tag if year == 1 else f"{tag}{year}"] = skill_lessons(
+ content / d / f"{year}-rocnik", tag, year)
+ payload["mluvnice"] = mluvnice(content / "cestina" / "mluvnice")
+ payload["books"] = books(content / "cestina" / "cetba")
+ payload["changelog"] = parse_changelog(content / "aplikace" / "novinky.txt")
+ payload["progress"] = {"keys": PROGRESS, "legacy": LEGACY}
+ payload["i18n"] = I18N.map
+ check(payload)
+ return payload
+
+
+def check(payload: dict) -> None:
+ for sec, lessons in payload.items():
+ if not isinstance(lessons, list):
+ continue
+ for L in lessons:
+ if not isinstance(L, dict):
+ continue
+ for q in L.get("quiz") or []:
+ if isinstance(q, dict) and q.get("options") and not 0 <= q["correct"] < len(q["options"]):
+ ERRORS.append(f"{sec} {L.get('id')}: správná odpověď mimo možnosti")
+
+
+def main() -> int:
+ outs = OUTS
+ if "--out" in sys.argv:
+ outs = [Path(a) for a in sys.argv[sys.argv.index("--out") + 1:] if a]
+ if not outs:
+ sys.exit("usage: build-content.py [--out PATH ...]")
+ payload = build()
+ for w in WARNINGS:
+ print(f"upozornění: {w}", file=sys.stderr)
+ if ERRORS:
+ for e in ERRORS:
+ print(f"chyba: {e}", file=sys.stderr)
+ print(f"{len(ERRORS)} chyb, content.json nevznikl", file=sys.stderr)
+ return 1
+ text = json.dumps(payload, ensure_ascii=False, indent=1)
+ for dest in outs:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ if not dest.exists() or dest.read_text(encoding="utf-8") != text:
+ dest.write_text(text, encoding="utf-8")
+ print(f"content.json: {len(IDS)} lekcí, {len(payload['i18n'])} textů → "
+ + ", ".join(rel(d) for d in outs))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/bundle-ios.sh b/scripts/bundle-ios.sh
new file mode 100755
index 0000000..c9b9033
--- /dev/null
+++ b/scripts/bundle-ios.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+# Build an unsigned iOS IPA for sideloading (Xcode / AltStore / Sideloadly).
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$root"
+
+# The Xcode target builds content.json from content/ itself.
+export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
+COMMIT="${COMMIT:-$(git -C "$root" rev-parse HEAD 2>/dev/null || echo dev)}"
+dd="$root/ios/DerivedData"
+
+rm -rf "$root/ios/Payload"
+mkdir -p "$dd" "$root/dist-ios"
+
+xcodebuild \
+ -project ios/Graduately.xcodeproj \
+ -scheme Graduately \
+ -configuration Release \
+ -sdk iphoneos \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath "$dd" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ COMMIT="$COMMIT"
+
+app=$(find "$dd" -name 'Graduately.app' -path '*/Release-iphoneos/*' | head -n 1)
+if [ -z "$app" ]; then
+ echo "Graduately.app not found" >&2
+ exit 1
+fi
+
+mkdir -p "$root/ios/Payload"
+cp -R "$app" "$root/ios/Payload/Graduately.app"
+(cd "$root/ios" && zip -r -q "$root/dist-ios/graduately-ios.ipa" Payload)
+echo "wrote $root/dist-ios/graduately-ios.ipa"
diff --git a/scripts/bundle-linux.sh b/scripts/bundle-linux.sh
new file mode 100755
index 0000000..795ec12
--- /dev/null
+++ b/scripts/bundle-linux.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Linux AppImage (x86_64) of the Compose desktop app, with its own Java runtime.
+# Needs python3, curl and a JDK 17+ that ships jmods (set PACKAGE_JDK, e.g.
+# Temurin 21). The asset name and the icon path inside the image are what the
+# installer and the updater of older builds expect; keep them.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64|amd64) ARCH=x86_64 ;;
+ *)
+ echo "Only x86_64 AppImages are built, this machine is $ARCH" >&2
+ exit 1
+ ;;
+esac
+
+OUT="$ROOT/dist/graduately-linux-${ARCH}.AppImage"
+APPDIR="$ROOT/dist/AppDir"
+TOOLS="$ROOT/dist/tools"
+ICON="$ROOT/data/share/icons/hicolor/512x512/apps/maturita.png"
+IMAGE="$ROOT/android/desktop/build/compose/binaries/main-release/app/graduately"
+
+echo "==> Building the desktop app"
+(cd android && ./gradlew :desktop:createReleaseDistributable --no-daemon)
+
+echo "==> Preparing AppDir"
+rm -rf "$APPDIR"
+mkdir -p "$APPDIR/usr/share/icons/hicolor/512x512/apps" "$APPDIR/usr/share/applications"
+cp -R "$IMAGE" "$APPDIR/graduately"
+cp -f "$ICON" "$APPDIR/maturita.png"
+cp -f "$ICON" "$APPDIR/.DirIcon"
+cp -f "$ICON" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
+
+cat > "$APPDIR/AppRun" <<'EOF'
+#!/bin/sh
+HERE="$(dirname "$(readlink -f "$0")")"
+exec "$HERE/graduately/bin/graduately" "$@"
+EOF
+chmod +x "$APPDIR/AppRun"
+
+DESKTOP="$ROOT/data/share/applications/org.maturita.Maturita.desktop"
+cp -f "$DESKTOP" "$APPDIR/org.maturita.Maturita.desktop"
+cp -f "$DESKTOP" "$APPDIR/usr/share/applications/org.maturita.Maturita.desktop"
+
+TOOL="$TOOLS/appimagetool-${ARCH}.AppImage"
+if [[ ! -x "$TOOL" ]]; then
+ echo "==> Downloading appimagetool"
+ mkdir -p "$TOOLS"
+ curl -fsSL -o "$TOOL" \
+ "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
+ chmod +x "$TOOL"
+fi
+
+echo "==> Building AppImage"
+rm -f "$OUT"
+# CI runners have no FUSE; extract-and-run avoids needing it.
+APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$ARCH" "$TOOL" --no-appstream "$APPDIR" "$OUT"
+chmod +x "$OUT"
+echo "Created ${OUT#"$ROOT"/}"
diff --git a/scripts/bundle-macos-swift.sh b/scripts/bundle-macos-swift.sh
new file mode 100755
index 0000000..9a8a27c
--- /dev/null
+++ b/scripts/bundle-macos-swift.sh
@@ -0,0 +1,128 @@
+#!/bin/sh
+# Build the native Swift macOS .app and zip it for the builds branch.
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$root"
+
+export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
+if [ -z "${COMMIT:-}" ]; then
+ if [ ! -e "$root/.git" ] ||
+ ! COMMIT="$(git -C "$root" rev-parse --verify HEAD 2>/dev/null)"; then
+ echo "error: cannot determine the source commit; set COMMIT when bundling without Git metadata" >&2
+ exit 1
+ fi
+fi
+export COMMIT
+
+arch=$(uname -m)
+case "$arch" in
+ arm64|aarch64) arch_tag=arm64 ;;
+ x86_64) arch_tag=x86_64 ;;
+ *)
+ echo "Unsupported architecture: $arch" >&2
+ exit 1
+ ;;
+esac
+
+dd="$root/macos/DerivedData"
+out_zip="graduately-macos-${arch_tag}.zip"
+mkdir -p "$dd" "$root/dist"
+
+xcodebuild \
+ -project macos/Graduately.xcodeproj \
+ -scheme Graduately \
+ -configuration Release \
+ -destination "generic/platform=macOS" \
+ -derivedDataPath "$dd" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ COMMIT="$COMMIT"
+
+app=$(find "$dd" -name 'Graduately.app' -path '*/Release/*' | head -n 1)
+if [ -z "$app" ]; then
+ echo "Graduately.app not found" >&2
+ exit 1
+fi
+
+dest="$root/dist/Graduately.app"
+rm -rf "$dest"
+cp -R "$app" "$dest"
+
+ENTITLEMENTS="$root/macos/Graduately/Graduately.entitlements"
+
+pick_sign_identity() {
+ if [ -n "${MACOS_SIGN_IDENTITY:-}" ]; then
+ printf '%s\n' "$MACOS_SIGN_IDENTITY"
+ return
+ fi
+ found="$(security find-identity -v -p codesigning 2>/dev/null \
+ | awk -F'"' '/Developer ID Application/ { print $2; exit }')"
+ if [ -n "$found" ]; then
+ printf '%s\n' "$found"
+ return
+ fi
+ printf '%s\n' "-"
+}
+
+IDENTITY="$(pick_sign_identity)"
+echo "==> Signing Graduately.app ($IDENTITY)"
+sign_args="--force --sign $IDENTITY"
+if [ "$IDENTITY" != "-" ]; then
+ sign_args="$sign_args --options runtime --timestamp"
+fi
+if [ -f "$ENTITLEMENTS" ]; then
+ # shellcheck disable=SC2086
+ codesign $sign_args --entitlements "$ENTITLEMENTS" "$dest"
+else
+ # shellcheck disable=SC2086
+ codesign $sign_args "$dest"
+fi
+codesign --verify --strict --verbose=2 "$dest"
+
+can_notarize() {
+ [ "$IDENTITY" != "-" ] || return 1
+ if [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_KEY_ID:-}" ] &&
+ [ -n "${APPLE_API_ISSUER:-}" ]; then
+ return 0
+ fi
+ if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] &&
+ [ -n "${APPLE_TEAM_ID:-}" ]; then
+ return 0
+ fi
+ return 1
+}
+
+if can_notarize; then
+ echo "==> Notarizing Graduately.app"
+ zip="$root/dist/.notarize-$out_zip"
+ rm -f "$zip"
+ ditto -c -k --keepParent "$dest" "$zip"
+ if [ -n "${APPLE_API_KEY:-}" ]; then
+ keyfile="$(mktemp)"
+ printf '%s\n' "$APPLE_API_KEY" > "$keyfile"
+ xcrun notarytool submit "$zip" --wait \
+ --key "$keyfile" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER"
+ rm -f "$keyfile"
+ else
+ xcrun notarytool submit "$zip" --wait \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID"
+ fi
+ rm -f "$zip"
+ xcrun stapler staple "$dest"
+else
+ if [ "$IDENTITY" != "-" ] && [ -n "${GITHUB_ACTIONS:-}${CI:-}" ]; then
+ echo "error: Developer ID is present but notarization secrets are missing." >&2
+ exit 1
+ fi
+fi
+
+rm -f "$root/dist/$out_zip"
+(
+ cd "$root/dist"
+ zip -qry "$out_zip" Graduately.app
+)
+echo "wrote $root/dist/$out_zip"
diff --git a/scripts/bundle-windows.sh b/scripts/bundle-windows.sh
new file mode 100755
index 0000000..8ca8650
--- /dev/null
+++ b/scripts/bundle-windows.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Windows x64 zip of the Compose desktop app, with its own Java runtime.
+# Runs in Git Bash or MSYS2. Needs python3 and a JDK 17+ that ships jmods
+# (set PACKAGE_JDK, e.g. Temurin 21).
+#
+# graduately.exe must sit at the root of the zip: the installer and the
+# updater of older builds unpack it over the install folder and start it.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+OUT="$ROOT/dist/graduately-windows-x64.zip"
+IMAGE="$ROOT/android/desktop/build/compose/binaries/main-release/app/graduately"
+
+echo "==> Building the desktop app"
+(cd android && ./gradlew :desktop:createReleaseDistributable --no-daemon)
+
+[[ -f "$IMAGE/graduately.exe" ]] || { echo "graduately.exe missing in $IMAGE" >&2; exit 1; }
+
+echo "==> Zipping"
+mkdir -p "$ROOT/dist"
+rm -f "$OUT"
+if command -v 7z >/dev/null 2>&1; then
+ (cd "$IMAGE" && 7z a -tzip -mx=9 "$OUT" . >/dev/null)
+else
+ powershell -NoProfile -Command "Compress-Archive -Path '$(cygpath -w "$IMAGE")\\*' -DestinationPath '$(cygpath -w "$OUT")'"
+fi
+echo "Created ${OUT#"$ROOT"/}"
diff --git a/scripts/gen-icons.py b/scripts/gen-icons.py
new file mode 100755
index 0000000..d9fcb7d
--- /dev/null
+++ b/scripts/gen-icons.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""Regenerate assets/app-icon.ico and data/share/icons from assets/app-icon.png.
+
+Uses macOS `sips` when available; otherwise requires Pillow.
+Run from the repository root.
+"""
+from __future__ import annotations
+
+import struct
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SRC = ROOT / "assets" / "app-icon.png"
+ICO = ROOT / "assets" / "app-icon.ico"
+ICO_SIZES = [16, 32, 48, 64, 128, 256]
+SHARE_SIZES = [16, 32, 48, 64, 128, 256, 512]
+
+
+def resize_png(src: Path, dest: Path, size: int) -> None:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ if subprocess.call(["which", "sips"], stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL) == 0:
+ subprocess.check_call(
+ ["sips", "-z", str(size), str(size), str(src), "--out", str(dest)],
+ stdout=subprocess.DEVNULL,
+ )
+ return
+ try:
+ from PIL import Image
+ except ImportError as exc:
+ raise SystemExit(
+ "Need macOS sips or Pillow to resize icons"
+ ) from exc
+ img = Image.open(src).convert("RGBA")
+ img = img.resize((size, size), Image.Resampling.LANCZOS)
+ img.save(dest, format="PNG")
+
+
+def write_ico(entries: list[tuple[int, bytes]], dest: Path) -> None:
+ count = len(entries)
+ header = struct.pack("= 256 else size
+ h = 0 if size >= 256 else size
+ direntries.append(
+ struct.pack(" int:
+ if not SRC.is_file():
+ print(f"missing {SRC}", file=sys.stderr)
+ return 1
+
+ with tempfile.TemporaryDirectory(prefix="graduately-ico-") as tmp:
+ entries: list[tuple[int, bytes]] = []
+ for size in ICO_SIZES:
+ png = Path(tmp) / f"icon_{size}.png"
+ resize_png(SRC, png, size)
+ entries.append((size, png.read_bytes()))
+ write_ico(entries, ICO)
+ print(f"wrote {ICO.relative_to(ROOT)}")
+
+ for size in SHARE_SIZES:
+ dest = ROOT / "data" / "share" / "icons" / "hicolor" / f"{size}x{size}" / "apps" / "maturita.png"
+ resize_png(SRC, dest, size)
+ print(f"wrote {dest.relative_to(ROOT)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/smoke-platforms.sh b/scripts/smoke-platforms.sh
new file mode 100755
index 0000000..483de1e
--- /dev/null
+++ b/scripts/smoke-platforms.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# Local smoke checks for installers, content packs, and published builds.
+# Does not replace CI; catches packaging / content regressions early.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+REPO="${REPO:-pepaskopec-blip/Graduately}"
+fail=0
+
+say() { printf '==> %s\n' "$*"; }
+ok() { printf ' ok: %s\n' "$*"; }
+bad() { printf ' FAIL: %s\n' "$*" >&2; fail=1; }
+
+say "Rebuild installer zips"
+chmod +x installers/pack-installer-zips.sh
+./installers/pack-installer-zips.sh
+ok "installer zips refreshed"
+
+say "Installer zip contents"
+expect_zip() {
+ local zip=$1 want=$2
+ if unzip -Z1 "$zip" | grep -qxF "$want"; then
+ ok "$zip → $want"
+ else
+ bad "$zip missing $want (has: $(unzip -Z1 "$zip" | tr '\n' ' '))"
+ fi
+}
+expect_zip installers/graduately-installer-macos.zip "Nainstalovat Graduately.app/Contents/MacOS/installer"
+expect_zip installers/graduately-installer-linux.zip "graduately-installer-linux.sh"
+expect_zip installers/graduately-installer-windows.zip "graduately-installer-windows.cmd"
+expect_zip installers/graduately-installer-android.zip "Stahnout Graduately.html"
+expect_zip installers/graduately-installer-ios.zip "Jak nainstalovat Graduately.html"
+
+say "Build content.json"
+tmp=$(mktemp)
+python3 scripts/build-content.py --out "$tmp" >/dev/null
+python3 - "$tmp" <<'PY'
+import json, sys
+path = sys.argv[1]
+d = json.load(open(path, encoding="utf-8"))
+books = d.get("books") or []
+assert len(books) == 82, f"expected 82 books, got {len(books)}"
+for b in books:
+ bid = b.get("id")
+ assert b.get("quiz"), f"{bid}: empty quiz"
+ assert b.get("plot"), f"{bid}: empty plot"
+ assert b.get("notes"), f"{bid}: empty notes"
+ assert b.get("quizTitle"), f"{bid}: missing quizTitle"
+ assert b.get("plotTitle"), f"{bid}: missing plotTitle"
+assert d.get("changelog"), "changelog missing from content.json"
+print(f"books={len(books)} changelog={len(d['changelog'])}")
+PY
+ok "content packs"
+rm -f "$tmp"
+
+say "Answer checks"
+if (cd android && ./gradlew -q :desktop:test); then
+ ok "Kotlin"
+else
+ bad "Kotlin answer checks"
+fi
+if command -v xcrun >/dev/null 2>&1; then
+ if ./scripts/test-answers-swift.sh >/dev/null; then
+ ok "Swift"
+ else
+ bad "Swift answer checks"
+ fi
+fi
+
+say "Published builds tip"
+sha=$(curl -fsSL --max-time 20 \
+ "https://github.com/$REPO/commits/builds.atom" |
+ sed -n 's/.*Commit\/\([0-9a-f]\{40\}\).*/\1/p' |
+ head -n 1)
+if [[ ${#sha} -eq 40 ]]; then
+ ok "builds tip ${sha:0:7}"
+ for asset in \
+ graduately-macos-arm64.zip \
+ graduately-windows-x64.zip \
+ graduately-linux-x86_64.AppImage \
+ graduately-android.apk \
+ graduately-ios.ipa \
+ VERSION
+ do
+ code=$(curl -sI -o /dev/null -w '%{http_code}' --max-time 30 \
+ "https://raw.githubusercontent.com/$REPO/$sha/$asset")
+ if [[ "$code" == "200" ]]; then
+ ok "$asset ($code)"
+ continue
+ fi
+ legacy="${asset/graduately/maturita}"
+ if [[ "$legacy" != "$asset" ]]; then
+ code2=$(curl -sI -o /dev/null -w '%{http_code}' --max-time 30 \
+ "https://raw.githubusercontent.com/$REPO/$sha/$legacy")
+ if [[ "$code2" == "200" ]]; then
+ ok "$asset not yet; legacy $legacy still published ($code2)"
+ continue
+ fi
+ fi
+ bad "$asset HTTP $code"
+ done
+else
+ bad "could not resolve builds tip SHA"
+fi
+
+if [[ "$fail" -ne 0 ]]; then
+ echo "smoke failed" >&2
+ exit 1
+fi
+echo "smoke passed"
diff --git a/scripts/test-answers-swift.sh b/scripts/test-answers-swift.sh
new file mode 100755
index 0000000..7f776f5
--- /dev/null
+++ b/scripts/test-answers-swift.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Shared answer-check cases (tests/answers.json) against the Swift apps' code.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+OUT="$ROOT/build/tests"
+mkdir -p "$OUT"
+
+python3 "$ROOT/scripts/build-content.py" --out "$OUT/content.json"
+xcrun swiftc -O -o "$OUT/swift-answers" \
+ "$ROOT/ios/Graduately/Data/Answers.swift" "$ROOT/tests/swift/main.swift"
+"$OUT/swift-answers" "$ROOT/tests/answers.json" "$OUT/content.json"
diff --git a/scripts/test-macos-bundle-commit.sh b/scripts/test-macos-bundle-commit.sh
new file mode 100755
index 0000000..d6ea6bb
--- /dev/null
+++ b/scripts/test-macos-bundle-commit.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Exercise build identity at the real bundler entry point without Xcode.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TMP="$(mktemp -d "${TMPDIR:-/tmp}/graduately-bundle-commit.XXXXXX")"
+trap 'rm -rf "$TMP"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+mkdir -p "$TMP/bin"
+cat > "$TMP/bin/xcodebuild" <<'EOF'
+#!/usr/bin/env bash
+printf '%s\n' "${COMMIT-UNSET}" >> "$MATURITA_TEST_MAKE_LOG"
+# Stop before packaging, after observing the real build's environment.
+exit 97
+EOF
+cat > "$TMP/bin/uname" <<'EOF'
+#!/bin/sh
+echo arm64
+EOF
+chmod +x "$TMP/bin/xcodebuild" "$TMP/bin/uname"
+
+new_fixture() {
+ mkdir -p "$1/scripts"
+ cp "$ROOT/scripts/bundle-macos.sh" "$1/scripts/bundle-macos.sh"
+ cp "$ROOT/scripts/bundle-macos-swift.sh" "$1/scripts/bundle-macos-swift.sh"
+ chmod +x "$1/scripts/bundle-macos.sh" "$1/scripts/bundle-macos-swift.sh"
+}
+
+run_bundle() {
+ local fixture="$1" mode="$2" value="${3-}"
+ MAKE_LOG="$fixture/make.log"
+ OUTPUT_LOG="$fixture/output.log"
+ rm -f "$MAKE_LOG"
+ if (
+ unset COMMIT GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE
+ export PATH="$TMP/bin:$PATH"
+ export MATURITA_TEST_MAKE_LOG="$MAKE_LOG"
+ # A source archive must not accidentally inherit an enclosing checkout.
+ export GIT_CEILING_DIRECTORIES="$TMP"
+ if [[ "$mode" == supplied ]]; then
+ export COMMIT="$value"
+ fi
+ bash "$fixture/scripts/bundle-macos.sh"
+ ) > "$OUTPUT_LOG" 2>&1; then
+ STATUS=0
+ else
+ STATUS=$?
+ fi
+}
+
+expect_build_commit() {
+ local expected="$1" actual
+ [[ "$STATUS" -eq 97 ]] || {
+ cat "$OUTPUT_LOG" >&2
+ fail "expected the stubbed build to run (status $STATUS)"
+ }
+ [[ -f "$MAKE_LOG" ]] || fail "xcodebuild was not called"
+ actual="$(cat "$MAKE_LOG")"
+ [[ "$actual" == "$expected" ]] ||
+ fail "build did not receive COMMIT=$expected (got '$actual')"
+}
+
+expect_missing_identity() {
+ [[ "$STATUS" -ne 0 ]] || fail 'missing identity unexpectedly succeeded'
+ [[ ! -e "$MAKE_LOG" ]] || fail 'missing identity reached xcodebuild'
+ grep -q 'COMMIT' "$OUTPUT_LOG" || fail 'missing identity has no COMMIT guidance'
+}
+
+GIT_FIXTURE="$TMP/checkout with spaces"
+new_fixture "$GIT_FIXTURE"
+git init -q "$GIT_FIXTURE"
+git -C "$GIT_FIXTURE" -c user.name='Bundle test' \
+ -c user.email='bundle-test@example.invalid' -c commit.gpgsign=false \
+ -c core.hooksPath=/dev/null commit --allow-empty -qm 'Fixture commit'
+EXPECTED="$(git -C "$GIT_FIXTURE" rev-parse HEAD)"
+
+run_bundle "$GIT_FIXTURE" unset
+expect_build_commit "$EXPECTED"
+echo 'PASS: Git checkout identity with spaces in its path'
+
+run_bundle "$GIT_FIXTURE" supplied ''
+expect_build_commit "$EXPECTED"
+echo 'PASS: empty COMMIT falls back to checkout identity'
+
+ARCHIVE_FIXTURE="$TMP/source archive"
+new_fixture "$ARCHIVE_FIXTURE"
+EXPLICIT=0123456789abcdef0123456789abcdef01234567
+run_bundle "$ARCHIVE_FIXTURE" supplied "$EXPLICIT"
+expect_build_commit "$EXPLICIT"
+echo 'PASS: explicit COMMIT works without Git metadata'
+
+run_bundle "$ARCHIVE_FIXTURE" supplied dev
+expect_build_commit dev
+echo 'PASS: explicit development identity is preserved'
+
+run_bundle "$ARCHIVE_FIXTURE" unset
+expect_missing_identity
+echo 'PASS: source archive without COMMIT fails before building'
+
+NESTED_FIXTURE="$GIT_FIXTURE/exported sources"
+new_fixture "$NESTED_FIXTURE"
+run_bundle "$NESTED_FIXTURE" unset
+expect_missing_identity
+echo 'PASS: source archive does not inherit an unrelated parent Git identity'
diff --git a/tests/answers.json b/tests/answers.json
new file mode 100644
index 0000000..36d4fe1
--- /dev/null
+++ b/tests/answers.json
@@ -0,0 +1,104 @@
+{
+ "_about": "Answer checking cases shared by the Swift (iOS, macOS) and Kotlin (Android, desktop) apps. Both must agree with every line. Run with `make test`.",
+ "normalize": [
+ ["Größe", "groesse"],
+ ["Über", "ueber"],
+ [" Hallo, Welt! ", "hallo welt"],
+ ["Příliš žluťoučký kůň", "prilis zlutoucky kun"],
+ ["a\tb\nc", "a b c"],
+ ["e-mail", "email"],
+ ["a , b", "a b"],
+ ["", ""]
+ ],
+ "accepts": [
+ ["Deutschland", "deutschland|eutschland", true],
+ ["Osterreich", "österreich|osterreich", true],
+ ["Oesterreich", "österreich", true],
+ ["hasnt", "hasn't|has not", true],
+ ["has not", "hasn't|has not", true],
+ ["Seděla", "seděla|sedla", true],
+ ["sedela", "seděla|sedla", true],
+ ["is", "are", false],
+ ["", "", false],
+ ["x", null, false]
+ ],
+ "incomplete": [
+ ["der", "der Vater", true],
+ ["der vater", "der Vater", false],
+ ["", "der Vater", false],
+ ["die", "der Vater|die Mutter", true]
+ ],
+ "netTxtEq": [
+ ["10.0.0.0", " 10.0.0.0 ", true],
+ ["ABC", "abc", true],
+ ["10.0.0.1", "10.0.0.2", false],
+ [null, null, true],
+ ["a", null, false]
+ ],
+ "hasUmlaut": [
+ ["Mädchen", true],
+ ["Straße", true],
+ ["Strasse", false],
+ [null, false]
+ ],
+ "math": [
+ ["num:12", "12", true],
+ ["num:12", "12.0", true],
+ ["num:12", "12,0", true],
+ ["num:12", "12 cm", true],
+ ["num:12", "13", false],
+ ["num:12", "", false],
+ ["num:12", "abc", false],
+ ["num:1/2", "0,5", true],
+ ["num:1/2", "2/4", true],
+ ["num:1/2", "1/3", false],
+ ["num:3/2", "1 1/2", true],
+ ["num:-3", "−3", true],
+ ["num:50", "50 %", true],
+ ["num:2√3", "√12", true],
+ ["num:2√3", "sqrt(12)", true],
+ ["num:2√3", "3.4641", true],
+ ["num:2√3", "3.46", false],
+ ["num:2π", "2pi", true],
+ ["num:2π", "6.283", false],
+ ["set:2;3", "3; 2", true],
+ ["set:2;3", "2, 3", true],
+ ["set:2;3", "2;3;4", false],
+ ["set:2;3", "2", false],
+ ["pair:2;-1", "2; -1", true],
+ ["pair:2;-1", "-1; 2", false],
+ ["word:ano|yes", "Ano", true],
+ ["word:ano|yes", "áno", true],
+ ["word:ano|yes", "ne", false],
+ [null, "1", false]
+ ],
+ "drawNeed": [
+ ["square:2,3;5,3", 2],
+ ["mid:0,0;4,4", 1],
+ ["points:1,4;fix:1,1;fix:5,1", 1],
+ ["num:3", 0]
+ ],
+ "drawGiven": [
+ ["points:1,4;fix:1,1;fix:5,1", [[1, 1], [5, 1]]],
+ ["mid:0,0;4,4", [[0, 0], [4, 4]]],
+ ["para:0,0;2,0;0,2", [[0, 0], [2, 0], [0, 2]]],
+ ["num:3", []]
+ ],
+ "draw": [
+ ["mid:0,0;4,4", [[2, 2]], true],
+ ["mid:0,0;4,4", [[2, 3]], false],
+ ["mid:0,0;3,4", [[1, 2]], false],
+ ["para:0,0;2,0;0,2", [[2, 2]], true],
+ ["para:0,0;2,0;0,2", [[2, 1]], false],
+ ["right:2,4;8,4", [[5, 1]], true],
+ ["right:2,4;8,4", [[5, 4]], false],
+ ["square:2,3;5,3", [[5, 6], [2, 6]], true],
+ ["square:2,3;5,3", [[2, 6], [5, 6]], true],
+ ["square:2,3;5,3", [[5, 0], [2, 0]], true],
+ ["square:2,3;5,3", [[5, 5], [2, 5]], false],
+ ["points:1,4;fix:1,1", [[1, 4]], true],
+ ["points:1,4;fix:1,1", [[1, 5]], false],
+ ["points:1,4;fix:1,1", [[11, 0]], false],
+ ["num:3", [], false]
+ ]
+}
diff --git a/tests/swift/main.swift b/tests/swift/main.swift
new file mode 100644
index 0000000..fb4c289
--- /dev/null
+++ b/tests/swift/main.swift
@@ -0,0 +1,64 @@
+// Runs tests/answers.json against ios/Graduately/Data/Answers.swift, the same
+// cases the Kotlin apps check (android/desktop/src/test). Usage:
+// swift-answers
+import Foundation
+
+let args = CommandLine.arguments
+guard args.count == 3,
+ let casesData = FileManager.default.contents(atPath: args[1]),
+ let cases = try? JSONSerialization.jsonObject(with: casesData) as? [String: Any],
+ let contentData = FileManager.default.contents(atPath: args[2]),
+ let content = try? JSONSerialization.jsonObject(with: contentData) as? [String: Any]
+else {
+ print("usage: swift-answers tests/answers.json content.json")
+ exit(2)
+}
+
+var failures = 0
+var checked = 0
+
+func rows(_ name: String) -> [[Any]] { cases[name] as? [[Any]] ?? [] }
+func str(_ v: Any) -> String? { v as? String }
+func points(_ v: Any) -> [(Int, Int)] { (v as? [[Int]] ?? []).map { ($0[0], $0[1]) } }
+
+func expect(_ got: T, _ want: T, _ what: String) {
+ checked += 1
+ if got != want {
+ failures += 1
+ print("FAIL \(what): got \(got), want \(want)")
+ }
+}
+
+for r in rows("normalize") { expect(normalizeAnswer(str(r[0])), str(r[1]) ?? "", "normalize \(r[0])") }
+for r in rows("accepts") { expect(answerAccepts(normalizeAnswer(str(r[0])), str(r[1])), r[2] as! Bool, "accepts \(r)") }
+for r in rows("incomplete") { expect(answerIncomplete(normalizeAnswer(str(r[0])), str(r[1])), r[2] as! Bool, "incomplete \(r)") }
+for r in rows("netTxtEq") { expect(netTxtEq(str(r[0]), str(r[1])), r[2] as! Bool, "netTxtEq \(r)") }
+for r in rows("hasUmlaut") { expect(hasUmlaut(str(r[0])), r[1] as! Bool, "hasUmlaut \(r)") }
+for r in rows("math") { expect(mathAnswerOk(str(r[0]), str(r[1])), r[2] as! Bool, "math \(r)") }
+for r in rows("drawNeed") { expect(mathDrawNeed(str(r[0])), r[1] as! Int, "drawNeed \(r)") }
+for r in rows("drawGiven") {
+ expect(mathDrawGiven(str(r[0])).map { "\($0.0),\($0.1)" }, points(r[1]).map { "\($0.0),\($0.1)" }, "drawGiven \(r)")
+}
+for r in rows("draw") { expect(mathDrawOk(str(r[0]), points(r[1])), r[2] as! Bool, "draw \(r)") }
+
+// Every worked answer in content/ must pass its own check.
+var problems = 0
+for section in ["mat0", "mat", "mat2", "mat3", "mat4"] {
+ for lesson in content[section] as? [[String: Any]] ?? [] {
+ for p in lesson["problems"] as? [[String: Any]] ?? [] {
+ guard let spec = p["answer"] as? String else { continue }
+ let at = "\(section) \(lesson["key"] ?? ""): \(spec)"
+ if mathIsDraw(spec) {
+ expect(mathDrawNeed(spec) > 0, true, at)
+ } else {
+ let body = spec.split(separator: ":", maxSplits: 1).last.map(String.init) ?? spec
+ expect(mathAnswerOk(spec, body.components(separatedBy: "|")[0]), true, at)
+ }
+ problems += 1
+ }
+ }
+}
+expect(problems > 100, true, "content has math problems")
+
+print("swift: \(checked - failures)/\(checked) ok")
+exit(failures == 0 ? 0 : 1)