diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/AendGgner.java | 53 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/Bezug.java | 257 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/BezugTest.java | 59 |
3 files changed, 347 insertions, 22 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index 195a425..1544951 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -36,16 +36,20 @@ public class AendGgner implements Callable<Integer> { @Parameters( index = "0", + paramLabel = "<base law>", description = - "The base law: gii-norm XML from gesetze-im-internet.de, or — for Bavarian state law —" - + " the consolidated version from gesetze-bayern.de (PDF or canonical plain text).") - private Path baseFile; + "The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the" + + " consolidated version from the state portal (PDF or canonical plain text). May be" + + " a file, an http(s) URL, or \"gii:<abbr>\" (e.g. gii:uwg) for federal law.") + private String baseFile; @Parameters( index = "1..*", arity = "*", - description = "The amendment act(s) to apply (BGBl PDF or plain text).") - private List<Path> patches; + paramLabel = "<amendment>", + description = + "The amendment act(s) to apply (BGBl PDF or plain text). May be files or http(s) URLs.") + private List<String> patches; @Option( names = {"-o", "--output"}, @@ -126,9 +130,18 @@ public class AendGgner implements Callable<Integer> { paramLabel = "<file>", description = "Compare the result against the official amended version, norm by norm. Accepts the same " - + "formats as the base law. The report is added to the synopsis; a mismatch yields " + + "inputs as the base law. The report is added to the synopsis; a mismatch yields " + "exit code 3.") - private Path nachfassung; + private String nachfassung; + + /** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */ + private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException { + var quellen = new ArrayList<Quelle>(); + for (var angabe : angaben) { + quellen.add(Bezug.hole(angabe)); + } + return quellen; + } public static void main(String... args) { int exitCode = new CommandLine(new AendGgner()).execute(args); @@ -142,7 +155,7 @@ public class AendGgner implements Callable<Integer> { log.debugf("Logging configured."); if (dumpGesetz) { - var gesetz = Pipeline.ladeStammgesetz(baseFile); + var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile)); System.out.printf( "%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size()); for (var norm : gesetz.normen()) { @@ -157,11 +170,7 @@ public class AendGgner implements Callable<Integer> { } if (extractOnly) { - var quellen = new ArrayList<Quelle>(); - for (var file : patches) { - quellen.add(Quelle.lies(file)); - } - System.out.print(Pipeline.extrahiereText(Quelle.lies(baseFile), quellen, raw)); + System.out.print(Pipeline.extrahiereText(Bezug.hole(baseFile), hole(patches), raw)); return 0; } @@ -170,11 +179,11 @@ public class AendGgner implements Callable<Integer> { // Ohne weitere Argumente wird die erste Datei selbst eingeordnet — zum Nachsehen, was // ÄndGgner in einem einzelnen Dokument erkennt, braucht es dann kein Stammgesetz. var zuPruefen = patches == null || patches.isEmpty() ? List.of(baseFile) : patches; - for (var file : zuPruefen) { - var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(file)); + for (var quelle : hole(zuPruefen)) { + var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(quelle)); System.out.printf( "%s: %s [eigene Drs. %s, Bezug %s] %s%n", - file.getFileName(), + quelle.name(), kopf.art(), kopf.eigeneDrucksache() == null ? "—" : kopf.eigeneDrucksache(), kopf.bezugsDrucksachen().isEmpty() ? "—" : String.join(", ", kopf.bezugsDrucksachen()), @@ -184,15 +193,15 @@ public class AendGgner implements Callable<Integer> { } if (dumpBefehle) { - var gesetz = Pipeline.ladeStammgesetz(baseFile); + var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile)); var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz)); var parser = new AenderungsgesetzParser(); - for (var file : patches) { - var text = TextBereiniger.bereinige(extraktor.extrahiere(file)); + for (var quelle : hole(patches)) { + var text = TextBereiniger.bereinige(extraktor.extrahiere(quelle)); var ergebnis = parser.parse(text, gesetz, artikel); System.out.printf( "%s: %d Befehle aus Artikel %s%n", - file.getFileName(), ergebnis.befehle().size(), ergebnis.artikel()); + quelle.name(), ergebnis.befehle().size(), ergebnis.artikel()); for (var warnung : ergebnis.warnungen()) { System.err.println("WARNUNG: " + warnung); } @@ -238,11 +247,11 @@ public class AendGgner implements Callable<Integer> { } var auftrag = - Pipeline.Auftrag.von(baseFile, patches) + Pipeline.Auftrag.von(Bezug.hole(baseFile), hole(patches)) .mitArtikel(artikel) .mitVollstaendig(vollstaendig) .mitStichtag(tag) - .mitNachfassung(nachfassung == null ? null : Quelle.lies(nachfassung)); + .mitNachfassung(nachfassung == null ? null : Bezug.hole(nachfassung)); var ergebnis = Pipeline.erzeugeSynopse(auftrag); diff --git a/src/main/java/eu/mulk/aendggner/Bezug.java b/src/main/java/eu/mulk/aendggner/Bezug.java new file mode 100644 index 0000000..cded363 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/Bezug.java @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; + +/** + * Woher eine Eingabe kommt: aus dem Dateisystem, aus dem Netz oder aus dem Bundesanzeiger-Portal. + * + * <p>Das Handbuch verlangte bislang, das Norm-XML des Bundes von Hand herunterzuladen, obgleich + * seine Anschrift aus der Abkürzung des Gesetzes folgt. Die Handarbeit ist damit keine Leistung des + * Benutzers, sondern eine Zumutung; sie entfällt. + * + * <p>Diese Klasse gehört allein der Befehlszeile. Die Browserfassung darf sie nicht anrühren: Dort + * gibt es kein Dateisystem, und der Wagen des Benutzers verweigert einer fremden Anschrift ohnehin + * die Auskunft. Die Kernpipeline kennt deshalb nur {@link Quelle} — Name und Bytes. + */ +public final class Bezug { + + private static final org.jboss.logging.Logger log = + org.jboss.logging.Logger.getLogger(Bezug.class); + + /** gesetze-im-internet.de gibt jedes Werk unter seiner Kennung als Archiv aus. */ + private static final String GII = "https://www.gesetze-im-internet.de/%s/xml.zip"; + + /** Das Verzeichnis sämtlicher Werke des Portals; es nennt zu jedem Titel dessen Anschrift. */ + private static final String GII_VERZEICHNIS = "https://www.gesetze-im-internet.de/gii-toc.xml"; + + private static final java.util.regex.Pattern GII_LINK = + java.util.regex.Pattern.compile( + "<link>https?://www\\.gesetze-im-internet\\.de/([^/]+)/xml\\.zip</link>"); + + private static final Duration FRIST = Duration.ofSeconds(60); + + static { + // Ohne dies verweigert die Laufzeit das einfache Kennwort gegenüber einem Vermittler, sobald + // der Weg verschlüsselt ist. In einer Behörde führt jeder Weg über einen solchen Vermittler, + // und das Kennwort steht ohnehin in der Umgebung; die Sperre schützt hier niemanden. + if (System.getProperty("jdk.http.auth.tunneling.disabledSchemes") == null) { + System.setProperty("jdk.http.auth.tunneling.disabledSchemes", ""); + } + } + + private Bezug() {} + + /** + * Die Eingabe, wie sie angegeben ist: ein Dateipfad, eine Netzanschrift {@code http(s)://…} oder + * die Kurzform {@code gii:<abkürzung>} für das Bundesrecht ({@code gii:uwg}). + * + * <p>Der Dateipfad hat den Vorrang: Wer eine Datei „gii:etwas“ nennt, meint sie. Geladenes wird + * unter {@code ~/.cache/aendggner} abgelegt und beim nächsten Lauf von dort genommen — ein + * zweiter Lauf mit denselben Eingaben soll das Netz nicht abermals behelligen. + */ + public static Quelle hole(String angabe) throws IOException, InterruptedException { + var alsPfad = Path.of(angabe); + if (Files.exists(alsPfad)) { + return Quelle.lies(alsPfad); + } + if (angabe.startsWith("gii:")) { + var kurz = kennung(angabe.substring("gii:".length())); + if (kurz.isEmpty()) { + throw new IOException("„" + angabe + "“ nennt keine Kennung (etwa „gii:uwg_2004“)."); + } + try { + return lade(GII.formatted(kurz), kurz + "-xml.zip"); + } catch (NichtGefunden nichtGefunden) { + var gefunden = sucheImVerzeichnis(kurz); + return lade(GII.formatted(gefunden), gefunden + "-xml.zip"); + } + } + if (angabe.startsWith("http://") || angabe.startsWith("https://")) { + return lade(angabe, dateiname(angabe)); + } + // Kein Pfad, keine Anschrift: Quelle.lies meldet den fehlenden Pfad mit dem üblichen Wortlaut. + return Quelle.lies(alsPfad); + } + + private static Quelle lade(String anschrift, String name) + throws IOException, InterruptedException { + var abgelegt = zwischenspeicher(anschrift, name); + if (abgelegt != null && Files.exists(abgelegt)) { + return new Quelle(name, Files.readAllBytes(abgelegt)); + } + + try (var client = baueClient()) { + var antwort = + client.send( + HttpRequest.newBuilder(URI.create(anschrift)).timeout(FRIST).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + if (antwort.statusCode() == 404) { + throw new NichtGefunden("„" + anschrift + "“ gibt es nicht (Status 404)."); + } + if (antwort.statusCode() != 200) { + throw new IOException( + "„" + anschrift + "“ antwortet mit dem Status " + antwort.statusCode() + "."); + } + if (abgelegt != null) { + try { + Files.createDirectories(abgelegt.getParent()); + Files.write(abgelegt, antwort.body()); + } catch (IOException nichtSchreibbar) { + // Der Zwischenspeicher ist eine Bequemlichkeit, keine Bedingung: Ist das + // Heimatverzeichnis + // schreibgeschützt, so wird eben jedes Mal geladen. + log.debugf("Zwischenspeicher %s nicht beschreibbar: %s", abgelegt, nichtSchreibbar); + } + } + return new Quelle(name, antwort.body()); + } + } + + /** Die Anschrift gibt es nicht — für die Kennung des Bundesrechts ein Anlass nachzuschlagen. */ + private static final class NichtGefunden extends IOException { + NichtGefunden(String meldung) { + super(meldung); + } + } + + /** + * Die Kennung, wie das Portal sie führt: klein geschrieben, und was kein Buchstabe, keine Ziffer + * und kein Bindestrich ist, wird zum Unterstrich. So wird aus der Abkürzung „UWG 2004“ die + * Kennung „uwg_2004“ und aus „1-DM-GoldmünzG“ die Kennung „1-dm-goldm_nzg“. + */ + static String kennung(String angabe) { + return angabe.strip().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9-]", "_"); + } + + /** + * Sucht die Kennung im Verzeichnis des Portals, wenn die angegebene ins Leere führt. + * + * <p>Das Portal hängt an manche Kennung das Jahr der Fassung („uwg_2004“), und wer „uwg“ eingibt, + * kann das nicht wissen. Eine Kennung, die mit der angegebenen beginnt, ist deshalb gemeint — + * aber nur, wenn es genau eine gibt. Sonst wird nicht geraten, sondern aufgezählt. + */ + private static String sucheImVerzeichnis(String kurz) throws IOException, InterruptedException { + var verzeichnis = + new String( + lade(GII_VERZEICHNIS, "gii-toc.xml").inhalt(), java.nio.charset.StandardCharsets.UTF_8); + var treffer = new java.util.LinkedHashSet<String>(); + var m = GII_LINK.matcher(verzeichnis); + while (m.find()) { + if (m.group(1).equals(kurz) || m.group(1).startsWith(kurz + "_")) { + treffer.add(m.group(1)); + } + } + if (treffer.size() == 1) { + return treffer.iterator().next(); + } + if (treffer.isEmpty()) { + throw new IOException( + "Das Portal führt kein Werk unter der Kennung „" + + kurz + + "“. Die Kennung ist der letzte Teil seiner Anschrift auf gesetze-im-internet.de."); + } + throw new IOException( + "Die Kennung „" + + kurz + + "“ ist mehrdeutig; das Portal führt " + + String.join(", ", treffer) + + ". Gemeint ist eine von ihnen."); + } + + /** + * Der Wagen samt Vermittler, wie ihn die Umgebung vorschreibt. + * + * <p>Die Laufzeit liest {@code HTTPS_PROXY} und Verwandte nicht von sich aus; auf allen übrigen + * Werkzeugen der Kommandozeile ist das aber die gewohnte Angabe. Wer hinter einem Vermittler + * sitzt — in der Verwaltung ist das die Regel —, soll nichts eigens einstellen müssen. + */ + private static HttpClient baueClient() { + var bauer = HttpClient.newBuilder().connectTimeout(FRIST); + var vermittler = umgebung("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"); + if (vermittler != null) { + try { + var anschrift = URI.create(vermittler); + if (anschrift.getHost() != null) { + int hafen = anschrift.getPort() > 0 ? anschrift.getPort() : 8080; + bauer.proxy(ProxySelector.of(new InetSocketAddress(anschrift.getHost(), hafen))); + var kennung = anschrift.getUserInfo(); + if (kennung != null && kennung.contains(":")) { + var teile = kennung.split(":", 2); + bauer.authenticator( + new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(teile[0], teile[1].toCharArray()); + } + }); + } + } + } catch (IllegalArgumentException unbrauchbar) { + // Eine unlesbare Angabe bleibt außer Betracht; der unmittelbare Weg wird versucht. + } + } + return bauer.build(); + } + + private static @org.jspecify.annotations.Nullable String umgebung(String... namen) { + for (var name : namen) { + var wert = System.getenv(name); + if (wert != null && !wert.isBlank()) { + return wert; + } + } + return null; + } + + /** + * Der Ort im Zwischenspeicher; {@code null}, wenn keiner zu bestimmen ist. Der Name trägt einen + * Abdruck der Anschrift, damit zwei gleichnamige Werke verschiedener Herkunft sich nicht + * verdrängen. + */ + private static Path zwischenspeicher(String anschrift, String name) { + var verzeichnis = System.getenv("XDG_CACHE_HOME"); + var heim = System.getProperty("user.home"); + if ((verzeichnis == null || verzeichnis.isBlank()) && (heim == null || heim.isBlank())) { + return null; + } + var wurzel = + verzeichnis != null && !verzeichnis.isBlank() + ? Path.of(verzeichnis) + : Path.of(heim, ".cache"); + return wurzel.resolve("aendggner").resolve(abdruck(anschrift) + "-" + name); + } + + private static String abdruck(String anschrift) { + try { + var summe = + MessageDigest.getInstance("SHA-256") + .digest(anschrift.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(summe, 0, 8); + } catch (java.security.NoSuchAlgorithmException unmoeglich) { + throw new IllegalStateException(unmoeglich); + } + } + + /** Das letzte Glied der Anschrift, ohne Abfrageteil; sonst die Anschrift selbst. */ + static String dateiname(String anschrift) { + var ohneAbfrage = anschrift.split("[?#]", 2)[0]; + int schrägstrich = ohneAbfrage.lastIndexOf('/'); + var letztes = schrägstrich >= 0 ? ohneAbfrage.substring(schrägstrich + 1) : ohneAbfrage; + return letztes.isBlank() ? anschrift : letztes; + } +} diff --git a/src/test/java/eu/mulk/aendggner/BezugTest.java b/src/test/java/eu/mulk/aendggner/BezugTest.java new file mode 100644 index 0000000..6b54691 --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/BezugTest.java @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Geprüft wird, was ohne Netz zu prüfen ist: die Ableitung der Kennung, die Ableitung des Namens + * und der Vorrang der vorhandenen Datei. Der Abruf selbst gehört nicht in eine Prüfung, die auf + * jedem Rechner laufen soll. + */ +class BezugTest { + + /** + * Die Kennung des Portals: klein geschrieben, und was kein Buchstabe, keine Ziffer und kein + * Bindestrich ist, wird zum Unterstrich. So schreibt gesetze-im-internet.de seine Anschriften. + */ + @ParameterizedTest + @CsvSource({ + "UWG 2004, uwg_2004", + "uwg_2004, uwg_2004", + "GEG, geg", + "1-DM-GoldmünzG, 1-dm-goldm_nzg", + "' IfSG ', ifsg", + }) + void leitetDieKennungAb(String angabe, String erwartet) { + assertThat(Bezug.kennung(angabe)).isEqualTo(erwartet); + } + + @ParameterizedTest + @CsvSource({ + "https://dserver.bundestag.de/btd/21/018/2101855.pdf, 2101855.pdf", + "https://www.gesetze-im-internet.de/uwg_2004/xml.zip, xml.zip", + "https://example.org/heft.pdf?fassung=2#seite3, heft.pdf", + }) + void leitetDenNamenAusDerAnschriftAb(String anschrift, String erwartet) { + assertThat(Bezug.dateiname(anschrift)).isEqualTo(erwartet); + } + + /** Die vorhandene Datei hat den Vorrang: Wer eine Datei „gii:etwas“ nennt, meint sie. */ + @Test + void nimmtDieVorhandeneDateiVorJederDeutung() throws IOException, InterruptedException { + var datei = Path.of("src/test/resources/sampledata/Brandenburg/FraktG-alt.txt"); + assumeTrue(Files.exists(datei), "Beispieldaten fehlen"); + + var quelle = Bezug.hole(datei.toString()); + + assertThat(quelle.name()).isEqualTo("FraktG-alt.txt"); + assertThat(quelle.inhalt()).isEqualTo(Files.readAllBytes(datei)); + } +} |
