aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java61
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java34
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java25
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java51
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java43
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java54
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Stand.java38
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java5
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java31
10 files changed, 323 insertions, 40 deletions
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index c7b22bf..fee0ce9 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -16,6 +16,7 @@ import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
import eu.mulk.aendggner.anwendung.Grund;
import eu.mulk.aendggner.anwendung.Nachfassungsabgleich;
+import eu.mulk.aendggner.gesetz.Fortschreibung;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Superskript;
import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
@@ -170,7 +171,9 @@ public final class Pipeline {
var parser = new AenderungsgesetzParser();
var warnungen = new ArrayList<String>();
- var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
+ var dokumente =
+ ordneNachAusfertigung(
+ wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen), warnungen);
var gesetz = altesGesetz;
var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
@@ -179,6 +182,10 @@ public final class Pipeline {
Inkrafttreten inkrafttreten = null;
for (var dokument : dokumente) {
+ var heft = new Fortschreibung(dokument.kopf().anzeigeName(), dokument.kopf().ausfertigung());
+ if (gesetz.traegt(heft)) {
+ warnungen.add(doppelanwendungsRuege(dokument, heft));
+ }
var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
if (parseErgebnis.befehle().isEmpty()) {
warnungen.add(
@@ -191,7 +198,13 @@ public final class Pipeline {
}
var geteilt = teileNachStichtag(parseErgebnis, stichtag, warnungen);
var anwendung = BefehlAnwender.anwenden(gesetz, geteilt.anzuwenden());
- gesetz = anwendung.neu();
+ // Vermerkt wird nur, was auch gewirkt hat: Ein Heft, dessen Befehle sämtlich liegenblieben,
+ // hat den Wortlaut nicht fortgeschrieben und darf ihn deshalb nicht als fortgeschrieben
+ // ausweisen.
+ gesetz =
+ anwendung.anzahlAngewandt() > 0 && !gesetz.traegt(heft)
+ ? anwendung.neu().mitFortschreibung(heft)
+ : anwendung.neu();
protokoll.addAll(anwendung.protokoll());
protokoll.addAll(geteilt.zurueckgestellt());
warnungen.addAll(parseErgebnis.warnungen());
@@ -232,6 +245,50 @@ public final class Pipeline {
abgleich);
}
+ /**
+ * Die Rüge, wenn ein Heft ein zweites Mal auf denselben Wortlaut trifft.
+ *
+ * <p>Ein Änderungsbefehl ist keine Zustandsbeschreibung, sondern eine Anordnung: Wer zweimal
+ * anfügt, fügt zweimal an. Am Wortlaut allein ist das nicht zu erkennen — die Befehle greifen ein
+ * zweites Mal anstandslos —, wohl aber an dem, was die Fassung über sich selbst mitführt.
+ * Abgebrochen wird gleichwohl nicht: Es kann Gründe geben, dasselbe Heft erneut anzuwenden, und
+ * verworfen wird hier nichts (§ 1 Absatz 4 des Handbuchs). Gesagt wird es.
+ */
+ private static String doppelanwendungsRuege(Quelldokument dokument, Fortschreibung heft) {
+ return ("Die Fassung trägt „%s“ bereits; %s wird damit ein zweites Mal angewandt. Ein Befehl"
+ + " ist eine Anordnung und keine Zustandsbeschreibung: Was angefügt wird, wird dann"
+ + " zweimal angefügt. Gemeint ist die Kette wohl mit einem anderen Heft.")
+ .formatted(heft.bezeichnung(), dokument.quelle().name());
+ }
+
+ /**
+ * Bringt die Hefte in die Reihenfolge ihrer Ausfertigung.
+ *
+ * <p>Die Kette wendet Heft auf Heft an; jedes setzt den Stand voraus, den das vorige hinterlassen
+ * hat. Die Reihenfolge der Aufrufargumente ist dafür kein Maßstab, das Ausfertigungsdatum schon.
+ * Umgestellt wird nur, wenn <em>jedes</em> Dokument ein Datum trägt — sonst fehlt der gemeinsame
+ * Maßstab, und geraten wird nicht. Und umgestellt wird nicht stillschweigend.
+ */
+ private static List<Quelldokument> ordneNachAusfertigung(
+ List<Quelldokument> dokumente, List<String> warnungen) {
+ if (dokumente.size() < 2 || dokumente.stream().anyMatch(d -> d.kopf().ausfertigung() == null)) {
+ return dokumente;
+ }
+ var geordnet = new ArrayList<>(dokumente);
+ geordnet.sort(java.util.Comparator.comparing(d -> d.kopf().ausfertigung()));
+ if (geordnet.equals(dokumente)) {
+ return dokumente;
+ }
+ warnungen.add(
+ "Die Änderungsdokumente wurden nach ihrem Ausfertigungsdatum geordnet und in dieser Folge"
+ + " angewandt: "
+ + geordnet.stream()
+ .map(d -> d.quelle().name())
+ .collect(java.util.stream.Collectors.joining(", "))
+ + ". Jedes Heft setzt den Stand voraus, den das vorige hinterlässt.");
+ return List.copyOf(geordnet);
+ }
+
/** Die Befehle eines Dokuments, geschieden nach dem, was am Stichtag schon galt. */
private record Geteilt(
List<eu.mulk.aendggner.aenderung.Aenderungsbefehl> anzuwenden,
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
index 9dde246..de607db 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package eu.mulk.aendggner.aenderung;
+import eu.mulk.aendggner.aenderung.parse.DeutschesDatum;
+import java.time.LocalDate;
import java.util.List;
import org.jspecify.annotations.Nullable;
@@ -19,21 +21,45 @@ import org.jspecify.annotations.Nullable;
* null} bei Dokumenten ohne Drucksachenkopf (Gesetzblätter, Referentenentwürfe).
* @param bezugsDrucksachen die Nummern der Drucksachen, auf die sich das Dokument bezieht.
* @param titel eine kurze Bezeichnung für Quellen- und Warnzeilen.
+ * @param ausfertigung das Datum, das die Zeile „Vom …“ nennt, oder {@code null}. Entwürfe tragen
+ * keines, und ein Sammelheft mit mehreren Verkündungen trägt mehrere — dann bleibt es
+ * gleichfalls leer, denn welche Verkündung gemeint ist, steht dort nicht fest. Geraten wird
+ * nicht.
*/
public record DokumentKopf(
DokumentArt art,
@Nullable String eigeneDrucksache,
List<String> bezugsDrucksachen,
- String titel) {
+ String titel,
+ @Nullable LocalDate ausfertigung) {
public DokumentKopf {
bezugsDrucksachen = List.copyOf(bezugsDrucksachen);
}
- /** Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“. */
+ public DokumentKopf(
+ DokumentArt art,
+ @Nullable String eigeneDrucksache,
+ List<String> bezugsDrucksachen,
+ String titel) {
+ this(art, eigeneDrucksache, bezugsDrucksachen, titel, null);
+ }
+
+ /**
+ * Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“ oder
+ * „Änderungsgesetz vom 22. April 2026“.
+ *
+ * <p>Sie ist zugleich der Schlüssel, an dem die Fassung wiedererkennt, welches Heft sie schon
+ * trägt (siehe {@link eu.mulk.aendggner.gesetz.Fortschreibung}). Deshalb steht hier, was das
+ * Dokument von sich selbst sagt — Drucksachennummer oder Ausfertigungsdatum —, und nicht der
+ * Dateiname, den jeder anders schreibt.
+ */
public String anzeigeName() {
- return eigeneDrucksache == null
+ if (eigeneDrucksache != null) {
+ return art.anzeigeName() + " Drs. " + eigeneDrucksache;
+ }
+ return ausfertigung == null
? art.anzeigeName()
- : art.anzeigeName() + " Drs. " + eigeneDrucksache;
+ : art.anzeigeName() + " vom " + DeutschesDatum.schreibe(ausfertigung);
}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
index a01442f..2d6f0d6 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.Inkrafttreten;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
import eu.mulk.aendggner.gesetz.Gesetz;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -383,8 +384,8 @@ public final class AenderungsgesetzParser {
* geraten wird nicht.
*/
private static Optional<String> standWarnung(String vorspann, Gesetz ziel) {
- var stand = ziel.stand();
- if (stand == null || stand.juengsteAenderung() == null) {
+ var wortlautStand = ziel.wortlautStand();
+ if (wortlautStand == null) {
return Optional.empty();
}
var satzDatum = EINLEITUNGS_DATUM.matcher(vorspann.replaceAll("\\s+", " "));
@@ -392,14 +393,14 @@ public final class AenderungsgesetzParser {
return Optional.empty();
}
var genannt = DeutschesDatum.lies(satzDatum.group(1), satzDatum.group(2), satzDatum.group(3));
- if (genannt == null || !stand.juengsteAenderung().isAfter(genannt)) {
+ if (genannt == null || !wortlautStand.isAfter(genannt)) {
return Optional.empty();
}
return Optional.of(
"Das Stammgesetz ist jünger als das Änderungsgesetz: Sein Wortlaut ist bis zum "
- + DeutschesDatum.schreibe(stand.juengsteAenderung())
+ + DeutschesDatum.schreibe(wortlautStand)
+ " fortgeschrieben („"
- + stand.kommentar()
+ + standHerkunft(ziel, wortlautStand)
+ "“), während der Einleitungssatz die Fassung vom "
+ satzDatum.group(1)
+ ". "
@@ -412,6 +413,20 @@ public final class AenderungsgesetzParser {
}
/**
+ * Woher der Wortlautstand rührt: aus einem Heft, das das Erzeugnis selbst angewandt hat, sonst
+ * aus der Standangabe der Quelle. In der Kette ist die Fassung nämlich jünger als ihre eigene
+ * Standzeile — sie trägt die Hefte, die seither auf sie angewandt worden sind.
+ */
+ private static String standHerkunft(Gesetz ziel, LocalDate wortlautStand) {
+ for (var heft : ziel.fortschreibungen()) {
+ if (wortlautStand.equals(heft.datum())) {
+ return heft.bezeichnung();
+ }
+ }
+ return ziel.stand() != null ? ziel.stand().kommentar() : "";
+ }
+
+ /**
* „… zuletzt durch Artikel 5 des Gesetzes vom 19. Juni 2020 (BGBl. I S. 1385) geändert worden
* ist“ — das Datum der <em>letzten Änderung</em>, nicht das der Ausfertigung. Deshalb muss ihm
* die Änderungsklausel vorausgehen: Der Einleitungssatz nennt zuerst das Ausfertigungsdatum („Das
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
index 0ac3b2f..6f926bf 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
@@ -4,6 +4,7 @@ package eu.mulk.aendggner.aenderung.parse;
import eu.mulk.aendggner.aenderung.DokumentArt;
import eu.mulk.aendggner.aenderung.DokumentKopf;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -78,6 +79,9 @@ public final class DokumentErkenner {
/** So viele Zeilen darf ein Titel überspannen. */
private static final int TITELZEILEN = 6;
+ /** Die Ausfertigungszeile eines verkündeten Werkes: „Vom 22. April 2026“. */
+ private static final Pattern VOM_ZEILE = Pattern.compile("^Vom " + DeutschesDatum.MUSTER + "$");
+
/**
* @param rohText der unbereinigte Extraktionstext des Dokuments.
*/
@@ -85,7 +89,47 @@ public final class DokumentErkenner {
var zeilen = kopfZeilen(rohText);
var art = bestimmeArt(zeilen, rohText);
return new DokumentKopf(
- art, eigeneDrucksache(zeilen), bezugsDrucksachen(zeilen), titel(zeilen));
+ art,
+ eigeneDrucksache(zeilen),
+ bezugsDrucksachen(zeilen),
+ titel(zeilen),
+ ausfertigung(rohText));
+ }
+
+ /**
+ * Eine Rohtextzeile, wie die Erkennung sie liest: ohne die Zeilenend- und Schriftgrößenmarken aus
+ * dem privaten Unicode-Bereich, die erst der {@link TextBereiniger} auswertet, und ohne
+ * geschützte Leerzeichen, die für {@code \s} nicht als Leerraum zählen. Beides hinge sonst
+ * unsichtbar an Titeln, Nummern und Daten.
+ */
+ private static String normiere(String zeile) {
+ return ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip();
+ }
+
+ /**
+ * Das Ausfertigungsdatum, das die Zeile „Vom …“ nennt.
+ *
+ * <p>Nur wenn das <em>ganze</em> Dokument genau eine solche Zeile führt. Ein Sammelheft stellt
+ * mehrere Verkündungen nebeneinander (so das thüringische GVBl. Nr. 2/2026 mit vieren), und
+ * welche von ihnen das Dokument ausmacht, sagt es nicht; dann bleibt das Datum lieber leer, als
+ * falsch zu sein. Denn an ihm hängt die Wiedererkennung des Heftes, und ein falsch bezeichnetes
+ * Heft wäre schlimmer als ein unbezeichnetes. Gesucht wird deshalb über den vollen Text und nicht
+ * bloß über die {@link #KOPFZEILEN} des Kopfes: Die zweite Verkündung stünde sonst außerhalb des
+ * Blickfeldes, und das Heft trüge den Namen der ersten.
+ */
+ private static @Nullable LocalDate ausfertigung(String rohText) {
+ LocalDate gefunden = null;
+ for (var zeile : rohText.split("\n", -1)) {
+ var m = VOM_ZEILE.matcher(normiere(zeile));
+ if (!m.matches()) {
+ continue;
+ }
+ if (gefunden != null) {
+ return null;
+ }
+ gefunden = DeutschesDatum.lies(m.group(1), m.group(2), m.group(3));
+ }
+ return gefunden;
}
/**
@@ -94,10 +138,7 @@ public final class DokumentErkenner {
private static List<String> kopfZeilen(String rohText) {
var zeilen = new ArrayList<String>();
for (var zeile : rohText.split("\n", -1)) {
- // Der Rohtext trägt noch die Zeilenend- und Schriftgrößenmarken aus dem privaten
- // Unicode-Bereich, die erst der TextBereiniger auswertet, dazu geschützte Leerzeichen, die
- // für \s nicht als Leerraum zählen. Beides hinge sonst unsichtbar an Titeln und Nummern.
- var normiert = ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip();
+ var normiert = normiere(zeile);
if (normiert.isEmpty()) {
continue;
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java b/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java
new file mode 100644
index 0000000..3d2c3b1
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.gesetz;
+
+import eu.mulk.aendggner.aenderung.parse.DeutschesDatum;
+import java.time.LocalDate;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Ein Änderungsheft, das auf diese Fassung bereits angewandt worden ist.
+ *
+ * <p>Ein Befehl ist keine Zustandsbeschreibung, sondern eine Anordnung: Wer zweimal anfügt, fügt
+ * zweimal an. Dasselbe Heft ein zweites Mal auf seine eigene Ausgabe anzuwenden ist deshalb keine
+ * folgenlose Wiederholung — und dem Wortlaut allein ist das nicht anzusehen. Die Fassung führt
+ * darum mit, was auf ihr schon geschehen ist; im kanonischen Klartext steht das als Zeile
+ * „Fortgeschrieben durch: …“.
+ *
+ * <p>Die Bezeichnung ist zugleich der Schlüssel: Zwei Hefte gelten als dasselbe, wenn sie
+ * gleichlautend bezeichnet sind. Das trägt, weil die Bezeichnung aus dem Dokument selbst stammt
+ * (Art und Ausfertigungsdatum, bei Drucksachen deren Nummer) und nicht aus dem Dateinamen.
+ *
+ * @param bezeichnung wie das Heft sich nennt, z.B. „Änderungsgesetz vom 22. April 2026“.
+ * @param datum sein Ausfertigungsdatum, soweit die Bezeichnung eines nennt. Daran hängt die
+ * Altersrüge: Nach der Fortschreibung ist der Wortlaut so jung wie das jüngste angewandte Heft.
+ */
+public record Fortschreibung(String bezeichnung, @Nullable LocalDate datum) {
+
+ /** „… vom 22. April 2026“ — das Ausfertigungsdatum, wie die Bezeichnung es führt. */
+ private static final Pattern VOM = Pattern.compile("\\bvom " + DeutschesDatum.MUSTER + "\\b");
+
+ /**
+ * Liest eine Bezeichnung, wie der Klartext sie führt, und gewinnt das Datum aus ihr zurück. Die
+ * Bezeichnung bleibt dabei unangetastet — ausgegeben wird, was eingelesen wurde.
+ */
+ public static Fortschreibung aus(String bezeichnung) {
+ var m = VOM.matcher(bezeichnung);
+ if (!m.find()) {
+ return new Fortschreibung(bezeichnung, null);
+ }
+ return new Fortschreibung(bezeichnung, DeutschesDatum.lies(m.group(1), m.group(2), m.group(3)));
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
index f7e94c0..c2f5dd8 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package eu.mulk.aendggner.gesetz;
+import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.jspecify.annotations.Nullable;
@@ -13,6 +15,10 @@ import org.jspecify.annotations.Nullable;
* @param stand der Stand der Quelle, soweit sie einen angibt; {@code null} bei handgepflegtem
* Klartext. An ihm hängt die Auskunft, ob das Stammgesetz jünger ist als das Änderungsgesetz,
* das darauf angewandt wird.
+ * @param fortschreibungen die Änderungshefte, die auf diesen Wortlaut bereits angewandt worden sind
+ * — leer bei jeder Fassung, die aus einer fremden Quelle stammt. Gefüllt wird die Liste erst,
+ * wenn das Erzeugnis selbst fortschreibt; sie überdauert die Kette, weil der kanonische
+ * Klartext sie mitführt (siehe {@link Fortschreibung}).
*/
public record Gesetz(
String jurabk,
@@ -20,11 +26,23 @@ public record Gesetz(
String kurzue,
List<Norm> normen,
List<Gliederung> gliederungen,
- @Nullable Stand stand) {
+ @Nullable Stand stand,
+ List<Fortschreibung> fortschreibungen) {
public Gesetz {
normen = List.copyOf(normen);
gliederungen = List.copyOf(gliederungen);
+ fortschreibungen = List.copyOf(fortschreibungen);
+ }
+
+ public Gesetz(
+ String jurabk,
+ String langue,
+ String kurzue,
+ List<Norm> normen,
+ List<Gliederung> gliederungen,
+ @Nullable Stand stand) {
+ this(jurabk, langue, kurzue, normen, gliederungen, stand, List.of());
}
public Gesetz(
@@ -45,14 +63,42 @@ public record Gesetz(
}
public Gesetz mitNormen(List<Norm> neueNormen) {
- return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand);
+ return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand, fortschreibungen);
}
public Gesetz mitGliederungen(List<Gliederung> neueGliederungen) {
- return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen, stand);
+ return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen, stand, fortschreibungen);
}
public Gesetz mitLangue(String neuerLangtitel) {
- return new Gesetz(jurabk, neuerLangtitel, kurzue, normen, gliederungen, stand);
+ return new Gesetz(
+ jurabk, neuerLangtitel, kurzue, normen, gliederungen, stand, fortschreibungen);
+ }
+
+ /** Vermerkt ein angewandtes Heft; die Reihenfolge ist die der Anwendung. */
+ public Gesetz mitFortschreibung(Fortschreibung heft) {
+ var erweitert = new ArrayList<>(fortschreibungen);
+ erweitert.add(heft);
+ return new Gesetz(jurabk, langue, kurzue, normen, gliederungen, stand, erweitert);
+ }
+
+ /** Ob dieses Heft auf den Wortlaut bereits angewandt worden ist. */
+ public boolean traegt(Fortschreibung heft) {
+ return fortschreibungen.stream().anyMatch(f -> f.bezeichnung().equals(heft.bezeichnung()));
+ }
+
+ /**
+ * Wie jung der Wortlaut wirklich ist: das späteste Datum, das entweder die Standangabe der Quelle
+ * oder eines der angewandten Hefte nennt. An ihm hängt die Altersrüge — und zwar auch in der
+ * Kette, denn wer ein Heft anwendet, macht den Wortlaut so jung wie dieses Heft.
+ */
+ public @Nullable LocalDate wortlautStand() {
+ LocalDate juengste = stand != null ? stand.juengsteAenderung() : null;
+ for (var heft : fortschreibungen) {
+ if (heft.datum() != null && (juengste == null || heft.datum().isAfter(juengste))) {
+ juengste = heft.datum();
+ }
+ }
+ return juengste;
}
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Stand.java b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
index 7541f5f..92f41be 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
@@ -3,6 +3,7 @@
package eu.mulk.aendggner.gesetz;
import java.time.LocalDate;
+import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
/**
@@ -19,4 +20,39 @@ import org.jspecify.annotations.Nullable;
* @param juengsteAenderung das späteste Datum, das irgendeine Standangabe nennt — der wirkliche
* Stand des Wortlauts; {@code null}, wenn keine Angabe ein Datum trägt.
*/
-public record Stand(String kommentar, @Nullable LocalDate juengsteAenderung) {}
+public record Stand(String kommentar, @Nullable LocalDate juengsteAenderung) {
+
+ /** „… v. 19.6.2020 I 1385“ — das Datum, das eine Standangabe nennt. */
+ private static final Pattern STANDDATUM =
+ Pattern.compile("v\\. (\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})");
+
+ /** Eine Standzeile mitsamt dem spätesten Datum, das in ihr steht. */
+ public static Stand aus(String kommentar) {
+ return new Stand(kommentar, juengstesDatum(kommentar));
+ }
+
+ /**
+ * Das späteste Datum, das {@code text} nennt; {@code null}, wenn keines darin steht. Eine
+ * Standangabe nennt mitunter mehrere, und ein verschriebenes Datum („19.5..2020“) kommt vor — es
+ * bleibt außer Betracht, statt den ganzen Stand zu verwerfen.
+ */
+ public static @Nullable LocalDate juengstesDatum(String text) {
+ LocalDate juengste = null;
+ var m = STANDDATUM.matcher(text);
+ while (m.find()) {
+ try {
+ var datum =
+ LocalDate.of(
+ Integer.parseInt(m.group(3)),
+ Integer.parseInt(m.group(2)),
+ Integer.parseInt(m.group(1)));
+ if (juengste == null || datum.isAfter(juengste)) {
+ juengste = datum;
+ }
+ } catch (RuntimeException verschrieben) {
+ // Ein unmögliches Datum bleibt außer Betracht.
+ }
+ }
+ return juengste;
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
index 35561c6..c0fd0b9 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
@@ -165,10 +165,6 @@ public final class GiiXmlLoader {
return null;
}
- /** „… v. 19.6.2020 I 1385“ — das Datum, das eine Standangabe nennt. */
- private static final Pattern STANDDATUM =
- Pattern.compile("v\\. (\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})");
-
/**
* Der Stand des Gesetzes: die Standzeile im Wortlaut und das späteste Datum, das
* <em>irgendeine</em> Standangabe nennt. Die Hinweise („Änderung durch Art. 1 G v. 18.11.2020 …
@@ -186,20 +182,9 @@ public final class GiiXmlLoader {
if (zeile == null && "Stand".equals(kindText(angabe, "standtyp"))) {
zeile = kommentar;
}
- var m = STANDDATUM.matcher(kommentar);
- while (m.find()) {
- try {
- var datum =
- LocalDate.of(
- Integer.parseInt(m.group(3)),
- Integer.parseInt(m.group(2)),
- Integer.parseInt(m.group(1)));
- if (juengste == null || datum.isAfter(juengste)) {
- juengste = datum;
- }
- } catch (RuntimeException ignoriert) {
- // Ein verschriebenes Datum („19.5..2020“) kommt vor; es bleibt außer Betracht.
- }
+ var datum = Stand.juengstesDatum(kommentar);
+ if (datum != null && (juengste == null || datum.isAfter(juengste))) {
+ juengste = datum;
}
}
return zeile == null ? null : new Stand(zeile, juengste);
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
index 67434ca..3a82945 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
@@ -59,6 +59,11 @@ public final class LandesRechtTextAusgeber {
if (gesetz.stand() != null) {
sb.append("Stand: ").append(gesetz.stand().kommentar()).append('\n');
}
+ // Was das Erzeugnis selbst getan hat, steht mit im Kopf: Ohne diese Zeilen wüsste die nächste
+ // Stufe der Kette nicht, welche Hefte der Wortlaut schon trägt (siehe Fortschreibung).
+ for (var heft : gesetz.fortschreibungen()) {
+ sb.append("Fortgeschrieben durch: ").append(heft.bezeichnung()).append('\n');
+ }
var gliederungen = gesetz.gliederungen();
int gliederungsZeiger = 0;
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
index bef8a2c..7f7e2f4 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
@@ -3,9 +3,11 @@
package eu.mulk.aendggner.gesetz.land;
import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Fortschreibung;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
+import eu.mulk.aendggner.gesetz.Stand;
import eu.mulk.aendggner.gesetz.Superskript;
import java.util.ArrayList;
import java.util.List;
@@ -38,6 +40,13 @@ final class LandesRechtTextParser {
private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+");
+ /** Die Standangabe der Quelle, wie der {@link LandesRechtTextAusgeber} sie schreibt. */
+ private static final Pattern STAND_ZEILE = Pattern.compile("^Stand: (\\S.*)$");
+
+ /** Ein bereits angewandtes Änderungsheft, gleichfalls aus der Feder des Ausgebers. */
+ private static final Pattern FORTSCHREIBUNGS_ZEILE =
+ Pattern.compile("^Fortgeschrieben durch: (\\S.*)$");
+
// Juris-Abkürzung direkt hinter dem Langtitel. Bayern führt einteilige Kürzel („(BayJG)“), die
// übrigen Länder oft mehrteilige („(GO NRW)“); Nur die Zeile unmittelbar nach dem Titel wird
// geprüft, sodass spätere Fundstellen-Klammern („(BayRS V S. 595)“) nicht getroffen werden.
@@ -189,7 +198,20 @@ final class LandesRechtTextParser {
}
}
// Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen.
+ // Zwei Zeilenformen werden dabei aufgenommen statt verworfen: die Standangabe der Quelle und
+ // die Hefte, die das Erzeugnis selbst schon angewandt hat. Beides gibt der Ausgeber aus; würde
+ // es hier fallen, so verlöre die Kette bei jedem Schritt ihr Gedächtnis.
+ Stand stand = null;
+ var fortschreibungen = new ArrayList<Fortschreibung>();
while (i < zeilen.size() && !istStrukturZeile(zeilen, i)) {
+ var kopfzeile = zeilen.get(i).strip();
+ var standZeile = STAND_ZEILE.matcher(kopfzeile);
+ var heftZeile = FORTSCHREIBUNGS_ZEILE.matcher(kopfzeile);
+ if (standZeile.matches()) {
+ stand = Stand.aus(standZeile.group(1).strip());
+ } else if (heftZeile.matches()) {
+ fortschreibungen.add(Fortschreibung.aus(heftZeile.group(1).strip()));
+ }
i++;
}
@@ -347,7 +369,14 @@ final class LandesRechtTextParser {
"Kein „§ N“- oder „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung im"
+ " kanonischen Klartextformat?");
}
- return new Gesetz(jurabk != null ? jurabk : langue, langue, kurzue, normen, gliederungen);
+ return new Gesetz(
+ jurabk != null ? jurabk : langue,
+ langue,
+ kurzue,
+ normen,
+ gliederungen,
+ stand,
+ fortschreibungen);
}
/** Eröffnet die Zeile eine Struktureinheit (Gliederung, Inhaltsübersicht oder Normkopf)? */