Notebook-Uebersichtsseite - das fehlende Gegenstueck zu programme.html

Der 403 auf /OR/Notebooks_04/ hatte eine andere Ursache als vermutet: Beim
Eintragen der Website-Adresse ins README hatte ich in der Lesen-Tabelle das
VERZEICHNIS verlinkt. Keine Seite der Website tut das - die Kapitelseiten
zeigen direkt auf Notebooks_04/<name>.ipynb, und das funktioniert. Der
Webserver verweigert Directory-Listing, zu Recht.

Die Frage "gibt es so etwas nicht schon?" war aber berechtigt und hat die
eigentliche Luecke sichtbar gemacht: baue_programme_seite() erzeugt seit
jeher programme.html, eine gestaltete Uebersicht der 76 Programme. Fuer die
25 Notebooks gab es kein Gegenstueck - sie wurden ausgeliefert, aber keine
Seite listete sie.

Neu ist baue_notebooks_seite(), nach demselben Muster und mit denselben
Bausteinen: die Zuordnung Seite -> Notebook kommt fertig aus
baue_notebooks(), Rahmen und Navigation aus baue_seiten_schablone() und
baue_sidebar_html(). Gruppiert nach Vorspann, Kapiteln und Anhaengen; der
einleitende Absatz nennt die drei Wege, ein Notebook auszufuehren.

Dazu ein ZIP mit allen 25 Notebooks (208 KB), byteidentisch ueber zwei
Laeufe. Ein ZIP speichert je Eintrag die Aenderungszeit; ohne festen Wert
entstuende bei jedem Bau eine andere Datei, und da OR_HTML_04/ versioniert
ist, wuechse das Repository bei jedem Lauf. Die Eintraege werden deshalb
sortiert und mit ZipInfo(date_time=(1980,1,1,0,0,0)) geschrieben - dieselbe
Sorgfalt wie svg.hashsalt bei den Diagrammen.

Gegen kuenftige 403 schreiben spiegle_notebooks() und baue_programme_seite()
je eine dreizeilige index.html mit meta refresh in ihr Downloadverzeichnis.
Das behebt zugleich denselben latenten Fall bei programme/.

Ein eigener Fehler, zum zweiten Mal derselbe: Das .replace(",", ".") fuer
deutsche Tausenderpunkte hing am Ende eines mehrzeiligen f-Strings - und in
Python bindet die Methode an die GESAMTE zusammengesetzte Zeichenkette. Aus
"Drei Wege, sie auszufuehren" wurde "Drei Wege. sie auszufuehren". Exakt der
Fehler, vor dem ich in erzeuge_wirkung.py selbst einen Kommentar hinterlassen
hatte. Die Zahl wird jetzt getrennt formatiert.

Geprueft: 25 gelistete Notebooks, 0 tote Links auf der Seite, ZIP mit 25
Eintraegen und identischen Zeitstempeln, Seitenleisteneintrag auf allen 40
Seiten mit Seitenleiste, --check fehlerfrei, PDF unveraendert 759 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 15:20:06 +02:00
commit 5c49725d5c
49 changed files with 311 additions and 41 deletions

View file

@ -34,6 +34,7 @@ import os
import re
import subprocess
import sys
import zipfile
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
@ -1541,6 +1542,7 @@ def baue_sidebar_html(struktur: list[dict], aktuelle_seite: str, pdf_verfuegbar:
+ "".join(teile_html)
+ '<ul class="sidebar-extra">'
+ '<li><a href="programme.html">' + icon("book") + ' Beispielprogramme</a></li>'
+ '<li><a href="notebooks.html">' + icon("book") + ' Notebooks</a></li>'
+ '<li><a href="stichwortverzeichnis.html">' + icon("search")
+ ' Stichwortverzeichnis</a></li>'
+ '<li><a href="gesamtdokument.html">' + icon("external-link")
@ -1855,6 +1857,21 @@ def baue_notebooks(struktur: list[dict]) -> dict[str, str]:
return notizbuecher
def schreibe_verzeichnis_weiterleitung(verzeichnis: str, ziel: str) -> None:
"""Legt in einem Downloadverzeichnis eine index.html an, die auf die
zugehoerige Uebersichtsseite weiterleitet.
Keine Seite der Website verlinkt diese Verzeichnisse - verlinkt werden
immer die Einzeldateien. Wer die URL aber raet oder aus einer aelteren
Fassung uebernimmt, bekommt vom Webserver ein 403 (Directory-Listing ist
zu Recht abgeschaltet). Drei Zeilen machen daraus etwas Brauchbares."""
with open(os.path.join(verzeichnis, "index.html"), "w", encoding="utf-8") as f:
f.write('<!doctype html><meta charset="utf-8">\n'
f'<meta http-equiv="refresh" content="0; url={ziel}">\n'
f'<title>Weiterleitung</title>\n'
f'<p>Weiter zur <a href="{ziel}">Übersicht</a>.</p>\n')
def spiegle_notebooks(html_verz: str, notizbuecher: dict[str, str]) -> None:
"""Legt die Notebooks neben die Website, damit der Download-Link in der
Kapitelkarte auch dann funktioniert, wenn nur OR_HTML_04/ auf einen
@ -1866,6 +1883,7 @@ def spiegle_notebooks(html_verz: str, notizbuecher: dict[str, str]) -> None:
inhalt = f.read()
with open(os.path.join(ziel_verz, name), "w", encoding="utf-8") as f:
f.write(inhalt)
schreibe_verzeichnis_weiterleitung(ziel_verz, "../notebooks.html")
# Letzte Zeile der Karte 'Kapitel auf einen Blick' - davor wird der
@ -2131,9 +2149,89 @@ def baue_programme_seite(html_verz: str, struktur: list[dict], pdf_verfuegbar: b
inhalt_html=inhalt)
with open(os.path.join(html_verz, "programme.html"), "w", encoding="utf-8") as f:
f.write(html)
schreibe_verzeichnis_weiterleitung(ziel_verz, "../programme.html")
print(f"Programme-Seite erzeugt: {kopiert} Dateien nach {ziel_verz} kopiert.")
# Der ZIP-Nullpunkt. Ein ZIP speichert je Eintrag eine Aenderungszeit - ohne
# festen Wert entstuende bei jedem Bau eine andere Datei. Da OR_HTML_04/
# versioniert ist, wuerde das Repository sonst bei jedem Lauf um die Groesse
# des Archivs wachsen. Dieselbe Ueberlegung wie svg.hashsalt und
# metadata={"Date": None} bei den Diagrammgeneratoren.
ZIP_ZEITSTEMPEL = (1980, 1, 1, 0, 0, 0)
def baue_notebooks_seite(html_verz: str, struktur: list[dict],
notizbuecher: dict[str, str], pdf_verfuegbar: bool) -> None:
"""Uebersicht ueber alle Notebooks - das Gegenstueck zu
baue_programme_seite() fuer die andere Sorte Download.
Die Zuordnung Seite -> Notebookname kommt fertig aus baue_notebooks();
hier wird nichts neu hergeleitet. Gruppiert wird nach Typ, damit Vorwort
und Anhang nicht zwischen den Kapiteln stehen.
"""
gruppen = {"sonder": ("Vorspann", []), "kapitel": ("Kapitel", []),
"anhang": ("Anhänge", [])}
for e in struktur:
name = notizbuecher.get(e["seite"])
if not name:
continue
beschriftung = (f'{e["nummer"]}: {e["titel"]}' if e.get("nummer")
else e["titel"])
gruppen.get(e["typ"], gruppen["kapitel"])[1].append(
f'<li><a href="{e["seite"]}">{beschriftung}</a> '
f'<a class="download-link" href="{NOTEBOOK_NAME}/{name}" download>'
f'{icon("download", "icon")} {name}</a></li>')
anzahl = sum(len(eintraege) for _, eintraege in gruppen.values())
# --- Alle Notebooks in einem Archiv ---------------------------------
archiv_name = f"{NOTEBOOK_NAME}.zip"
archiv_pfad = os.path.join(html_verz, archiv_name)
with zipfile.ZipFile(archiv_pfad, "w", zipfile.ZIP_DEFLATED) as archiv:
for datei in sorted(set(notizbuecher.values())):
with open(os.path.join(NOTEBOOK_VERZ, datei), encoding="utf-8") as f:
inhalt = f.read()
eintrag = zipfile.ZipInfo(f"{NOTEBOOK_NAME}/{datei}", ZIP_ZEITSTEMPEL)
eintrag.compress_type = zipfile.ZIP_DEFLATED
eintrag.external_attr = 0o644 << 16
archiv.writestr(eintrag, inhalt)
archiv_kb = os.path.getsize(archiv_pfad) / 1024
# Deutsche Tausenderpunkte NUR auf der Zahl. Ein .replace(",", ".") am Ende
# eines mehrzeiligen f-Strings trifft die gesamte zusammengesetzte
# Zeichenkette - also auch jedes Satzkomma davor.
archiv_groesse = f"{archiv_kb:,.0f}".replace(",", ".")
gruppen_html = "".join(
f'<section class="programmgruppe"><h2>{titel}</h2>'
f'<ul class="programmliste">{"".join(eintraege)}</ul></section>'
for titel, eintraege in gruppen.values() if eintraege)
inhalt = (
f"<h1>Notebooks</h1>"
f"<p>Zu jedem Kapitel gehört ein Jupyter-Notebook mit allen Codeblöcken "
f"in Lesereihenfolge — insgesamt {anzahl}. Drei Wege, sie auszuführen: "
f"in einer lokalen <strong>Jupyter</strong>-Installation öffnen, in "
f"<strong>Google&nbsp;Colab</strong> hochladen, oder mit dem "
f"<strong>Kurs-Image</strong> starten, das alle Solver mitbringt:</p>"
f'<pre><code>docker run --rm -p 127.0.0.1:8888:8888 or-mit-python</code></pre>'
f'<p class="notebook-archiv">'
f'<a class="download-link" href="{archiv_name}" download>'
f'{icon("download", "icon")} Alle {anzahl} Notebooks als ZIP '
f'({archiv_groesse}&nbsp;KB)</a></p>'
+ gruppen_html)
html = baue_seiten_schablone(
titel="Notebooks", aktuelle_seite="notebooks.html",
sidebar_html=baue_sidebar_html(struktur, "notebooks.html", pdf_verfuegbar),
inhalt_html=inhalt)
with open(os.path.join(html_verz, "notebooks.html"), "w", encoding="utf-8") as f:
f.write(html)
print(f"Notebook-Seite erzeugt: {anzahl} Notebooks, "
f"Archiv {archiv_name} ({archiv_kb:.0f} KB).")
_UMLAUT_TABELLE = str.maketrans(
{"ä": "a", "ö": "o", "ü": "u", "ß": "s", "Ä": "a", "Ö": "o", "Ü": "u"})
@ -2249,6 +2347,7 @@ def baue_html_website() -> None:
suchindex, pdf_verfuegbar, notizbuecher)
baue_landingpage(html_verz, struktur, labels, label_seite, datei_seite, pdf_verfuegbar)
baue_programme_seite(html_verz, struktur, pdf_verfuegbar)
baue_notebooks_seite(html_verz, struktur, notizbuecher, pdf_verfuegbar)
baue_stichwortverzeichnis(html_verz, struktur, stichwortregister, pdf_verfuegbar)
erzeuge_suchindex(html_verz, suchindex)
baue_gesamtdokument(html_verz)