"""Reine Text-Helfer: Titel-Normalisierung, Listen-Parser, Chunk-Aufteilung. Kein Zustand, keine IO — überall gefahrlos importierbar. """ import re import unicodedata _CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader def _norm_titel(s: str) -> str: """Normalisiert einen Titel für den Schlüssel-Vergleich. NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown- Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten. """ s = unicodedata.normalize("NFKC", s) s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s) s = re.sub(r"[–—‐]", "-", s) s = re.sub(r"\s+", " ", s).strip().strip(".:;").strip() return s.casefold() def _titel(entry: str) -> str: return entry.split(" — ")[0].strip() or entry def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]: """Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen.""" seen: dict[str, int] = {} out: dict[int, str] = {} for num, text in entries.items(): titel = _titel(text) key = _norm_titel(titel) seen[key] = seen.get(key, 0) + 1 if seen[key] > 1: rest = text.split(" — ", 1) text = f"{titel} ({seen[key]})" + (f" — {rest[1]}" if len(rest) == 2 else "") # zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht out[num] = text return out def _titel_index(entries: dict[int, str]) -> dict[str, int]: return {_norm_titel(_titel(text)): num for num, text in entries.items()} def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None: """Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …").""" if not isinstance(t, str): return None return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t))) def _parse_auswahl(text: str) -> dict[int, str]: """Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile.""" entries: dict[int, str] = {} last = None for line in text.splitlines(): m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line) if m: last = int(m.group(1)) entries[last] = m.group(2) elif last is not None and line.strip(): entries[last] += " " + line.strip() return entries def _parse_kategorien(text: str) -> dict[str, list[str]]: """Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten.""" cats: dict[str, list[str]] = {} current = None for line in text.splitlines(): s = line.strip() m = re.match(r"#+\s*(KERN|WICHTIG|REST)\b", s, re.IGNORECASE) if m: current = m.group(1).upper() cats.setdefault(current, []) continue m = re.match(r"(\d+)[.)]\s+(.*\S)", s) if m and current: cats[current].append(m.group(2)) return cats def _lade_bausteine(text: str) -> dict[int, str]: """Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat).""" if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE): cats = _parse_kategorien(text) texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])] return {i: t for i, t in enumerate(texts, 1)} return _parse_auswahl(text) _FRAGMENT_KAPITEL_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_SECTION_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_SUB_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_BAUSTEIN_RE = re.compile(r"", re.IGNORECASE) # Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung). _FRAGMENT_KOMPAKT_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_AUSF_RE = re.compile(r"", re.IGNORECASE) _STUFEN = ("einfach", "mittel", "schwer") def _parse_fragment(text: str) -> list[dict]: """Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge. Zwei optionale Lese-Schichten je Section über `` / ``: Zeilen nach `kompakt` landen in `sec["kompakt"]`, sonst in `sec["md"]` (= ausführlich, auch der Default ohne Marker → rückwärtskompatibel). Optionale ``-Marker sammeln sich weiter in `sec["subs"]`. """ sections: list[dict] = [] kapitel = None current = None cur_sub = None cur_layer = "md" # Default: alles ohne Schicht-Marker ist die ausführliche Fassung for line in text.splitlines(): s = line.strip() m = _FRAGMENT_KAPITEL_RE.match(s) if m: kapitel = m.group(1) current = None cur_sub = None continue m = _FRAGMENT_SECTION_RE.match(s) if m: current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []} cur_sub = None cur_layer = "md" sections.append(current) continue if current is not None and _FRAGMENT_KOMPAKT_RE.match(s): cur_layer = "kompakt" cur_sub = None continue if current is not None and _FRAGMENT_AUSF_RE.match(s): cur_layer = "md" cur_sub = None continue m = _FRAGMENT_SUB_RE.match(s) if m and current is not None: teil = m.group(1).split("|", 1) stufe = teil[0].strip().casefold() cur_sub = { "stufe": stufe if stufe in _STUFEN else "einfach", "titel": teil[1].strip() if len(teil) == 2 else "", "md": [], } current["subs"].append(cur_sub) continue if current is not None: current[cur_layer].append(line) if cur_sub is not None and cur_layer == "md": cur_sub["md"].append(line) for sec in sections: sec["md"] = "\n".join(sec["md"]).strip() sec["kompakt"] = "\n".join(sec["kompakt"]).strip() for sub in sec["subs"]: sub["md"] = "\n".join(sub["md"]).strip() sec["subs"] = [sub for sub in sec["subs"] if sub["md"]] return sections def _parse_subbausteine(text: str) -> dict[str, list[str]]: """Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge. Format: `` gefolgt von Listenzeilen `- Subbaustein`. """ out: dict[str, list[str]] = {} current = None for line in text.splitlines(): s = line.strip() m = _FRAGMENT_BAUSTEIN_RE.match(s) if m: current = m.group(1).strip() out.setdefault(current, []) continue if current is None: continue m = re.match(r"[-*]\s+(.*\S)", s) if m: out[current].append(m.group(1).strip()) return {k: v for k, v in out.items() if v} def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]: """Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl.""" n = max(1, min(n, len(chapters))) chunks: list[list[dict]] = [] current: list[dict] = [] count = 0 remaining_total = sum(len(c["nums"]) for c in chapters) remaining_chunks = n for ch in chapters: current.append(ch) count += len(ch["nums"]) if remaining_chunks > 1 and count >= remaining_total / remaining_chunks: chunks.append(current) remaining_total -= count remaining_chunks -= 1 current = [] count = 0 if current: chunks.append(current) return chunks