| package eu.mulk.aendggner.anwendung; |
| |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl; |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung; |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; |
| import eu.mulk.aendggner.aenderung.Stelle; |
| import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung; |
| import eu.mulk.aendggner.anwendung.BefehlAnwender.Status; |
| import eu.mulk.aendggner.gesetz.Norm; |
| import java.util.ArrayList; |
| import java.util.LinkedHashSet; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.regex.Pattern; |
| import org.jspecify.annotations.Nullable; |
| |
| /** |
| * Wendet Angabe-Befehle auf die Inhaltsübersichts-Norm an. Deren Text besteht aus Tabellenzeilen |
| * („§ 5 | Titel“) und Zwischenüberschriften („2. Abschnitt - …“); die Befehle ersetzen, entfernen |
| * oder ergänzen einzelne solcher Zeilen. |
| */ |
| final class InhaltsuebersichtAnwender { |
| |
| private static final String ENBEZ = "Inhaltsübersicht"; |
| |
| private InhaltsuebersichtAnwender() {} |
| |
| /** |
| * Behandelt die Struktur-Befehle auf der Inhaltsübersicht (Angabe gefasst/ersetzt/gestrichen/ |
| * eingefügt). Liefert {@code null} für wortweise Operationen — die laufen über die normalen |
| * Textzweige des {@link BefehlAnwender}, weil die Inhaltsübersicht eine gewöhnliche Norm ist. |
| */ |
| static @Nullable AngewandteAenderung wendeAn(List<Norm> normen, Aenderungsbefehl befehl) { |
| return switch (befehl) { |
| case Neufassung n -> { |
| var ziel = zielKette(n.stelle()); |
| if (ziel.isEmpty()) { |
| yield ersetzeGesamteUebersicht(normen, n); |
| } |
| yield ersetzeZeilen(normen, befehl, ziel, ziel, n.neuerText()); |
| } |
| case StrukturErsetzung s -> { |
| var von = zielKette(s.stelle()); |
| var bis = s.bisStelle() != null ? zielKette(s.bisStelle()) : von; |
| if (von.isEmpty() || bis.isEmpty()) { |
| yield manuell(befehl, "Angabe-Bereich nennt kein auflösbares Ziel."); |
| } |
| yield ersetzeZeilen(normen, befehl, von, bis, s.text()); |
| } |
| case Aufhebung a -> { |
| var ziel = zielKette(a.stelle()); |
| if (ziel.isEmpty()) { |
| yield manuell(befehl, "Zu streichende Angabe nennt kein auflösbares Ziel."); |
| } |
| yield ersetzeZeilen(normen, befehl, ziel, ziel, null); |
| } |
| case StrukturEinfuegung e -> { |
| var anker = zielKette(e.stelle()); |
| if (anker.isEmpty()) { |
| yield manuell(befehl, "Einfügeanker in der Inhaltsübersicht nennt kein Ziel."); |
| } |
| yield fuegeZeilenEin(normen, befehl, anker, e.vorher(), e.text()); |
| } |
| case Aenderungsbefehl.Ersetzung ignoriert -> null; |
| case Aenderungsbefehl.Streichung ignoriert -> null; |
| case Aenderungsbefehl.WoerterEinfuegung ignoriert -> null; |
| default -> |
| manuell(befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt."); |
| }; |
| } |
| |
| /** Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. */ |
| private static List<Stelle.Komponente> zielKette(Stelle stelle) { |
| return stelle.komponenten().stream() |
| .filter( |
| k -> k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit) |
| .toList(); |
| } |
| |
| /** Ersetzt die Zeilen von {@code von} bis {@code bis} durch die Angaben des Zitats (oder nichts). */ |
| private static AngewandteAenderung ersetzeZeilen( |
| List<Norm> normen, |
| Aenderungsbefehl befehl, |
| List<Stelle.Komponente> von, |
| List<Stelle.Komponente> bis, |
| @Nullable String zitat) { |
| int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); |
| if (normIndex < 0) { |
| return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); |
| } |
| var norm = normen.get(normIndex); |
| var vonFund = findeZeile(norm, von); |
| if (vonFund.fehler() != null) { |
| return manuell(befehl, vonFund.fehler()); |
| } |
| var bisFund = von.equals(bis) ? vonFund : findeZeile(norm, bis); |
| if (bisFund.fehler() != null) { |
| return manuell(befehl, bisFund.fehler()); |
| } |
| if (vonFund.absatzIndex() != bisFund.absatzIndex() || bisFund.bisZeile() < vonFund.vonZeile()) { |
| return manuell(befehl, "Angabe-Bereich liegt nicht zusammenhängend in der Inhaltsübersicht."); |
| } |
| var zeilen = new ArrayList<>(zeilenVon(norm, vonFund.absatzIndex())); |
| var einrueckung = einrueckungVon(zeilen.get(vonFund.vonZeile())); |
| for (int i = bisFund.bisZeile(); i >= vonFund.vonZeile(); i--) { |
| zeilen.remove(i); |
| } |
| if (zitat != null) { |
| zeilen.addAll(vonFund.vonZeile(), angabenZeilen(zitat, einrueckung)); |
| } |
| setzeZeilen(normen, normIndex, vonFund.absatzIndex(), zeilen); |
| return angewandt(befehl); |
| } |
| |
| private static AngewandteAenderung fuegeZeilenEin( |
| List<Norm> normen, |
| Aenderungsbefehl befehl, |
| List<Stelle.Komponente> anker, |
| boolean vorher, |
| String zitat) { |
| int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); |
| if (normIndex < 0) { |
| return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); |
| } |
| var norm = normen.get(normIndex); |
| var fund = findeZeile(norm, anker); |
| if (fund.fehler() != null) { |
| return manuell(befehl, fund.fehler()); |
| } |
| var zeilen = new ArrayList<>(zeilenVon(norm, fund.absatzIndex())); |
| var einrueckung = einrueckungVon(zeilen.get(fund.vonZeile())); |
| int position = vorher ? fund.vonZeile() : fund.bisZeile() + 1; |
| zeilen.addAll(position, angabenZeilen(zitat, einrueckung)); |
| setzeZeilen(normen, normIndex, fund.absatzIndex(), zeilen); |
| return angewandt(befehl); |
| } |
| |
| // --- Zeilenmodell ---------------------------------------------------------------------------- |
| |
| private record Zeilenfund( |
| int absatzIndex, int vonZeile, int bisZeile, @Nullable String fehler) { |
| static Zeilenfund fehlgeschlagen(String begruendung) { |
| return new Zeilenfund(-1, -1, -1, begruendung); |
| } |
| } |
| |
| /** |
| * Findet die (norm-weit eindeutige) Zeile der adressierten Angabe. Die Kette wird verschachtelt |
| * aufgelöst: „Teil 2 Abschnitt 4“ sucht die Abschnitt-Zeile erst hinter der Teil-2-Zeile (und |
| * vor dem nächsten Teil), sodass gleichnamige Abschnitte anderer Teile nicht stören. |
| */ |
| private static Zeilenfund findeZeile(Norm norm, List<Stelle.Komponente> kette) { |
| var ziel = kette.get(kette.size() - 1); |
| Zeilenfund gefunden = null; |
| for (int a = 0; a < norm.absaetze().size(); a++) { |
| var zeilen = zeilenVon(norm, a); |
| int von = 0; |
| int bis = zeilen.size(); |
| boolean fenstergueltig = true; |
| for (int k = 0; k < kette.size() - 1 && fenstergueltig; k++) { |
| int eltern = eindeutigeZeile(zeilen, zeilenMuster(kette.get(k)), von, bis); |
| if (eltern < 0) { |
| fenstergueltig = false; |
| continue; |
| } |
| von = eltern + 1; |
| bis = naechsteGleichrangige(zeilen, kette.get(k), von, zeilen.size()); |
| } |
| if (!fenstergueltig) { |
| continue; |
| } |
| int treffer = eindeutigeZeile(zeilen, zeilenMuster(ziel), von, bis); |
| if (treffer == -2) { |
| return Zeilenfund.fehlgeschlagen( |
| "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig."); |
| } |
| if (treffer >= 0) { |
| if (gefunden != null) { |
| return Zeilenfund.fehlgeschlagen( |
| "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig."); |
| } |
| gefunden = new Zeilenfund(a, treffer, treffer, null); |
| } |
| } |
| if (gefunden == null) { |
| return Zeilenfund.fehlgeschlagen( |
| "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht nicht auffindbar."); |
| } |
| return gefunden; |
| } |
| |
| /** Die eindeutige Trefferzeile in [von,bis): Index, -1 (nicht gefunden) oder -2 (mehrdeutig). */ |
| private static int eindeutigeZeile(List<String> zeilen, Pattern muster, int von, int bis) { |
| int treffer = -1; |
| for (int z = von; z < bis; z++) { |
| if (muster.matcher(zeilen.get(z).strip()).find()) { |
| if (treffer >= 0) { |
| return -2; |
| } |
| treffer = z; |
| } |
| } |
| return treffer; |
| } |
| |
| /** Die nächste Zeile derselben Gliederungsart („Teil <n>“) als Fenstergrenze. */ |
| private static int naechsteGleichrangige( |
| List<String> zeilen, Stelle.Komponente eltern, int von, int bis) { |
| if (!(eltern instanceof Stelle.Gliederungseinheit g)) { |
| return bis; |
| } |
| var muster = |
| Pattern.compile( |
| "^(?:" |
| + Pattern.quote(g.art()) |
| + "\\s+\\d|\\d+[a-z]?\\.\\s*" |
| + Pattern.quote(g.art()) |
| + "\\b)"); |
| for (int z = von; z < bis; z++) { |
| if (muster.matcher(zeilen.get(z).strip()).find()) { |
| return z; |
| } |
| } |
| return bis; |
| } |
| |
| private static Pattern zeilenMuster(Stelle.Komponente ziel) { |
| return switch (ziel) { |
| case Stelle.Paragraph p -> |
| Pattern.compile( |
| "^" + Pattern.quote(p.sigel()) + "\\s*" + Pattern.quote(p.nummer()) + "(?![0-9a-z])"); |
| case Stelle.Gliederungseinheit g -> { |
| if (g.nummer().isEmpty()) { |
| yield Pattern.compile("^" + Pattern.quote(g.art()) + "\\b"); |
| } |
| // Beide Schreibweisen: „Abschnitt 2“ und „2. Abschnitt“. |
| yield Pattern.compile( |
| "^(?:" |
| + Pattern.quote(g.art()) |
| + "\\s+" |
| + Pattern.quote(g.nummer()) |
| + "(?![0-9a-z])|" |
| + Pattern.quote(g.nummer()) |
| + "\\.\\s*" |
| + Pattern.quote(g.art()) |
| + "\\b)"); |
| } |
| default -> Pattern.compile("(?!)"); |
| }; |
| } |
| |
| /** |
| * „Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt: „…““ — der komplette |
| * Zeilenbestand der Inhaltsübersichts-Norm wird aus dem Zitat neu aufgebaut. |
| */ |
| private static AngewandteAenderung ersetzeGesamteUebersicht( |
| List<Norm> normen, Neufassung befehl) { |
| int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); |
| if (normIndex < 0) { |
| return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); |
| } |
| var flach = befehl.neuerText().replaceAll("\\s+", " ").strip(); |
| flach = flach.replaceFirst("^Inhaltsübersicht\\s*", ""); |
| // Plausibilitätssperre: Enthält das Zitat Befehlssprache, hat vermutlich ein unbalanciertes |
| // Anführungszeichen nachfolgende Befehle in das Zitat gezogen — dann keinesfalls anwenden. |
| if (flach.contains("wie folgt geändert") || flach.contains(" wird wie folgt gefasst")) { |
| return manuell( |
| befehl, |
| "Das Zitat der neuen Inhaltsübersicht enthält Befehlstext — vermutlich ist ein" |
| + " Anführungszeichen unbalanciert; bitte manuell prüfen."); |
| } |
| var zeilen = new ArrayList<String>(); |
| for (var stueck : UEBERSICHT_MARKE.split(flach)) { |
| var s = stueck.strip(); |
| if (s.isEmpty()) { |
| continue; |
| } |
| var m = |
| Pattern.compile( |
| "^((?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch)\\s+\\d+[a-z]?|Anhang" |
| + "|(?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$") |
| .matcher(s); |
| if (m.matches() && !m.group(2).isEmpty()) { |
| zeilen.add(m.group(1) + " | " + m.group(2)); |
| } else { |
| zeilen.add(s); |
| } |
| } |
| if (zeilen.size() < 2) { |
| return manuell(befehl, "Das Zitat enthält keine erkennbare Inhaltsübersicht."); |
| } |
| var norm = normen.get(normIndex); |
| normen.set( |
| normIndex, |
| norm.mitAbsaetzen( |
| List.of(new eu.mulk.aendggner.gesetz.Absatz(null, String.join("\n", zeilen))))); |
| return angewandt(befehl); |
| } |
| |
| // Zeilenanfänge einer Inhaltsübersicht: §-Angaben (nicht Querverweise) und Gliederungsmarken. |
| private static final Pattern UEBERSICHT_MARKE = |
| Pattern.compile( |
| "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+" |
| + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben" |
| + "|und|bis|oder|sowie|des|der|dieses)" |
| + "(?:\\(|\\p{Lu})" |
| + "|(?<!\\S)(?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch) \\d+[a-z]?(?!\\S)" |
| + "|(?<!\\S)Anhang(?!\\S))"); |
| |
| /** |
| * Zerlegt das Zitat in Angabe-Zeilen: bei §-Angaben eine Zeile je Paragraph (im Zeilenformat der |
| * Inhaltsübersicht, „§ N | Titel“), sonst eine einzelne Zeile. |
| */ |
| private static List<String> angabenZeilen(String zitat, String einrueckung) { |
| var flach = zitat.strip().replaceAll("\\s+", " "); |
| var zeilen = new ArrayList<String>(); |
| if (flach.startsWith("§") || flach.startsWith("Art.")) { |
| for (var stueck : PARAGRAPH_ANGABE.split(flach)) { |
| var s = stueck.strip(); |
| if (s.isEmpty()) { |
| continue; |
| } |
| var m = Pattern.compile("^((?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$").matcher(s); |
| if (m.matches() && !m.group(2).isEmpty()) { |
| zeilen.add(einrueckung + m.group(1) + " | " + m.group(2)); |
| } else { |
| zeilen.add(einrueckung + s); |
| } |
| } |
| } |
| if (zeilen.isEmpty()) { |
| zeilen.add(einrueckung + flach); |
| } |
| return zeilen; |
| } |
| |
| // Trennt einen Block mehrerer §-Angaben an den §-Anfängen; Querverweise („… zu § 3 Absatz 3“) |
| // trennen nicht (nach ihnen folgt ein Kleinwort oder eine Strukturangabe statt eines Titels). |
| private static final Pattern PARAGRAPH_ANGABE = |
| Pattern.compile( |
| "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+" |
| + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben" |
| + "|und|bis|oder|sowie|des|der|dieses)" |
| + "(?:\\(|\\p{Lu}))"); |
| |
| private static List<String> zeilenVon(Norm norm, int absatzIndex) { |
| return norm.absaetze().get(absatzIndex).text().lines().toList(); |
| } |
| |
| private static void setzeZeilen( |
| List<Norm> normen, int normIndex, int absatzIndex, List<String> zeilen) { |
| var norm = normen.get(normIndex); |
| var absaetze = new ArrayList<>(norm.absaetze()); |
| absaetze.set(absatzIndex, absaetze.get(absatzIndex).mitText(String.join("\n", zeilen))); |
| normen.set(normIndex, norm.mitAbsaetzen(absaetze)); |
| } |
| |
| private static String einrueckungVon(String zeile) { |
| return zeile.substring(0, zeile.length() - zeile.stripLeading().length()); |
| } |
| |
| private static String anzeige(Stelle.Komponente ziel) { |
| return switch (ziel) { |
| case Stelle.Paragraph p -> p.enbez(); |
| case Stelle.Gliederungseinheit g -> g.bezeichnung(); |
| default -> ziel.toString(); |
| }; |
| } |
| |
| private static AngewandteAenderung angewandt(Aenderungsbefehl befehl) { |
| return new AngewandteAenderung( |
| befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(ENBEZ))); |
| } |
| |
| private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) { |
| return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of()); |
| } |
| |
| private static eu.mulk.aendggner.gesetz.Gesetz gesetzAus(List<Norm> normen) { |
| return new eu.mulk.aendggner.gesetz.Gesetz("", null, null, normen); |
| } |
| } |