Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d566cf8dac | ||
|
|
567bfa799a |
34 changed files with 5136 additions and 24 deletions
20
.env.beispiel
Normal file
20
.env.beispiel
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Vorlage fuer .env - kopieren, ausfuellen, absichern:
|
||||||
|
#
|
||||||
|
# cp .env.beispiel .env && chmod 600 .env
|
||||||
|
#
|
||||||
|
# Gelesen von veroeffentliche_04.py. Hier steht KEIN Geheimnis: rsync und scp
|
||||||
|
# haben keine Passwort-Option, die Anmeldung laeuft ueber den SSH-Schluessel.
|
||||||
|
# Einmalig einrichten mit
|
||||||
|
#
|
||||||
|
# ssh-copy-id dschlueter@jamulix.de
|
||||||
|
#
|
||||||
|
# Danach fragt nichts mehr nach einem Passwort.
|
||||||
|
|
||||||
|
OR_HOST=jamulix.de
|
||||||
|
OR_BENUTZER=dschlueter
|
||||||
|
OR_ZIEL=/var/www/jamulix.de/html/OR/
|
||||||
|
OR_URL=https://jamulix.de/OR/
|
||||||
|
|
||||||
|
# Optional - nur setzen, wenn vom Ueblichen abweichend:
|
||||||
|
# OR_SSH_PORT=22
|
||||||
|
# OR_SSH_KEY=~/.ssh/id_ed25519
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -22,3 +22,8 @@ svg-inkscape/
|
||||||
Operations_Research_mit_Python_Version_04_Programme/output/
|
Operations_Research_mit_Python_Version_04_Programme/output/
|
||||||
produktionsmix.xlsx
|
produktionsmix.xlsx
|
||||||
produktionsmix_ergebnis.xlsx
|
produktionsmix_ergebnis.xlsx
|
||||||
|
|
||||||
|
# Zugangsdaten fuers Veroeffentlichen (veroeffentliche_04.py). Enthaelt zwar
|
||||||
|
# kein Passwort - die Anmeldung laeuft ueber den SSH-Schluessel -, aber Host,
|
||||||
|
# Benutzer und Serverpfad gehen niemanden etwas an. Vorlage: .env.beispiel
|
||||||
|
.env
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
|
||||||
```
|
```
|
||||||
Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte
|
Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte
|
||||||
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
|
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
|
||||||
|
web_04/ Quelle: site.css, site.js, icons.svg,
|
||||||
|
plotly.min.js, katex/ - die statischen
|
||||||
|
Bestandteile der Website
|
||||||
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
|
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
|
||||||
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
|
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
|
||||||
OR_HTML_04/ generiert: Mehrseiten-Website
|
OR_HTML_04/ generiert: Mehrseiten-Website
|
||||||
|
|
@ -39,7 +42,11 @@ LICENSE, LICENSE-TEXT.md MIT fuer Code, CC BY-SA 4.0 fuer
|
||||||
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
|
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
|
||||||
```
|
```
|
||||||
|
|
||||||
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet.
|
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet. Seit dem
|
||||||
|
Umzug nach `web_04/` stimmt dieser Satz auch: Vorher lagen `site.css`, `site.js`,
|
||||||
|
`icons.svg` und `katex/` mitten im erzeugten `OR_HTML_04/`. `--check` bewacht das jetzt
|
||||||
|
(`pruefe_assets()`), und der Bau bricht ab, wenn jemand die Kopie statt der Quelle
|
||||||
|
bearbeitet hat.
|
||||||
|
|
||||||
Dieses Verzeichnis ist seit dem 08.09.2026 ein **eigenes Git-Repository** (`main`). Das
|
Dieses Verzeichnis ist seit dem 08.09.2026 ein **eigenes Git-Repository** (`main`). Das
|
||||||
übergeordnete `OR_mit_Python/` ist nur noch das Archiv der Historie bis zur Trennung und
|
übergeordnete `OR_mit_Python/` ist nur noch das Archiv der Historie bis zur Trennung und
|
||||||
|
|
|
||||||
2
OR_HTML_04/stichwortverzeichnis.html
generated
2
OR_HTML_04/stichwortverzeichnis.html
generated
File diff suppressed because one or more lines are too long
|
|
@ -32,6 +32,7 @@ import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -71,6 +72,19 @@ BILDER_VERZ = os.path.join(BASIS, BILDER_NAME)
|
||||||
HTML_NAME = f"OR_HTML_{VERSION}"
|
HTML_NAME = f"OR_HTML_{VERSION}"
|
||||||
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
|
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
|
||||||
|
|
||||||
|
# Statische Bestandteile der Website, die kein Skript erzeugt: das von Hand
|
||||||
|
# gepflegte Stylesheet und Skript, die Symbolsammlung, die mitgelieferte
|
||||||
|
# Plotly- und KaTeX-Bibliothek. Der Aufbau von web_04/ spiegelt das Ziel
|
||||||
|
# (assets/, katex/), damit spiegle_statische_assets() ein stumpfer
|
||||||
|
# Baumdurchlauf bleibt.
|
||||||
|
#
|
||||||
|
# Sie lagen frueher in OR_HTML_04/ selbst - also mitten im erzeugten
|
||||||
|
# Verzeichnis. Das war eine Falle: Wer eine CSS-Regel suchte, suchte sie in den
|
||||||
|
# Quellen und fand nichts; wer OR_HTML_04/ loeschte und neu baute, bekam eine
|
||||||
|
# Website ohne Stil.
|
||||||
|
WEB_NAME = f"web_{VERSION}"
|
||||||
|
WEB_VERZ = os.path.join(BASIS, WEB_NAME)
|
||||||
|
|
||||||
# Generierte Jupyter-Notebooks (eines je Kapitel).
|
# Generierte Jupyter-Notebooks (eines je Kapitel).
|
||||||
NOTEBOOK_NAME = f"Notebooks_{VERSION}"
|
NOTEBOOK_NAME = f"Notebooks_{VERSION}"
|
||||||
NOTEBOOK_VERZ = os.path.join(BASIS, NOTEBOOK_NAME)
|
NOTEBOOK_VERZ = os.path.join(BASIS, NOTEBOOK_NAME)
|
||||||
|
|
@ -374,6 +388,49 @@ def pruefe_dateien() -> list[str]:
|
||||||
f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
|
f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
|
||||||
fehlend.append(name)
|
fehlend.append(name)
|
||||||
|
|
||||||
|
fehlend.extend(pruefe_assets())
|
||||||
|
return fehlend
|
||||||
|
|
||||||
|
|
||||||
|
# Dateien, die der Bau selbst in OR_HTML_04/assets/ erzeugt. Alles andere, was
|
||||||
|
# der Seitenkopf einbindet, muss aus der Quelle web_04/ kommen.
|
||||||
|
ERZEUGTE_ASSETS = {
|
||||||
|
"assets/highlight.css", # erzeuge_highlight_css() (pandoc)
|
||||||
|
"assets/search-index.js", # erzeuge_suchindex()
|
||||||
|
"assets/programme.js", # baue_programme_seite()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_assets() -> list[str]:
|
||||||
|
"""Jede Datei, die der Seitenkopf per href=/src= einbindet, braucht einen
|
||||||
|
Erzeuger: entweder ein Skript (ERZEUGTE_ASSETS) oder die Quelle web_04/.
|
||||||
|
|
||||||
|
Ohne diese Pruefung entsteht beim naechsten Eintrag im Seitenkopf wieder
|
||||||
|
eine Datei ohne Herkunft - genau der Zustand, aus dem site.css, site.js,
|
||||||
|
icons.svg und katex/ gerade befreit wurden."""
|
||||||
|
# Gelesen wird der Quelltext dieses Skripts, nicht eine gerenderte Seite:
|
||||||
|
# Die Verweise stehen als Literale in mehreren Bausteinen (Seitenkopf,
|
||||||
|
# KATEX_KOPF, PLOTLY_SKRIPT), und eine Textsuche findet sie alle, ohne
|
||||||
|
# dass dafuer eine Seite gebaut werden muss.
|
||||||
|
with open(os.path.abspath(__file__), encoding="utf-8") as f:
|
||||||
|
eigener_quelltext = f.read()
|
||||||
|
verweise = sorted(set(re.findall(r'(?:href|src)="((?:assets|katex)/[^"]+)"',
|
||||||
|
eigener_quelltext)))
|
||||||
|
fehlend = []
|
||||||
|
for rel in verweise:
|
||||||
|
if rel in ERZEUGTE_ASSETS:
|
||||||
|
continue
|
||||||
|
if os.path.exists(os.path.join(WEB_VERZ, rel)):
|
||||||
|
continue
|
||||||
|
print(f"FEHLER: Der Seitenkopf bindet '{rel}' ein, aber niemand erzeugt "
|
||||||
|
f"die Datei - sie fehlt in {WEB_NAME}/ und in ERZEUGTE_ASSETS.")
|
||||||
|
fehlend.append(rel)
|
||||||
|
# icons.svg wird nicht verlinkt, sondern inline eingebettet (ICON_SPRITE_INLINE)
|
||||||
|
# und taucht deshalb nicht in `verweise` auf - hier separat geprueft.
|
||||||
|
if not os.path.exists(os.path.join(WEB_VERZ, "assets", "icons.svg")):
|
||||||
|
print(f"FEHLER: {WEB_NAME}/assets/icons.svg fehlt - die Seiten haetten "
|
||||||
|
"keine Symbole.")
|
||||||
|
fehlend.append("assets/icons.svg")
|
||||||
return fehlend
|
return fehlend
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1364,7 +1421,7 @@ def baue_gesamtdokument(html_verz: str) -> None:
|
||||||
"--standalone", "--wrap=none", "--toc", "--toc-depth=3",
|
"--standalone", "--wrap=none", "--toc", "--toc-depth=3",
|
||||||
"-M", f"pagetitle={SITE_TITEL} - Version {VERSION}",
|
"-M", f"pagetitle={SITE_TITEL} - Version {VERSION}",
|
||||||
"-M", "lang=de", "--highlight-style=breezedark", "-o", ziel]
|
"-M", "lang=de", "--highlight-style=breezedark", "-o", ziel]
|
||||||
if os.path.exists(os.path.join(html_verz, "katex")):
|
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
|
||||||
befehl.insert(-2, "--katex=katex/")
|
befehl.insert(-2, "--katex=katex/")
|
||||||
if os.path.exists(stil):
|
if os.path.exists(stil):
|
||||||
befehl.extend(["-H", stil])
|
befehl.extend(["-H", stil])
|
||||||
|
|
@ -1431,10 +1488,14 @@ def icon(name: str, klasse: str = "icon") -> str:
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def _lade_icon_sprite_inline() -> str:
|
def _lade_icon_sprite_inline() -> str:
|
||||||
"""Liest assets/icons.svg und liefert nur die <symbol>-Definitionen
|
"""Liest web_04/assets/icons.svg und liefert nur die <symbol>-Definitionen
|
||||||
(ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
|
(ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
|
||||||
icon()."""
|
icon().
|
||||||
pfad = os.path.join(HTML_VERZ, "assets", "icons.svg")
|
|
||||||
|
Gelesen wird die QUELLE, nicht die Kopie in OR_HTML_04/: Sonst haenge der
|
||||||
|
Bau von seinem eigenen Ergebnis ab und schluege bei leerem Zielverzeichnis
|
||||||
|
fehl."""
|
||||||
|
pfad = os.path.join(WEB_VERZ, "assets", "icons.svg")
|
||||||
with open(pfad, encoding="utf-8") as f:
|
with open(pfad, encoding="utf-8") as f:
|
||||||
inhalt = f.read()
|
inhalt = f.read()
|
||||||
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL)
|
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL)
|
||||||
|
|
@ -1490,6 +1551,50 @@ def spiegle_bilder(html_verz: str) -> None:
|
||||||
print(f"Bilder gespiegelt: {kopiert} Datei(en) nach {HTML_NAME}/{BILDER_NAME}/")
|
print(f"Bilder gespiegelt: {kopiert} Datei(en) nach {HTML_NAME}/{BILDER_NAME}/")
|
||||||
|
|
||||||
|
|
||||||
|
def spiegle_statische_assets(html_verz: str) -> None:
|
||||||
|
"""Kopiert web_04/ nach OR_HTML_04/ - bei JEDEM Lauf, nicht nur wenn das
|
||||||
|
Ziel fehlt. Erst dadurch ist OR_HTML_04/ vollstaendig wiederherstellbar:
|
||||||
|
loeschen, neu bauen, fertig.
|
||||||
|
|
||||||
|
Der Waechter: Weicht die Zieldatei von der Quelle ab und ist sie neuer,
|
||||||
|
hat jemand die Kopie bearbeitet statt des Originals. Dann bricht der Bau
|
||||||
|
ab, statt die Arbeit still zu ueberschreiben - derselbe Fehler ist in
|
||||||
|
diesem Projekt schon vorgekommen."""
|
||||||
|
if not os.path.isdir(WEB_VERZ):
|
||||||
|
print(f"WARNUNG: Quellverzeichnis der Website-Assets fehlt: {WEB_VERZ}")
|
||||||
|
return
|
||||||
|
kopiert = 0
|
||||||
|
for wurzel, verzeichnisse, dateien in os.walk(WEB_VERZ):
|
||||||
|
verzeichnisse[:] = [v for v in verzeichnisse if not v.startswith(".")]
|
||||||
|
rel = os.path.relpath(wurzel, WEB_VERZ)
|
||||||
|
ziel_verz = html_verz if rel == "." else os.path.join(html_verz, rel)
|
||||||
|
os.makedirs(ziel_verz, exist_ok=True)
|
||||||
|
for name in sorted(dateien):
|
||||||
|
quelle = os.path.join(wurzel, name)
|
||||||
|
ziel = os.path.join(ziel_verz, name)
|
||||||
|
with open(quelle, "rb") as f:
|
||||||
|
daten = f.read()
|
||||||
|
if os.path.exists(ziel):
|
||||||
|
with open(ziel, "rb") as f:
|
||||||
|
vorhanden = f.read()
|
||||||
|
if vorhanden == daten:
|
||||||
|
continue
|
||||||
|
if os.path.getmtime(ziel) > os.path.getmtime(quelle):
|
||||||
|
rel_ziel = os.path.relpath(ziel, BASIS)
|
||||||
|
rel_quelle = os.path.relpath(quelle, BASIS)
|
||||||
|
raise SystemExit(
|
||||||
|
f"FEHLER: {rel_ziel} wurde geaendert, ist aber eine Kopie.\n"
|
||||||
|
f" Die Aenderung gehoert in die Quelle:\n"
|
||||||
|
f" mv {rel_ziel} {rel_quelle}")
|
||||||
|
with open(ziel, "wb") as f:
|
||||||
|
f.write(daten)
|
||||||
|
shutil.copystat(quelle, ziel)
|
||||||
|
kopiert += 1
|
||||||
|
gesamt = sum(len(d) for _, _, d in os.walk(WEB_VERZ))
|
||||||
|
print(f"Statische Assets: {gesamt} Datei(en) aus {WEB_NAME}/, "
|
||||||
|
f"davon {kopiert} neu geschrieben.")
|
||||||
|
|
||||||
|
|
||||||
def kopiere_pdf(html_verz: str) -> bool:
|
def kopiere_pdf(html_verz: str) -> bool:
|
||||||
"""Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der
|
"""Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der
|
||||||
Sidebar funktioniert, sobald der Ordner auf einen Webserver kopiert wird.
|
Sidebar funktioniert, sobald der Ordner auf einen Webserver kopiert wird.
|
||||||
|
|
@ -1651,7 +1756,7 @@ PANDOC_HTML_BASIS = ["-f", "markdown+autolink_bare_uris", "-t", "html5",
|
||||||
|
|
||||||
def _pandoc_fragment(text: str, html_verz: str) -> str:
|
def _pandoc_fragment(text: str, html_verz: str) -> str:
|
||||||
befehl = ["pandoc"] + PANDOC_HTML_BASIS
|
befehl = ["pandoc"] + PANDOC_HTML_BASIS
|
||||||
if os.path.exists(os.path.join(html_verz, "katex")):
|
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
|
||||||
befehl.append("--katex=katex/")
|
befehl.append("--katex=katex/")
|
||||||
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
|
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
|
||||||
capture_output=True, text=True, check=True)
|
capture_output=True, text=True, check=True)
|
||||||
|
|
@ -1725,14 +1830,20 @@ def resolve_plotly(text: str, fuer_html: bool) -> tuple[str, bool]:
|
||||||
return PLOTLY_RE.sub(ersetze, text), eingebettet
|
return PLOTLY_RE.sub(ersetze, text), eingebettet
|
||||||
|
|
||||||
|
|
||||||
def kopiere_plotly_bibliothek(html_verz: str) -> bool:
|
def kopiere_plotly_bibliothek() -> bool:
|
||||||
"""Kopiert die von plotly mitgelieferte plotly.min.js nach assets/.
|
"""Holt die von plotly mitgelieferte plotly.min.js in die QUELLE
|
||||||
|
web_04/assets/ - von dort verteilt spiegle_statische_assets() sie.
|
||||||
|
|
||||||
Bewusst lokal statt per CDN: Die Website soll komplett offline
|
Bewusst lokal statt per CDN: Die Website soll komplett offline
|
||||||
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets."""
|
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets.
|
||||||
ziel = os.path.join(html_verz, "assets", "plotly.min.js")
|
|
||||||
|
Nur wenn die Datei fehlt: Sie ist mit 4,8 MB versioniert und aendert sich
|
||||||
|
nur, wenn plotly aktualisiert wird. Wer sie auffrischen will, loescht sie
|
||||||
|
und baut neu."""
|
||||||
|
ziel = os.path.join(WEB_VERZ, "assets", "plotly.min.js")
|
||||||
if os.path.exists(ziel):
|
if os.path.exists(ziel):
|
||||||
return True
|
return True
|
||||||
|
os.makedirs(os.path.dirname(ziel), exist_ok=True)
|
||||||
try:
|
try:
|
||||||
import plotly
|
import plotly
|
||||||
quelle = os.path.join(os.path.dirname(plotly.__file__),
|
quelle = os.path.join(os.path.dirname(plotly.__file__),
|
||||||
|
|
@ -2265,18 +2376,33 @@ def baue_stichwortverzeichnis(html_verz: str, struktur: list[dict],
|
||||||
haupt_name, _, unter_name = eintrag["roh"].partition("!")
|
haupt_name, _, unter_name = eintrag["roh"].partition("!")
|
||||||
haupt_name = haupt_name.rsplit("@", 1)[-1].strip()
|
haupt_name = haupt_name.rsplit("@", 1)[-1].strip()
|
||||||
ziel = (eintrag["seite"], eintrag["anker"], eintrag["kontext"])
|
ziel = (eintrag["seite"], eintrag["anker"], eintrag["kontext"])
|
||||||
knoten = haupt.setdefault(haupt_name, {"ziele": set(), "unter": {}})
|
knoten = haupt.setdefault(haupt_name, {"ziele": {}, "unter": {}})
|
||||||
if unter_name:
|
if unter_name:
|
||||||
unter_name = unter_name.rsplit("@", 1)[-1].strip()
|
unter_name = unter_name.rsplit("@", 1)[-1].strip()
|
||||||
knoten["unter"].setdefault(unter_name, set()).add(ziel)
|
knoten["unter"].setdefault(unter_name, {})[ziel] = None
|
||||||
else:
|
else:
|
||||||
knoten["ziele"].add(ziel)
|
knoten["ziele"][ziel] = None
|
||||||
|
|
||||||
def ziel_links(ziele: set) -> str:
|
def ziel_links(ziele: dict) -> str:
|
||||||
eintraege = sorted(ziele, key=lambda z: (z[0], z[2]))
|
# Ein Link je Kapitel, nicht je Fundstelle. 'kontext' ist der
|
||||||
|
# Kapiteltitel und damit fuer alle Marken einer Datei gleich - vier
|
||||||
|
# {idx:Branch-and-Bound} in Kapitel 6 ergaben vier optisch identische
|
||||||
|
# Links nebeneinander. Behalten wird die ERSTE Fundstelle; die Marken
|
||||||
|
# kommen in Dokumentreihenfolge an, weil finditer() den Text linear
|
||||||
|
# durchlaeuft und dict die Einfuegereihenfolge bewahrt.
|
||||||
|
#
|
||||||
|
# Die Deduplizierung macht die anschliessende Sortierung zugleich
|
||||||
|
# eindeutig. Vorher war sie es nicht: Der Schluessel (seite, kontext)
|
||||||
|
# war fuer zwei Fundstellen desselben Kapitels identisch, und die
|
||||||
|
# Reihenfolge fiel auf die eines set() zurueck - also auf den je Prozess
|
||||||
|
# zufaelligen PYTHONHASHSEED. Zwei Laeufe erzeugten unterschiedliche
|
||||||
|
# Bytes, ohne dass sich eine Quelle geaendert haette.
|
||||||
|
je_kapitel: dict[tuple[str, str], str] = {}
|
||||||
|
for seite, anker, kontext in ziele:
|
||||||
|
je_kapitel.setdefault((seite, kontext), anker)
|
||||||
return ", ".join(
|
return ", ".join(
|
||||||
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>'
|
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>'
|
||||||
for seite, anker, kontext in eintraege)
|
for (seite, kontext), anker in sorted(je_kapitel.items()))
|
||||||
|
|
||||||
namen = sorted(haupt.keys(), key=_sortierschluessel)
|
namen = sorted(haupt.keys(), key=_sortierschluessel)
|
||||||
sprungleiste = " ".join(
|
sprungleiste = " ".join(
|
||||||
|
|
@ -2346,12 +2472,13 @@ def baue_html_website() -> None:
|
||||||
os.makedirs(html_verz, exist_ok=True)
|
os.makedirs(html_verz, exist_ok=True)
|
||||||
|
|
||||||
datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry()
|
datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry()
|
||||||
|
kopiere_plotly_bibliothek()
|
||||||
|
spiegle_statische_assets(html_verz)
|
||||||
spiegle_bilder(html_verz)
|
spiegle_bilder(html_verz)
|
||||||
erzeuge_highlight_css(html_verz)
|
erzeuge_highlight_css(html_verz)
|
||||||
pdf_verfuegbar = kopiere_pdf(html_verz)
|
pdf_verfuegbar = kopiere_pdf(html_verz)
|
||||||
notizbuecher = baue_notebooks(struktur)
|
notizbuecher = baue_notebooks(struktur)
|
||||||
spiegle_notebooks(html_verz, notizbuecher)
|
spiegle_notebooks(html_verz, notizbuecher)
|
||||||
kopiere_plotly_bibliothek(html_verz)
|
|
||||||
suchindex: list[dict] = []
|
suchindex: list[dict] = []
|
||||||
baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur,
|
baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur,
|
||||||
suchindex, pdf_verfuegbar, notizbuecher)
|
suchindex, pdf_verfuegbar, notizbuecher)
|
||||||
|
|
|
||||||
342
Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
Executable file
342
Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
Executable file
|
|
@ -0,0 +1,342 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# veroeffentliche_04.py
|
||||||
|
#
|
||||||
|
# Laedt OR_HTML_04/ auf den Webserver - mit rsync, nicht mit scp.
|
||||||
|
#
|
||||||
|
# Warum das der Unterschied ist: scp ueberschreibt nur, es loescht nie. Nach
|
||||||
|
# dem Teilen von Anhang E/F blieb 'anhang-glossar-literatur.html' online
|
||||||
|
# erreichbar, obwohl die Datei aus dem Buch verschwunden war - verlinkt von
|
||||||
|
# nirgendwo, aber auffindbar. Das Zielverzeichnis vorher zu leeren waere die
|
||||||
|
# grobe Antwort darauf: Bricht die Uebertragung von 23 MB in 213 Dateien ab,
|
||||||
|
# ist die Website tot. rsync --delete uebertraegt nur Geaendertes und entfernt
|
||||||
|
# genau das, was verschwunden ist.
|
||||||
|
#
|
||||||
|
# Der zweite Grund fuer dieses Skript wiegt schwerer: Die Website hing
|
||||||
|
# wochenlang einen Bau zurueck, ohne dass es auffiel. Deshalb prueft Schritt 1,
|
||||||
|
# ob der Stand ueberhaupt aktuell ist, und Schritt 4 prueft hinterher per HTTP
|
||||||
|
# nach, ob das Ergebnis stimmt.
|
||||||
|
#
|
||||||
|
# Anmeldung per SSH-Schluessel. rsync und scp haben keine Passwort-Option; ein
|
||||||
|
# Passwort braeuchte sshpass und staende waehrend der Uebertragung in der
|
||||||
|
# Prozessliste. Einmalig:
|
||||||
|
#
|
||||||
|
# ssh-copy-id dschlueter@jamulix.de
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
|
||||||
|
# ... --probelauf
|
||||||
|
#
|
||||||
|
# Konfiguration in Version_04/.env (chmod 600, nicht im Repository) - siehe
|
||||||
|
# .env.beispiel. Ein Geheimnis steht nicht darin.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BASIS = os.path.dirname(HIER)
|
||||||
|
|
||||||
|
VERSION = "04"
|
||||||
|
HTML_NAME = f"OR_HTML_{VERSION}"
|
||||||
|
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
|
||||||
|
WEB_NAME = f"web_{VERSION}"
|
||||||
|
PDF_DATEINAME = f"Operations_Research_mit_Python_Version_{VERSION}.pdf"
|
||||||
|
|
||||||
|
ENV_DATEI = os.path.join(BASIS, ".env")
|
||||||
|
|
||||||
|
# Verzeichnisse, deren Aenderung einen neuen Bau noetig macht. Ist eine Datei
|
||||||
|
# darin neuer als die juengste erzeugte Seite, ist die Website veraltet.
|
||||||
|
QUELLEN = [
|
||||||
|
os.path.join(BASIS, f"Operations_Research_mit_Python_Version_{VERSION}"),
|
||||||
|
os.path.join(BASIS, f"bilder_{VERSION}"),
|
||||||
|
os.path.join(BASIS, WEB_NAME),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Was nach dem Hochladen erreichbar sein MUSS bzw. NICHT MEHR erreichbar sein
|
||||||
|
# darf. Die 404-Zeile ist die eigentliche Probe darauf, dass --delete gewirkt
|
||||||
|
# hat: Sie war der Anlass fuer dieses Skript.
|
||||||
|
NACHPRUEFUNG = [
|
||||||
|
("", 200),
|
||||||
|
("index.html", 200),
|
||||||
|
("notebooks.html", 200),
|
||||||
|
("programme.html", 200),
|
||||||
|
("anhang-glossar.html", 200),
|
||||||
|
("anhang-literatur.html", 200),
|
||||||
|
("stichwortverzeichnis.html", 200),
|
||||||
|
("Notebooks_04.zip", 200),
|
||||||
|
("Notebooks_04/", 200),
|
||||||
|
("programme/", 200),
|
||||||
|
("assets/site.css", 200),
|
||||||
|
("katex/katex.min.js", 200),
|
||||||
|
(PDF_DATEINAME, 200),
|
||||||
|
("anhang-glossar-literatur.html", 404),
|
||||||
|
]
|
||||||
|
|
||||||
|
LOESCHGRENZE = 30
|
||||||
|
|
||||||
|
|
||||||
|
def lies_env() -> dict[str, str]:
|
||||||
|
"""Liest .env - Zeilen der Form SCHLUESSEL=wert, '#' leitet einen
|
||||||
|
Kommentar ein. Bewusst kein python-dotenv: fuenf Schluessel rechtfertigen
|
||||||
|
keine Abhaengigkeit."""
|
||||||
|
if not os.path.exists(ENV_DATEI):
|
||||||
|
raise SystemExit(
|
||||||
|
f"FEHLER: {ENV_DATEI} fehlt.\n"
|
||||||
|
f" Vorlage kopieren und ausfuellen:\n"
|
||||||
|
f" cp .env.beispiel .env && chmod 600 .env")
|
||||||
|
werte = {}
|
||||||
|
with open(ENV_DATEI, encoding="utf-8") as f:
|
||||||
|
for zeile in f:
|
||||||
|
zeile = zeile.split("#", 1)[0].strip()
|
||||||
|
if not zeile or "=" not in zeile:
|
||||||
|
continue
|
||||||
|
schluessel, _, wert = zeile.partition("=")
|
||||||
|
werte[schluessel.strip()] = wert.strip().strip('"').strip("'")
|
||||||
|
fehlend = [s for s in ("OR_HOST", "OR_BENUTZER", "OR_ZIEL", "OR_URL")
|
||||||
|
if not werte.get(s)]
|
||||||
|
if fehlend:
|
||||||
|
raise SystemExit(f"FEHLER: In .env fehlen: {', '.join(fehlend)}")
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def juengste_datei(verzeichnisse: list[str]) -> tuple[float, str]:
|
||||||
|
"""Liefert (Zeitstempel, Pfad) der zuletzt geaenderten Datei.
|
||||||
|
|
||||||
|
Dieses Skript selbst zaehlt nicht mit: Es aendert die Website nicht, sein
|
||||||
|
Bearbeiten duerfte den Bau also nicht als veraltet erscheinen lassen."""
|
||||||
|
neuste, pfad = 0.0, ""
|
||||||
|
for verzeichnis in verzeichnisse:
|
||||||
|
for wurzel, verz, dateien in os.walk(verzeichnis):
|
||||||
|
verz[:] = [v for v in verz
|
||||||
|
if v != "__pycache__" and not v.startswith(".")]
|
||||||
|
for name in dateien:
|
||||||
|
voll = os.path.join(wurzel, name)
|
||||||
|
if os.path.abspath(voll) == os.path.abspath(__file__):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zeit = os.path.getmtime(voll)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if zeit > neuste:
|
||||||
|
neuste, pfad = zeit, voll
|
||||||
|
return neuste, pfad
|
||||||
|
|
||||||
|
|
||||||
|
def vorpruefung() -> None:
|
||||||
|
"""Jede Verletzung bricht ab. Ein veralteter oder unvollstaendiger Stand
|
||||||
|
soll gar nicht erst auf den Server kommen."""
|
||||||
|
def scheitern(text: str) -> None:
|
||||||
|
raise SystemExit(f"FEHLER: {text}")
|
||||||
|
|
||||||
|
bau_aufruf = (f"python3 Operations_Research_mit_Python_Version_{VERSION}/"
|
||||||
|
f"build_version_{VERSION}.py --pdf --html")
|
||||||
|
if not os.path.exists(os.path.join(HTML_VERZ, "index.html")):
|
||||||
|
scheitern(f"{HTML_NAME}/index.html fehlt - erst bauen:\n"
|
||||||
|
f" {bau_aufruf}")
|
||||||
|
|
||||||
|
# Ist der Bau aktuell? Verglichen wird gegen die juengste erzeugte Seite,
|
||||||
|
# nicht gegen die aelteste: Der Bau schreibt alle Seiten in einem Zug.
|
||||||
|
quell_zeit, quell_datei = juengste_datei(QUELLEN)
|
||||||
|
bau_zeit, _ = juengste_datei([HTML_VERZ])
|
||||||
|
if quell_zeit > bau_zeit:
|
||||||
|
scheitern(
|
||||||
|
f"Die Quelle ist neuer als die Website:\n"
|
||||||
|
f" {os.path.relpath(quell_datei, BASIS)}\n"
|
||||||
|
f" Erst bauen, dann hochladen - sonst geht ein alter Stand "
|
||||||
|
f"online.\n"
|
||||||
|
f" (Bewusst trotzdem hochladen: --ohne-vorpruefung)")
|
||||||
|
|
||||||
|
# Das PDF wurde schon einmal aus der Wurzel kopiert, ohne dass die Kopie
|
||||||
|
# nachgezogen wurde - die Website bot ein aelteres PDF an als das Buch.
|
||||||
|
pdf_wurzel = os.path.join(BASIS, PDF_DATEINAME)
|
||||||
|
pdf_kopie = os.path.join(HTML_VERZ, PDF_DATEINAME)
|
||||||
|
if not os.path.exists(pdf_kopie):
|
||||||
|
scheitern(f"{HTML_NAME}/{PDF_DATEINAME} fehlt - mit --pdf --html bauen.")
|
||||||
|
if os.path.exists(pdf_wurzel):
|
||||||
|
gross_wurzel = os.path.getsize(pdf_wurzel)
|
||||||
|
gross_kopie = os.path.getsize(pdf_kopie)
|
||||||
|
if gross_wurzel != gross_kopie:
|
||||||
|
scheitern(
|
||||||
|
f"Das PDF der Website weicht vom gebauten ab "
|
||||||
|
f"({gross_kopie:n} statt {gross_wurzel:n} Bytes).\n"
|
||||||
|
f" Nochmal mit --html bauen, das kopiert es nach.")
|
||||||
|
|
||||||
|
# Ohne die beiden Weiterleitungen antwortet der Webserver auf die
|
||||||
|
# Verzeichnisse mit 403 - genau das war der Fehlerbericht.
|
||||||
|
pflicht = ["notebooks.html", "programme.html", f"Notebooks_{VERSION}.zip",
|
||||||
|
f"Notebooks_{VERSION}/index.html", "programme/index.html"]
|
||||||
|
fehlend = [d for d in pflicht
|
||||||
|
if not os.path.exists(os.path.join(HTML_VERZ, d))]
|
||||||
|
if fehlend:
|
||||||
|
scheitern(f"In {HTML_NAME}/ fehlen: {', '.join(fehlend)}\n"
|
||||||
|
f" Erst mit --html bauen.")
|
||||||
|
|
||||||
|
print("Vorpruefung bestanden: Bau ist aktuell und vollstaendig.")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_optionen(env: dict[str, str]) -> str:
|
||||||
|
teile = ["ssh"]
|
||||||
|
if env.get("OR_SSH_PORT"):
|
||||||
|
teile += ["-p", env["OR_SSH_PORT"]]
|
||||||
|
if env.get("OR_SSH_KEY"):
|
||||||
|
teile += ["-i", os.path.expanduser(env["OR_SSH_KEY"])]
|
||||||
|
return " ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_befehl(env: dict[str, str], probelauf: bool) -> list[str]:
|
||||||
|
# -rlt statt -a: Eigentuemer und Gruppe lassen sich als normaler Nutzer
|
||||||
|
# ohnehin nicht setzen, -a erzeugte dafuer nur Warnungen. --chmod sorgt
|
||||||
|
# dafuer, dass der Webserver alles lesen kann, unabhaengig von der lokalen
|
||||||
|
# umask.
|
||||||
|
befehl = ["rsync", "-rltz", "--delete", "--chmod=D755,F644",
|
||||||
|
"--skip-compress=pdf/zip/woff2/png/jpg/svg",
|
||||||
|
"--itemize-changes",
|
||||||
|
"-e", ssh_optionen(env)]
|
||||||
|
if probelauf:
|
||||||
|
befehl.append("--dry-run")
|
||||||
|
ziel = env["OR_ZIEL"]
|
||||||
|
befehl += [HTML_VERZ + os.sep,
|
||||||
|
f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{ziel}']
|
||||||
|
return befehl
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_ziel_erreichbar(env: dict[str, str]) -> None:
|
||||||
|
"""Ein Handschlag, bevor 23 MB losgeschickt werden - und er unterscheidet
|
||||||
|
die beiden Fehlerursachen, die sonst leicht verwechselt werden: Anmeldung
|
||||||
|
scheitert (ssh liefert 255) gegen rsync fehlt am Ziel."""
|
||||||
|
kennung = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}'
|
||||||
|
# BatchMode: keine Passwortabfrage, damit der Aufruf nicht haengt. Der
|
||||||
|
# ssh-agent funktioniert weiterhin. stderr laeuft absichtlich durch, damit
|
||||||
|
# ssh seine eigene Diagnose zeigen kann.
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
ssh_optionen(env).split() + ["-o", "BatchMode=yes", kennung,
|
||||||
|
"command -v rsync || echo KEIN_RSYNC"],
|
||||||
|
stdout=subprocess.PIPE, text=True)
|
||||||
|
if ergebnis.returncode == 255:
|
||||||
|
raise SystemExit(
|
||||||
|
f"FEHLER: Keine SSH-Verbindung zu {kennung} (siehe Meldung oben).\n"
|
||||||
|
f" Haeufigste Ursache: Der oeffentliche Schluessel liegt noch\n"
|
||||||
|
f" nicht auf dem Server. Einmalig:\n"
|
||||||
|
f" ssh-copy-id {kennung}\n"
|
||||||
|
f" Bei passwortgeschuetztem Schluessel: ssh-add einmal "
|
||||||
|
f"ausfuehren.")
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
raise SystemExit(f"FEHLER: ssh {kennung} endete mit Code "
|
||||||
|
f"{ergebnis.returncode}.")
|
||||||
|
if "KEIN_RSYNC" in ergebnis.stdout or not ergebnis.stdout.strip():
|
||||||
|
raise SystemExit(
|
||||||
|
"FEHLER: Auf dem Server ist kein rsync installiert.\n"
|
||||||
|
" Ausweg ohne rsync - nach OR.neu/ hochladen und tauschen;\n"
|
||||||
|
" das entfernt ebenfalls Altes und ist fuer Besucher fast\n"
|
||||||
|
" unterbrechungsfrei:\n"
|
||||||
|
f" scp -r {HTML_NAME}/* {kennung}:{env['OR_ZIEL']}../OR.neu/\n"
|
||||||
|
f' ssh {kennung} '
|
||||||
|
f'"mv OR OR.alt && mv OR.neu OR && rm -rf OR.alt"')
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_aenderungen(ausgabe: str) -> tuple[list[str], list[str]]:
|
||||||
|
"""Trennt die --itemize-changes-Ausgabe in Uebertragungen und Loeschungen."""
|
||||||
|
uebertragen, geloescht = [], []
|
||||||
|
for zeile in ausgabe.splitlines():
|
||||||
|
if zeile.startswith("*deleting"):
|
||||||
|
geloescht.append(zeile.split(None, 1)[-1])
|
||||||
|
elif zeile[:1] in ("<", ">", "c", "h") and " " in zeile:
|
||||||
|
uebertragen.append(zeile.split(None, 1)[-1])
|
||||||
|
return uebertragen, geloescht
|
||||||
|
|
||||||
|
|
||||||
|
def nachpruefung(basis_url: str) -> bool:
|
||||||
|
"""Holt jede Adresse und vergleicht den Statuscode mit der Erwartung."""
|
||||||
|
print("\nNachpruefung:")
|
||||||
|
alles_gut = True
|
||||||
|
for pfad, erwartet in NACHPRUEFUNG:
|
||||||
|
url = basis_url.rstrip("/") + "/" + pfad
|
||||||
|
anfrage = urllib.request.Request(url, method="HEAD")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(anfrage, timeout=20) as antwort:
|
||||||
|
code = antwort.status
|
||||||
|
except urllib.error.HTTPError as fehler:
|
||||||
|
code = fehler.code
|
||||||
|
except OSError as fehler:
|
||||||
|
code = f"{type(fehler).__name__}"
|
||||||
|
passt = code == erwartet
|
||||||
|
alles_gut = alles_gut and passt
|
||||||
|
print(f" {'OK ' if passt else 'FEHLER'} {code!s:>6} "
|
||||||
|
f"(erwartet {erwartet}) /{pfad}")
|
||||||
|
return alles_gut
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
zerleger = argparse.ArgumentParser(
|
||||||
|
description="Laedt OR_HTML_04/ auf den Webserver (rsync ueber SSH).")
|
||||||
|
zerleger.add_argument("--probelauf", action="store_true",
|
||||||
|
help="alles ausser der echten Uebertragung")
|
||||||
|
zerleger.add_argument("--loeschgrenze", type=int, default=LOESCHGRENZE,
|
||||||
|
help=f"Abbruch, wenn mehr Dateien geloescht wuerden "
|
||||||
|
f"(Vorgabe: {LOESCHGRENZE})")
|
||||||
|
zerleger.add_argument("--ohne-vorpruefung", action="store_true",
|
||||||
|
help="Schritt 1 ueberspringen (bewusst alter Stand)")
|
||||||
|
zerleger.add_argument("--ohne-nachpruefung", action="store_true",
|
||||||
|
help="Schritt 4 ueberspringen (kein Netz zum Server)")
|
||||||
|
argumente = zerleger.parse_args()
|
||||||
|
|
||||||
|
env = lies_env()
|
||||||
|
ziel = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{env["OR_ZIEL"]}'
|
||||||
|
print(f"Ziel: {ziel}")
|
||||||
|
|
||||||
|
if not argumente.ohne_vorpruefung:
|
||||||
|
vorpruefung()
|
||||||
|
pruefe_ziel_erreichbar(env)
|
||||||
|
|
||||||
|
# Schritt 2: Immer erst zaehlen. Der Probelauf kostet Sekunden und ist die
|
||||||
|
# einzige Gelegenheit, einen falschen Zielpfad zu bemerken, BEVOR --delete
|
||||||
|
# eine fremde Website ausraeumt.
|
||||||
|
probe = subprocess.run(rsync_befehl(env, probelauf=True),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if probe.returncode != 0:
|
||||||
|
print(probe.stderr, file=sys.stderr)
|
||||||
|
return probe.returncode
|
||||||
|
uebertragen, geloescht = zaehle_aenderungen(probe.stdout)
|
||||||
|
print(f"\nZu uebertragen: {len(uebertragen)} Datei(en)")
|
||||||
|
for name in uebertragen[:15]:
|
||||||
|
print(f" + {name}")
|
||||||
|
if len(uebertragen) > 15:
|
||||||
|
print(f" ... und {len(uebertragen) - 15} weitere")
|
||||||
|
print(f"Zu loeschen: {len(geloescht)} Datei(en)")
|
||||||
|
for name in geloescht:
|
||||||
|
print(f" - {name}")
|
||||||
|
|
||||||
|
if len(geloescht) > argumente.loeschgrenze:
|
||||||
|
print(f"\nFEHLER: {len(geloescht)} Loeschungen ueberschreiten die Grenze "
|
||||||
|
f"von {argumente.loeschgrenze}.\n"
|
||||||
|
f" Das ist meist ein falscher Zielpfad oder ein halb "
|
||||||
|
f"gebautes {HTML_NAME}/.\n"
|
||||||
|
f" Wenn es stimmt: --loeschgrenze {len(geloescht)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if argumente.probelauf:
|
||||||
|
print("\nProbelauf - es wurde nichts uebertragen.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not uebertragen and not geloescht:
|
||||||
|
print("\nNichts zu tun - der Server ist auf dem Stand des Baus.")
|
||||||
|
else:
|
||||||
|
print("\nUebertrage ...")
|
||||||
|
lauf = subprocess.run(rsync_befehl(env, probelauf=False))
|
||||||
|
if lauf.returncode != 0:
|
||||||
|
return lauf.returncode
|
||||||
|
print(f"Fertig: {len(uebertragen)} uebertragen, "
|
||||||
|
f"{len(geloescht)} geloescht.")
|
||||||
|
|
||||||
|
if argumente.ohne_nachpruefung:
|
||||||
|
return 0
|
||||||
|
return 0 if nachpruefung(env["OR_URL"]) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
91
PROGRESS.md
91
PROGRESS.md
|
|
@ -2024,14 +2024,99 @@ Archivs — so können Link und Datei nicht auseinanderlaufen. Das Glossar ersch
|
||||||
dasselbe leisten, und unten in der Gruppe „Anhänge", weil es ein Anhang ist.
|
dasselbe leisten, und unten in der Gruppe „Anhänge", weil es ein Anhang ist.
|
||||||
|
|
||||||
**Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der
|
**Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der
|
||||||
Build schreibt sie nicht (Zeitstempel vom 06.09., während `programme.js` bei jedem Lauf
|
Build schreibt sie nicht. Dasselbe gilt für `site.js`, `icons.svg`, `plotly.min.js` und
|
||||||
neu entsteht). Dasselbe gilt für `site.js`, `icons.svg`, `highlight.css` und
|
`katex/`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
|
||||||
`plotly.min.js`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
|
|
||||||
gepflegt bzw. mitgeliefert — wer `OR_HTML_04/` löscht und neu baut, verliert sie.
|
gepflegt bzw. mitgeliefert — wer `OR_HTML_04/` löscht und neu baut, verliert sie.
|
||||||
|
(Nachtrag: Der nächste Abschnitt räumt das auf. **Und `highlight.css` gehört nicht in diese
|
||||||
|
Liste** — die erzeugt `erzeuge_highlight_css()` aus `pandoc --print-highlight-style`; die
|
||||||
|
erste Fassung dieses Absatzes zählte sie fälschlich mit.)
|
||||||
|
|
||||||
Die alte `anhang-glossar-literatur.html` musste von Hand entfernt werden; der Build räumt
|
Die alte `anhang-glossar-literatur.html` musste von Hand entfernt werden; der Build räumt
|
||||||
verwaiste Seiten nicht ab.
|
verwaiste Seiten nicht ab.
|
||||||
|
|
||||||
|
### ✅ `web_04/` — die statischen Assets bekommen eine Quelle
|
||||||
|
|
||||||
|
Vier Dateien und die 22 KaTeX-Dateien lagen im erzeugten Verzeichnis, obwohl kein Skript sie
|
||||||
|
je geschrieben hat. Die Falle daran: Wer eine CSS-Regel suchte, suchte sie in den Quellen und
|
||||||
|
fand nichts — genau das ist beim Einrücken des ZIP-Menüpunkts passiert. Und wer `OR_HTML_04/`
|
||||||
|
gelöscht und neu gebaut hätte, hätte eine Website ohne Stil, ohne Symbole und ohne
|
||||||
|
Formelsatz bekommen.
|
||||||
|
|
||||||
|
Jetzt liegen sie in `web_04/`, dessen Aufbau (`assets/`, `katex/`) das Ziel spiegelt.
|
||||||
|
`spiegle_statische_assets()` kopiert sie bei **jedem** Lauf; `kopiere_plotly_bibliothek()`
|
||||||
|
füllt die Quelle statt des Ziels; `_lade_icon_sprite_inline()` liest die Quelle, hängt also
|
||||||
|
nicht mehr vom eigenen Ergebnis ab.
|
||||||
|
|
||||||
|
**Zwei Wächter**, weil genau diese Verwechslung schon vorgekommen ist:
|
||||||
|
|
||||||
|
* Wurde die Kopie in `OR_HTML_04/` von Hand geändert (Inhalt weicht ab *und* Zeitstempel ist
|
||||||
|
neuer), **bricht der Bau ab** und nennt den `mv`-Befehl, der es richtigstellt. Kein
|
||||||
|
stilles Überschreiben.
|
||||||
|
* `pruefe_assets()` liest die `href=`/`src=`-Literale aus dem Quelltext des Bauskripts und
|
||||||
|
verlangt für jedes einen Erzeuger — entweder `web_04/` oder die Liste `ERZEUGTE_ASSETS`
|
||||||
|
(`highlight.css`, `search-index.js`, `programme.js`). Ein künftiger Eintrag im Seitenkopf
|
||||||
|
ohne Datei dahinter fällt sofort auf.
|
||||||
|
|
||||||
|
Die Probe aufs Exempel: `rm -rf OR_HTML_04 && --html` baut alle 213 Dateien wieder auf,
|
||||||
|
Dateiliste identisch zur Sicherung.
|
||||||
|
|
||||||
|
### 🐛 Fund: Das Stichwortverzeichnis war nicht byte-reproduzierbar
|
||||||
|
|
||||||
|
Beim Gegenprüfen der Neubau-Probe unterschied sich `stichwortverzeichnis.html` zwischen zwei
|
||||||
|
Läufen, ohne dass sich eine Quelle geändert hatte. Ursache in `ziel_links()`: sortiert wurde
|
||||||
|
nach `(seite, kontext)` — und `kontext` ist der **Kapiteltitel**, für alle Marken einer Datei
|
||||||
|
also derselbe. Bei zwei Fundstellen im selben Kapitel war der Schlüssel gleich, und die
|
||||||
|
Reihenfolge fiel auf die eines `set()` zurück, also auf den je Prozess zufälligen
|
||||||
|
`PYTHONHASHSEED`.
|
||||||
|
|
||||||
|
Derselbe Konstruktionsfehler war auch sichtbar: Vier `{idx:Branch-and-Bound}` in Kapitel 6
|
||||||
|
ergaben **vier optisch identische Links** nebeneinander — zehn Einträge im Register waren
|
||||||
|
davon betroffen. Behoben durch einen Link je Kapitel (die erste Fundstelle in
|
||||||
|
Dokumentreihenfolge, `dict` statt `set`). Damit ist die Sortierung zugleich eindeutig.
|
||||||
|
|
||||||
|
Gegenprobe: drei Läufe mit `PYTHONHASHSEED=random` liefern dieselbe Prüfsumme
|
||||||
|
`3da5b9b2…`. Keine Hauptzeile trägt mehr einen doppelten Link; die 219 Fachbegriffe und 328
|
||||||
|
Indexmarken bleiben unverändert.
|
||||||
|
|
||||||
|
### ✅ `veroeffentliche_04.py` — der Upload wird nachprüfbar
|
||||||
|
|
||||||
|
Der Anlass war messbar. Der Live-Stand vor dem Skript:
|
||||||
|
|
||||||
|
```
|
||||||
|
/OR/ 200
|
||||||
|
/OR/anhang-glossar-literatur.html 200 <- Leiche des vorigen Stands
|
||||||
|
/OR/anhang-glossar.html 404
|
||||||
|
/OR/notebooks.html 404
|
||||||
|
/OR/Notebooks_04.zip 404
|
||||||
|
/OR/Notebooks_04/ 403
|
||||||
|
```
|
||||||
|
|
||||||
|
**`scp -r` überschreibt nur, es löscht nie.** Die alte Glossar-Seite bliebe nach einem
|
||||||
|
`scp`-Upload dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren wäre die grobe
|
||||||
|
Antwort: Bricht die Übertragung von 23 MB in 213 Dateien ab, ist die Website tot.
|
||||||
|
`rsync --delete` überträgt nur Geändertes und entfernt genau das, was verschwunden ist.
|
||||||
|
|
||||||
|
Der zweite Grund wiegt schwerer: **Die Website hing wochenlang einen Bau zurück**, ohne dass
|
||||||
|
es auffiel. Deshalb prüft das Skript vorher, ob eine Quelldatei neuer ist als die gebaute
|
||||||
|
Website, ob das PDF der Website byte-genau dem gebauten entspricht und ob die
|
||||||
|
Verzeichnis-Weiterleitungen vorhanden sind (der Grund für den 403). Und es prüft
|
||||||
|
**hinterher per HTTP** vierzehn Adressen nach — einschließlich der Probe, dass die entfernte
|
||||||
|
Seite wirklich 404 liefert.
|
||||||
|
|
||||||
|
Dazwischen ein Probelauf, der zählt: Mehr als 30 Löschungen brechen ab. Das ist der Schutz
|
||||||
|
gegen einen falschen Zielpfad — mit `--delete` räumt ein Tippfehler sonst ein fremdes
|
||||||
|
Verzeichnis aus.
|
||||||
|
|
||||||
|
**Anmeldung per SSH-Schlüssel, nicht per Passwort in der `.env`.** `rsync` und `scp` haben
|
||||||
|
keine Passwort-Option; ein Passwort bräuchte `sshpass` und stünde während der Übertragung in
|
||||||
|
der Prozessliste. Die `.env` (in `.gitignore`, Vorlage `.env.beispiel`) enthält deshalb nur
|
||||||
|
Host, Benutzer, Zielpfad und URL.
|
||||||
|
|
||||||
|
**Beim ersten Test lief die Prüfung in eine irreführende Meldung:** „Auf dem Server ist kein
|
||||||
|
rsync installiert" — tatsächlich schlug schon die Anmeldung fehl, weil der öffentliche
|
||||||
|
Schlüssel noch nicht auf `jamulix.de` liegt. Die Prüfung unterscheidet die Fälle jetzt am
|
||||||
|
ssh-Rückgabewert 255 und nennt `ssh-copy-id`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Commit-Historie des V04-Strangs
|
## 8. Commit-Historie des V04-Strangs
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -115,6 +115,7 @@ pip install -r Operations_Research_mit_Python_Version_04_Programme/requirements.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte |
|
| `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte |
|
||||||
| `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren |
|
| `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren |
|
||||||
|
| `web_04/` | **Quelle**: `site.css`, `site.js`, `icons.svg`, `plotly.min.js`, `katex/` — die statischen Bestandteile der Website |
|
||||||
| `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben |
|
| `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben |
|
||||||
| `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image |
|
| `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image |
|
||||||
| `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus |
|
| `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus |
|
||||||
|
|
@ -166,8 +167,9 @@ Prüfung hinterlassen.
|
||||||
| `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab |
|
| `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab |
|
||||||
| `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig |
|
| `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig |
|
||||||
|
|
||||||
**`OR_HTML_04/katex/`** ist externes Material und wird von keinem Skript erzeugt. Fehlt es,
|
**`web_04/katex/`** ist externes Material und wird von keinem Skript erzeugt — es liegt
|
||||||
bleiben die Formeln auf der Website ungesetzt.
|
deshalb als Quelle im Repository und wird bei jedem Bau nach `OR_HTML_04/katex/` kopiert.
|
||||||
|
Fehlt es, bleiben die Formeln auf der Website ungesetzt.
|
||||||
|
|
||||||
**Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie
|
**Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie
|
||||||
der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf
|
der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf
|
||||||
|
|
@ -177,6 +179,46 @@ Benutzerdatei zurück, falls sie fehlt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Veröffentlichen
|
||||||
|
|
||||||
|
`OR_HTML_04/` ist selbstgenügsam: Der Ordner lässt sich direkt auf einen Webserver kopieren.
|
||||||
|
Für den regelmäßigen Fall gibt es ein Skript.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# einmalig: den eigenen SSH-Schlüssel auf den Server legen
|
||||||
|
ssh-copy-id benutzer@example.org
|
||||||
|
|
||||||
|
# einmalig: Ziel eintragen
|
||||||
|
cp .env.beispiel .env && chmod 600 .env # dann .env ausfüllen
|
||||||
|
|
||||||
|
# jedes Mal
|
||||||
|
python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript benutzt **`rsync --delete`, nicht `scp`** — und das ist der entscheidende
|
||||||
|
Unterschied: `scp` überschreibt nur, es entfernt nie. Eine Seite, die aus dem Buch
|
||||||
|
verschwindet, bliebe sonst dauerhaft online erreichbar. `rsync` überträgt zudem nur, was sich
|
||||||
|
geändert hat; ein zweiter Lauf direkt danach überträgt nichts.
|
||||||
|
|
||||||
|
Vier Schritte, jeder mit einer eigenen Aufgabe:
|
||||||
|
|
||||||
|
1. **Vorprüfung.** Ist eine Quelldatei neuer als die gebaute Website, bricht das Skript ab —
|
||||||
|
ein alter Stand soll gar nicht erst hochgeladen werden. Ebenso geprüft: dass das PDF der
|
||||||
|
Website byte-genau dem gebauten entspricht und dass die Verzeichnis-Weiterleitungen da
|
||||||
|
sind.
|
||||||
|
2. **Probelauf.** Zählt und zeigt, was übertragen und was gelöscht würde. Über 30 Löschungen
|
||||||
|
ist der Lauf verdächtig (falscher Zielpfad, halb gebautes Verzeichnis) und bricht ab;
|
||||||
|
`--loeschgrenze N` hebt die Grenze an.
|
||||||
|
3. **Übertragung.**
|
||||||
|
4. **Nachprüfung per HTTP.** Vierzehn Adressen werden abgerufen und ihr Statuscode mit der
|
||||||
|
Erwartung verglichen — einschließlich der Probe, dass eine entfernte Seite wirklich 404
|
||||||
|
liefert.
|
||||||
|
|
||||||
|
`--probelauf` führt nur die Schritte 1 und 2 aus. In der `.env` steht **kein Geheimnis**:
|
||||||
|
`rsync` und `scp` haben keine Passwort-Option, die Anmeldung läuft über den SSH-Schlüssel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notebooks und Google Colab
|
## Notebooks und Google Colab
|
||||||
|
|
||||||
Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum
|
Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum
|
||||||
|
|
|
||||||
41
web_04/assets/icons.svg
Normal file
41
web_04/assets/icons.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||||
|
<symbol id="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.2" y2="16.2"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="4.5"/>
|
||||||
|
<line x1="12" y1="1.5" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22.5"/>
|
||||||
|
<line x1="1.5" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22.5" y2="12"/>
|
||||||
|
<line x1="4.5" y1="4.5" x2="6.2" y2="6.2"/><line x1="17.8" y1="17.8" x2="19.5" y2="19.5"/>
|
||||||
|
<line x1="19.5" y1="4.5" x2="17.8" y2="6.2"/><line x1="6.2" y1="17.8" x2="4.5" y2="19.5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 4 7 12 15 20"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 4 17 12 9 20"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="4 13 9.5 18.5 20 6"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 13.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V7A1.5 1.5 0 0 1 5 5.5h5.5"/>
|
||||||
|
<polyline points="14.5 3.5 20.5 3.5 20.5 9.5"/><line x1="11" y1="13" x2="20" y2="4"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 5.5A2 2 0 0 1 6 4h6v16H6a2 2 0 0 0-2 1.5z"/>
|
||||||
|
<path d="M20 5.5A2 2 0 0 0 18 4h-6v16h6a2 2 0 0 1 2 1.5z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="icon-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><path d="M4 19.5h16"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3 KiB |
3882
web_04/assets/plotly.min.js
vendored
Normal file
3882
web_04/assets/plotly.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
348
web_04/assets/site.css
Normal file
348
web_04/assets/site.css
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
/* site.css — Design-System der Mehrseiten-Website "Operations Research mit
|
||||||
|
Python". Struktur (CSS-Variablen, .dark-Umschaltung, kuehle Graustufen)
|
||||||
|
nach dem Vorbild https://jamulix.de/sorting/, Farben aus der bestehenden
|
||||||
|
Buchpalette (Titelseite/PDF: Indigo/Cyan/Violett/Gruen/Amber). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f6fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface2: #eef0f5;
|
||||||
|
--border: #d9dce6;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--muted: #5c6380;
|
||||||
|
--accent: #4338ca;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--cyan: #0891b2;
|
||||||
|
--violett: #7c3aed;
|
||||||
|
--emerald: #059669;
|
||||||
|
--amber: #b45309;
|
||||||
|
--rose: #e11d48;
|
||||||
|
--tint-indigo: #eef2ff;
|
||||||
|
--tint-violett: #f5f3ff;
|
||||||
|
--tint-cyan: #ecfeff;
|
||||||
|
--tint-emerald: #ecfdf5;
|
||||||
|
--tint-amber: #fffbeb;
|
||||||
|
--code-bg: #1e293b;
|
||||||
|
--code-text: #e6edf3;
|
||||||
|
--shadow: 0 2px 10px rgba(26, 26, 46, .07);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(26, 26, 46, .12);
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #0e0f1a;
|
||||||
|
--surface: #161826;
|
||||||
|
--surface2: #1e2133;
|
||||||
|
--border: #2b2f45;
|
||||||
|
--text: #e7e9f5;
|
||||||
|
--muted: #9aa1c4;
|
||||||
|
--accent: #818cf8;
|
||||||
|
--accent-text: #0e0f1a;
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--violett: #c4b5fd;
|
||||||
|
--emerald: #34d399;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--rose: #fb7185;
|
||||||
|
--tint-indigo: #1b1f3a;
|
||||||
|
--tint-violett: #241f3d;
|
||||||
|
--tint-cyan: #132833;
|
||||||
|
--tint-emerald: #10291f;
|
||||||
|
--tint-amber: #2c2410;
|
||||||
|
--code-bg: #0b0d14;
|
||||||
|
--code-text: #dbe4f0;
|
||||||
|
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0e0f1a;
|
||||||
|
--surface: #161826;
|
||||||
|
--surface2: #1e2133;
|
||||||
|
--border: #2b2f45;
|
||||||
|
--text: #e7e9f5;
|
||||||
|
--muted: #9aa1c4;
|
||||||
|
--accent: #818cf8;
|
||||||
|
--accent-text: #0e0f1a;
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--violett: #c4b5fd;
|
||||||
|
--emerald: #34d399;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--rose: #fb7185;
|
||||||
|
--tint-indigo: #1b1f3a;
|
||||||
|
--tint-violett: #241f3d;
|
||||||
|
--tint-cyan: #132833;
|
||||||
|
--tint-emerald: #10291f;
|
||||||
|
--tint-amber: #2c2410;
|
||||||
|
--code-bg: #0b0d14;
|
||||||
|
--code-text: #dbe4f0;
|
||||||
|
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--text);
|
||||||
|
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "DejaVu Sans", Arial, sans-serif;
|
||||||
|
line-height: 1.7; font-size: 16px; -webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
.icon { width: 1.15em; height: 1.15em; flex: none; vertical-align: -.2em; }
|
||||||
|
|
||||||
|
/* --- Kopfzeile --- */
|
||||||
|
.site-header {
|
||||||
|
position: sticky; top: 0; z-index: 30; display: flex; align-items: center;
|
||||||
|
gap: .75rem; padding: .6rem 1rem; background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.2rem; height: 2.2rem; border-radius: 8px; border: 1px solid transparent;
|
||||||
|
background: transparent; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--surface2); border-color: var(--border); }
|
||||||
|
.brand {
|
||||||
|
display: flex; align-items: center; gap: .5rem; color: var(--accent);
|
||||||
|
font-weight: 700; text-decoration: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.brand span { display: none; }
|
||||||
|
@media (min-width: 900px) { .brand span { display: inline; } }
|
||||||
|
.site-search { position: relative; flex: 1; max-width: 420px; margin: 0 auto; }
|
||||||
|
.site-search input {
|
||||||
|
width: 100%; padding: .5rem .8rem .5rem 2.2rem; border-radius: 20px;
|
||||||
|
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
.site-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.such-icon { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); color: var(--muted); }
|
||||||
|
.suche-ergebnisse {
|
||||||
|
position: absolute; top: calc(100% + .4rem); left: 0; right: 0; max-height: 60vh;
|
||||||
|
overflow-y: auto; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow-lg); z-index: 40;
|
||||||
|
}
|
||||||
|
.suche-ergebnisse a {
|
||||||
|
display: block; padding: .55rem .9rem; color: var(--text); text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.suche-ergebnisse a:last-child { border-bottom: none; }
|
||||||
|
.suche-ergebnisse a:hover, .suche-ergebnisse a.aktiv { background: var(--tint-indigo); }
|
||||||
|
.suche-ergebnisse small { display: block; color: var(--muted); font-weight: 400; }
|
||||||
|
.suche-leer { padding: .8rem .9rem; color: var(--muted); }
|
||||||
|
#theme-toggle .icon-moon { display: none; }
|
||||||
|
:root[data-theme="dark"] #theme-toggle .icon-sun { display: none; }
|
||||||
|
:root[data-theme="dark"] #theme-toggle .icon-moon { display: inline; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) #theme-toggle .icon-sun { display: none; }
|
||||||
|
:root:not([data-theme="light"]) #theme-toggle .icon-moon { display: inline; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Layout: Sidebar + Inhalt --- */
|
||||||
|
.site-body { display: flex; align-items: flex-start; max-width: 1400px; margin: 0 auto; }
|
||||||
|
.sidebar {
|
||||||
|
position: sticky; top: 3.6rem; width: 290px; flex: none;
|
||||||
|
height: calc(100vh - 3.6rem); overflow-y: auto; border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface); padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.sidebar-gruppe { border-bottom: 1px solid var(--border); }
|
||||||
|
.sidebar-gruppe summary {
|
||||||
|
cursor: pointer; padding: .6rem 1rem; font-weight: 700; color: var(--accent);
|
||||||
|
list-style: none; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.sidebar-gruppe summary::-webkit-details-marker { display: none; }
|
||||||
|
.sidebar-gruppe summary::after { content: "▾"; color: var(--muted); font-size: .8em; }
|
||||||
|
.sidebar-gruppe[open] summary::after { content: "▴"; }
|
||||||
|
.sidebar ul { list-style: none; margin: 0; padding: 0 .5rem .5rem; }
|
||||||
|
.sidebar li a {
|
||||||
|
display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem;
|
||||||
|
border-radius: 8px; color: var(--text); text-decoration: none; font-size: .92rem;
|
||||||
|
}
|
||||||
|
.sidebar li a:hover { background: var(--tint-indigo); }
|
||||||
|
.sidebar li.aktiv a { background: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.fortschritt-haken { width: 1em; height: 1em; color: var(--emerald); visibility: hidden; }
|
||||||
|
.fortschritt-haken .icon { width: 100%; height: 100%; }
|
||||||
|
li[data-erledigt="1"] .fortschritt-haken { visibility: visible; }
|
||||||
|
.sidebar-extra { border-top: 2px solid var(--border); margin-top: .5rem; padding-top: .8rem !important; }
|
||||||
|
/* Unterpunkt in der festen Menueliste (Download-ZIP unter Notebooks): eingerueckt und etwas kleiner, ohne echte Listenverschachtelung. */
|
||||||
|
.sidebar-unterpunkt a { padding-left: 1.9rem !important; font-size: .92em; }
|
||||||
|
.sidebar-overlay { display: none; }
|
||||||
|
|
||||||
|
.content { flex: 1; min-width: 0; padding: 1.5rem 2rem 5rem; max-width: 900px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* --- Breadcrumb, Prev/Next --- */
|
||||||
|
.breadcrumb { font-size: .85rem; color: var(--muted); margin-bottom: .8rem; }
|
||||||
|
.breadcrumb a { color: var(--muted); }
|
||||||
|
.prev-next { display: flex; justify-content: space-between; gap: 1rem; margin: 1.2rem 0; }
|
||||||
|
.prev-next-knopf {
|
||||||
|
display: flex; align-items: center; gap: .6rem; flex: 1; max-width: 48%;
|
||||||
|
padding: .7rem 1rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--surface); color: var(--text); text-decoration: none; font-size: .9rem;
|
||||||
|
box-shadow: var(--shadow); transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.prev-next-knopf:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
|
.prev-next-next { margin-left: auto; text-align: right; flex-direction: row-reverse; }
|
||||||
|
.prev-next-knopf small { display: block; color: var(--muted); font-weight: 400; }
|
||||||
|
.prev-next-leer { flex: 1; max-width: 48%; }
|
||||||
|
|
||||||
|
.fortschritt-knopf {
|
||||||
|
display: inline-flex; align-items: center; gap: .5rem; margin: 1rem 0;
|
||||||
|
padding: .55rem 1rem; border-radius: 20px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface2); color: var(--text); cursor: pointer; font: inherit; font-size: .88rem;
|
||||||
|
}
|
||||||
|
.fortschritt-knopf .icon { color: var(--muted); }
|
||||||
|
.fortschritt-knopf.erledigt { background: var(--tint-emerald); border-color: var(--emerald); color: var(--emerald); }
|
||||||
|
.fortschritt-knopf.erledigt .icon { color: var(--emerald); }
|
||||||
|
|
||||||
|
.site-footer { text-align: center; padding: 1.5rem; color: var(--muted); font-size: .85rem; border-top: 1px solid var(--border); }
|
||||||
|
.site-footer a { color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- Typografie im Artikel (angelehnt an bilder/or_html_style.html) --- */
|
||||||
|
article h1, article h2, article h3, article h4, article h5 {
|
||||||
|
line-height: 1.25; font-weight: 700; margin-top: 2.1em; margin-bottom: .6em; scroll-margin-top: 4.5rem;
|
||||||
|
}
|
||||||
|
article h1 { color: var(--accent); font-size: 1.9rem; border-bottom: 3px solid var(--accent); padding-bottom: .25em; }
|
||||||
|
article h2 { color: var(--accent); font-size: 1.45rem; border-bottom: 2px solid var(--border); padding-bottom: .2em; }
|
||||||
|
article h3 { color: var(--cyan); font-size: 1.18rem; }
|
||||||
|
article h4 { color: var(--violett); font-size: 1.05rem; }
|
||||||
|
article h2[id$="-lernziele"]::before { content: "🎯 "; }
|
||||||
|
article h2[id$="-uebungsaufgaben"]::before { content: "📝 "; }
|
||||||
|
article h2[id$="-selbsttest"]::before { content: "✅ "; }
|
||||||
|
article h2[id$="-zusammenfassung"]::before { content: "📌 "; }
|
||||||
|
article a { color: var(--accent); text-decoration: none; }
|
||||||
|
article a:hover { text-decoration: underline; }
|
||||||
|
article a[target="_blank"]::after { content: " \2197"; font-size: .82em; color: var(--cyan); text-decoration: none; }
|
||||||
|
article p { margin: .75em 0; }
|
||||||
|
article hr { border: 0; border-top: 1px solid var(--border); margin: 2.2em 0; }
|
||||||
|
article figure { margin: 2em 0; text-align: center; }
|
||||||
|
article img { max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 10px; }
|
||||||
|
article figure img { box-shadow: var(--shadow-lg); }
|
||||||
|
article figcaption { color: var(--muted); font-size: .9rem; font-style: italic; margin-top: .6em; }
|
||||||
|
article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .94rem; box-shadow: var(--shadow); border-radius: 8px; overflow: hidden; }
|
||||||
|
article th { background: var(--accent); color: var(--accent-text); font-weight: 700; text-align: left; }
|
||||||
|
article th, article td { padding: .55em .75em; border: 1px solid var(--border); vertical-align: top; }
|
||||||
|
article tbody tr:nth-child(even) { background: var(--tint-indigo); }
|
||||||
|
article code { font-family: "DejaVu Sans Mono", Consolas, Menlo, monospace; font-size: .9em; background: var(--tint-violett); color: var(--violett); padding: .12em .4em; border-radius: 5px; }
|
||||||
|
article pre { background: var(--code-bg); color: var(--code-text); padding: 1.1em 1.2em; border-radius: 10px; overflow-x: auto; line-height: 1.5; font-size: .85rem; box-shadow: var(--shadow-lg); }
|
||||||
|
article pre code { background: none; color: inherit; padding: 0; font-size: inherit; }
|
||||||
|
article div.sourceCode { position: relative; }
|
||||||
|
article div.sourceCode pre.sourceCode { padding-top: 2.6em; }
|
||||||
|
article blockquote { border-left: 4px solid var(--violett); background: var(--tint-violett); margin: 1.4em 0; padding: .6em 1.2em; border-radius: 0 8px 8px 0; }
|
||||||
|
article ul, article ol { padding-left: 1.4em; }
|
||||||
|
article li { margin: .3em 0; }
|
||||||
|
.katex-display { overflow-x: auto; overflow-y: hidden; padding: .4em 0; }
|
||||||
|
|
||||||
|
/* --- Code-Buttons (Kopieren / Herunterladen) --- */
|
||||||
|
.code-btn-leiste { position: absolute; top: .55em; right: .55em; z-index: 2; display: flex; gap: .4em; }
|
||||||
|
.code-btn, .download-link {
|
||||||
|
display: inline-flex; align-items: center; gap: .35em; background: rgba(255,255,255,.08);
|
||||||
|
color: #cbd5e1; border: 1px solid rgba(255,255,255,.18); border-radius: 6px;
|
||||||
|
padding: .3em .65em; font: inherit; font-size: .78rem; cursor: pointer;
|
||||||
|
text-decoration: none !important; transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.code-btn:hover, .download-link:hover { background: rgba(255,255,255,.18); color: #fff; }
|
||||||
|
.code-btn.copied { background: var(--emerald); color: #fff; border-color: var(--emerald); }
|
||||||
|
.download-link { position: static; margin-left: .6em; }
|
||||||
|
|
||||||
|
/* --- Karten (Kapitel auf einen Blick, Lernpfade, Kapitelraster) --- */
|
||||||
|
.card { border-radius: var(--radius); padding: 1.1em 1.4em; margin: 1.6em 0; box-shadow: var(--shadow); }
|
||||||
|
.card p, .card blockquote { margin: .5em 0 !important; border: none !important; background: none !important; padding: 0 !important; }
|
||||||
|
.card-blick { background: var(--tint-indigo); border: 1px solid var(--accent); }
|
||||||
|
.card-blick blockquote { border-left: 3px solid var(--accent); padding-left: .8em !important; }
|
||||||
|
|
||||||
|
/* --- Didaktische Karten der Version 04 ---------------------------------
|
||||||
|
Jede Kartenart traegt eine eigene Leitfarbe aus der Buchpalette, damit
|
||||||
|
Kursteilnehmer beim Blaettern sofort erkennen, was sie vor sich haben:
|
||||||
|
gruen = sofort ausprobieren, cyan = Formel im Klartext, violett = Excel,
|
||||||
|
amber = Selbstkontrolle, rose = hier steckt ein Fehler drin. Die
|
||||||
|
Leitfarbe liegt links als 5px-Balken, damit sie auch im Ausdruck und in
|
||||||
|
der Druckvorschau erkennbar bleibt. */
|
||||||
|
.card-schnellstart,
|
||||||
|
.card-formel,
|
||||||
|
.card-excel,
|
||||||
|
.card-quiz,
|
||||||
|
.card-denkfehler { border: 1px solid var(--border); border-left-width: 5px; }
|
||||||
|
|
||||||
|
.card-schnellstart { background: var(--tint-emerald); border-left-color: var(--emerald); }
|
||||||
|
.card-formel { background: var(--tint-cyan); border-left-color: var(--cyan); }
|
||||||
|
.card-excel { background: var(--tint-violett); border-left-color: var(--violett); }
|
||||||
|
.card-quiz { background: var(--tint-amber); border-left-color: var(--amber); }
|
||||||
|
.card-denkfehler { background: var(--tint-amber); border-left-color: var(--rose); }
|
||||||
|
|
||||||
|
/* Die Karten enthalten haeufig Tabellen (Formel-Uebersetzer) und
|
||||||
|
Codebloecke (Schnellstart, Denkfehler). Beide sollen sich klar vom
|
||||||
|
getoenten Kartengrund abheben statt darin zu verschwimmen. */
|
||||||
|
.card-schnellstart pre,
|
||||||
|
.card-denkfehler pre { margin: .8em 0 !important; }
|
||||||
|
.card-formel table { margin: .6em 0; background: var(--surface); }
|
||||||
|
.card-formel td:first-child { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* --- Interaktive Plotly-Figuren ---------------------------------------
|
||||||
|
Stehen im Text unmittelbar hinter der statischen Fassung (die im PDF
|
||||||
|
gedruckt wird). Auf schmalen Displays darf die Figur horizontal
|
||||||
|
scrollen, statt die Seite selbst breit zu machen. */
|
||||||
|
.plotly-figur { margin: 1.4em 0; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: .6em; overflow-x: auto; }
|
||||||
|
.plotly-figur .plotly-graph-div { min-width: 320px; }
|
||||||
|
|
||||||
|
/* Der Formel-Uebersetzer ist zweispaltig gedacht: links die Mathematik,
|
||||||
|
rechts der Klartext. Auf schmalen Displays wuerde die Formelspalte die
|
||||||
|
Tabelle sonst aus dem Viewport druecken. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.card-formel td:first-child { white-space: normal; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kartenraster { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem; margin: 1rem 0 2rem; }
|
||||||
|
.karte { display: block; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem; box-shadow: var(--shadow); text-decoration: none; color: var(--text); }
|
||||||
|
.karte:hover { border-color: var(--accent); transform: translateY(-2px); transition: transform .15s, border-color .15s; }
|
||||||
|
.karte h3 { margin: .3em 0; color: var(--text); font-size: 1.05rem; }
|
||||||
|
.karte p { color: var(--muted); font-size: .88rem; margin: .4em 0; }
|
||||||
|
.karte-nummer { font-size: .78rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
.karte-zeit { font-size: .8rem; color: var(--muted); }
|
||||||
|
.karte-pfad { background: var(--tint-violett); border-color: var(--violett); }
|
||||||
|
.karte-einstieg { background: var(--tint-cyan); border-color: var(--cyan); }
|
||||||
|
.kapitelgruppe { margin: 2.5rem 0; }
|
||||||
|
.kapitelgruppe h2 { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .3em; }
|
||||||
|
.lernpfade h2 { color: var(--violett); }
|
||||||
|
|
||||||
|
/* --- Landing-Page Hero --- */
|
||||||
|
.hero { margin: 1rem 0 2.5rem; }
|
||||||
|
.hero-kicker { color: var(--cyan); letter-spacing: .12em; font-weight: 700; font-size: .85rem; }
|
||||||
|
.hero-text h1 { font-size: 2.1rem; color: var(--accent); margin: .2em 0; }
|
||||||
|
.hero-untertitel { color: var(--muted); font-size: 1.05rem; }
|
||||||
|
.hero-badges { display: flex; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
|
.hero-kompendium { color: var(--muted); margin-top: 1.4rem; max-width: 640px; }
|
||||||
|
.hero-autor { color: var(--muted); font-size: .92rem; line-height: 1.5; }
|
||||||
|
.badge { padding: .35em .9em; border-radius: 20px; color: #fff; font-weight: 600; font-size: .85rem; }
|
||||||
|
.badge-indigo { background: var(--accent); }
|
||||||
|
.badge-cyan { background: var(--cyan); }
|
||||||
|
.badge-violett { background: var(--violett); }
|
||||||
|
.badge-emerald { background: var(--emerald); }
|
||||||
|
|
||||||
|
/* --- Programme-Seite --- */
|
||||||
|
.programmgruppe { margin: 2rem 0; }
|
||||||
|
.programmliste { list-style: none; padding: 0; }
|
||||||
|
.programmliste li { display: flex; align-items: center; justify-content: space-between; padding: .5em .8em; border: 1px solid var(--border); border-radius: 8px; margin: .4em 0; background: var(--surface); }
|
||||||
|
|
||||||
|
/* --- Stichwortverzeichnis --- */
|
||||||
|
.register-sprungleiste { display: flex; flex-wrap: wrap; gap: .3rem; margin: 1.2rem 0 1.8rem; position: sticky; top: 3.6rem; background: var(--bg); padding: .5rem 0; z-index: 5; }
|
||||||
|
.register-sprungleiste a { display: inline-flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; border-radius: 6px; background: var(--surface2); color: var(--accent); text-decoration: none; font-weight: 700; font-size: .85rem; }
|
||||||
|
.register-sprungleiste a:hover { background: var(--tint-indigo); }
|
||||||
|
.register-buchstabe { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .15em; margin-top: 2em; font-size: 1.3rem; }
|
||||||
|
.register-eintrag { padding: .55em 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.register-begriff { font-weight: 700; }
|
||||||
|
.register-eintrag a { font-size: .92rem; }
|
||||||
|
.register-unter { list-style: none; margin: .4em 0 0; padding-left: 1.4em; }
|
||||||
|
.register-unter li { margin: .3em 0; color: var(--muted); font-size: .92rem; }
|
||||||
|
.register-unter a { font-size: inherit; }
|
||||||
|
|
||||||
|
/* --- Mobile --- */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: 0; left: 0; height: 100vh; z-index: 50; transform: translateX(-105%);
|
||||||
|
transition: transform .2s ease; box-shadow: var(--shadow-lg); padding-top: 3.6rem;
|
||||||
|
}
|
||||||
|
.sidebar.offen { transform: translateX(0); }
|
||||||
|
.sidebar-overlay.offen { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 40; }
|
||||||
|
.content { padding: 1.2rem 1rem 4rem; }
|
||||||
|
.site-search { max-width: none; }
|
||||||
|
}
|
||||||
211
web_04/assets/site.js
Normal file
211
web_04/assets/site.js
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
// site.js — Interaktivität der Mehrseiten-Website "Operations Research mit
|
||||||
|
// Python": Dark-Mode-Toggle, mobile Sidebar, Volltextsuche, Lernfortschritt,
|
||||||
|
// Code-Kopieren/Herunterladen. Reines Vanilla-JS, keine Abhaengigkeiten.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var LS_THEME = "or-theme";
|
||||||
|
var LS_FORTSCHRITT = "or-fortschritt";
|
||||||
|
|
||||||
|
function ladeFortschritt() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(LS_FORTSCHRITT) || "{}");
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichereFortschritt(daten) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_FORTSCHRITT, JSON.stringify(daten));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dark-Mode-Toggle -------------------------------------------------
|
||||||
|
function initTheme() {
|
||||||
|
var btn = document.getElementById("theme-toggle");
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var aktuell = document.documentElement.getAttribute("data-theme");
|
||||||
|
var dunkelSystem = window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
var istDunkel = aktuell ? aktuell === "dark" : dunkelSystem;
|
||||||
|
var neu = istDunkel ? "light" : "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", neu);
|
||||||
|
try { localStorage.setItem(LS_THEME, neu); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mobile Sidebar -----------------------------------------------------
|
||||||
|
function initSidebar() {
|
||||||
|
var sidebar = document.getElementById("sidebar");
|
||||||
|
var overlay = document.getElementById("sidebar-overlay");
|
||||||
|
var knopf = document.getElementById("sidebar-toggle");
|
||||||
|
if (!sidebar || !overlay || !knopf) return;
|
||||||
|
|
||||||
|
function schliessen() {
|
||||||
|
sidebar.classList.remove("offen");
|
||||||
|
overlay.classList.remove("offen");
|
||||||
|
overlay.hidden = true;
|
||||||
|
}
|
||||||
|
knopf.addEventListener("click", function () {
|
||||||
|
var offen = sidebar.classList.toggle("offen");
|
||||||
|
overlay.hidden = !offen;
|
||||||
|
overlay.classList.toggle("offen", offen);
|
||||||
|
});
|
||||||
|
overlay.addEventListener("click", schliessen);
|
||||||
|
sidebar.querySelectorAll("a").forEach(function (a) {
|
||||||
|
a.addEventListener("click", schliessen);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktuelle Kapitelgruppe aufklappen, falls noch nicht offen.
|
||||||
|
var aktiv = sidebar.querySelector("li.aktiv");
|
||||||
|
if (aktiv) {
|
||||||
|
var details = aktiv.closest("details");
|
||||||
|
if (details) details.open = true;
|
||||||
|
// Nur den Sidebar-eigenen Scrollbereich verschieben, NICHT
|
||||||
|
// aktiv.scrollIntoView() verwenden: das wuerde auch das ganze Fenster
|
||||||
|
// scrollen und einen #anker-Sprung aus der URL (z.B. von einem
|
||||||
|
// Querverweis) wieder zunichtemachen.
|
||||||
|
sidebar.scrollTop = Math.max(0, aktiv.offsetTop - sidebar.clientHeight / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lernfortschritt ------------------------------------------------
|
||||||
|
function initFortschritt() {
|
||||||
|
var daten = ladeFortschritt();
|
||||||
|
document.querySelectorAll('.sidebar li[data-kapitel]').forEach(function (li) {
|
||||||
|
if (daten[li.dataset.kapitel]) li.dataset.erledigt = "1";
|
||||||
|
});
|
||||||
|
var knopf = document.querySelector(".fortschritt-knopf");
|
||||||
|
if (!knopf) return;
|
||||||
|
var kapitel = knopf.dataset.kapitel;
|
||||||
|
function aktualisiereKnopf() {
|
||||||
|
var erledigt = !!daten[kapitel];
|
||||||
|
knopf.classList.toggle("erledigt", erledigt);
|
||||||
|
knopf.querySelector("span").textContent =
|
||||||
|
erledigt ? "Als gelesen markiert" : "Als gelesen markieren";
|
||||||
|
var eintrag = document.querySelector(
|
||||||
|
'.sidebar li[data-kapitel="' + kapitel + '"]');
|
||||||
|
if (eintrag) eintrag.dataset.erledigt = erledigt ? "1" : "0";
|
||||||
|
}
|
||||||
|
aktualisiereKnopf();
|
||||||
|
knopf.addEventListener("click", function () {
|
||||||
|
daten[kapitel] = !daten[kapitel];
|
||||||
|
speichereFortschritt(daten);
|
||||||
|
aktualisiereKnopf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suche ------------------------------------------------------------
|
||||||
|
function initSuche() {
|
||||||
|
var eingabe = document.getElementById("suche-eingabe");
|
||||||
|
var ergebnisBox = document.getElementById("suche-ergebnisse");
|
||||||
|
if (!eingabe || !ergebnisBox) return;
|
||||||
|
// window.OR_SEARCH_INDEX kommt aus assets/search-index.js (als normales
|
||||||
|
// <script> geladen, nicht per fetch()) - fetch() auf lokale Dateien
|
||||||
|
// scheitert unter file:// (Seite ohne Webserver geoeffnet) an CORS.
|
||||||
|
var index = window.OR_SEARCH_INDEX || [];
|
||||||
|
|
||||||
|
function anzeigen(treffer) {
|
||||||
|
if (!treffer.length) {
|
||||||
|
ergebnisBox.innerHTML = '<p class="suche-leer">Keine Treffer.</p>';
|
||||||
|
ergebnisBox.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ergebnisBox.innerHTML = treffer.slice(0, 20).map(function (t) {
|
||||||
|
var href = t.seite + (t.anker ? "#" + t.anker : "");
|
||||||
|
var snippet = t.text ? "<small>" + t.text + "</small>" : "";
|
||||||
|
return '<a href="' + href + '">' + t.titel + snippet + "</a>";
|
||||||
|
}).join("");
|
||||||
|
ergebnisBox.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
eingabe.addEventListener("input", function () {
|
||||||
|
var q = eingabe.value.trim().toLowerCase();
|
||||||
|
if (q.length < 2) { ergebnisBox.hidden = true; return; }
|
||||||
|
var treffer = index.filter(function (e) {
|
||||||
|
return (e.titel && e.titel.toLowerCase().indexOf(q) !== -1) ||
|
||||||
|
(e.text && e.text.toLowerCase().indexOf(q) !== -1);
|
||||||
|
});
|
||||||
|
anzeigen(treffer);
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
if (!ergebnisBox.contains(ev.target) && ev.target !== eingabe) {
|
||||||
|
ergebnisBox.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
eingabe.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Escape") { ergebnisBox.hidden = true; eingabe.blur(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Code: Kopieren-Button (+ Herunterladen, falls Programmdatei bekannt) --
|
||||||
|
function initCodeButtons() {
|
||||||
|
// window.OR_PROGRAMME kommt aus assets/programme.js (normales <script>,
|
||||||
|
// kein fetch() - siehe initSuche()).
|
||||||
|
var programmListe = window.OR_PROGRAMME || [];
|
||||||
|
|
||||||
|
document.querySelectorAll("div.sourceCode").forEach(function (box) {
|
||||||
|
var code = box.querySelector("code");
|
||||||
|
if (!code) return;
|
||||||
|
var leiste = document.createElement("div");
|
||||||
|
leiste.className = "code-btn-leiste";
|
||||||
|
|
||||||
|
var kopieren = document.createElement("button");
|
||||||
|
kopieren.type = "button";
|
||||||
|
kopieren.className = "code-btn";
|
||||||
|
kopieren.setAttribute("aria-label", "Code kopieren");
|
||||||
|
kopieren.innerHTML =
|
||||||
|
'<svg class="icon"><use href="#icon-copy"></use></svg><span>Kopieren</span>';
|
||||||
|
kopieren.addEventListener("click", function () {
|
||||||
|
navigator.clipboard.writeText(code.innerText).then(function () {
|
||||||
|
var label = kopieren.querySelector("span");
|
||||||
|
var original = label.textContent;
|
||||||
|
kopieren.classList.add("copied");
|
||||||
|
label.textContent = "Kopiert!";
|
||||||
|
setTimeout(function () {
|
||||||
|
kopieren.classList.remove("copied");
|
||||||
|
label.textContent = original;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
leiste.appendChild(kopieren);
|
||||||
|
box.appendChild(leiste);
|
||||||
|
|
||||||
|
// Erste Codezeile "# Name.py" (nach Shebang) -> Download-Button, wenn
|
||||||
|
// eine passende Datei unter programme/ existiert (Programme.py werden
|
||||||
|
// beim Bau nach OR_HTML/programme/ kopiert, siehe build_version_03.py).
|
||||||
|
var text = code.innerText;
|
||||||
|
var treffer = text.match(/^#\s*([A-Za-z0-9_]+\.py)\s*$/m);
|
||||||
|
if (treffer && programmListe.indexOf(treffer[1]) !== -1) {
|
||||||
|
var link = document.createElement("a");
|
||||||
|
link.className = "code-btn";
|
||||||
|
link.href = "programme/" + treffer[1];
|
||||||
|
link.setAttribute("download", "");
|
||||||
|
link.innerHTML =
|
||||||
|
'<svg class="icon"><use href="#icon-download"></use></svg><span>Herunterladen</span>';
|
||||||
|
leiste.appendChild(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initTheme();
|
||||||
|
initSidebar();
|
||||||
|
initFortschritt();
|
||||||
|
initSuche();
|
||||||
|
initCodeButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bilder ohne width/height-Attribut (siehe Kapitelseiten) reflowen den
|
||||||
|
// Text erst, wenn sie fertig geladen sind - das passiert erst NACH
|
||||||
|
// DOMContentLoaded. Ein #anker-Sprung aus der URL (Querverweis, Such-
|
||||||
|
// treffer, Stichwortverzeichnis) kann dadurch zu weit oben landen. Nach
|
||||||
|
// vollstaendigem Laden (inkl. Bilder) hier zur Sicherheit erneut hinspringen.
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
if (!location.hash) return;
|
||||||
|
var ziel = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||||
|
if (ziel) ziel.scrollIntoView({ behavior: "instant", block: "start" });
|
||||||
|
});
|
||||||
|
})();
|
||||||
BIN
web_04/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
Binary file not shown.
BIN
web_04/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
BIN
web_04/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
Binary file not shown.
1
web_04/katex/katex.min.css
vendored
Normal file
1
web_04/katex/katex.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web_04/katex/katex.min.js
vendored
Normal file
1
web_04/katex/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue