diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a59e29..857cc5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht --- +## [2.42.1] — 2026-09-17 + +Der empfohlene Harvest-Endpunkt lieferte Turtle, das **kein RDF-Parser lesen konnte** — und +zwar seit es den Qualitätsblock gibt. Aufgefallen beim Testen der Live-Ausgabe. + +### 🐛 Fixed +- **Ungültiges Turtle im gesamten Katalog.** Die Dimensionsnamen im `odw:qualityScore`-Block + standen als nackte Bezeichner (`findability [ … ]`) da. Ein Prädikat muss in Turtle ein IRI + oder ein Präfixname sein; der Parser bricht an dieser Stelle ab und verwirft **das ganze + Dokument**, nicht nur die eine Zeile. Belegt mit dem echten Abruf einer Installation: + `Unexpected "findability" on line 34` — dasselbe Dokument ohne den Block parst mit 25 Tripeln + sauber. Die Namen tragen jetzt den `odw:`-Präfix. + In JSON-LD fiel derselbe Schlüssel stillschweigend weg (kein Eintrag im `@context`), die + Dimensionen fehlten dort also schon immer in der Ausgabe. +- **Der Serializer kann kein ungültiges Prädikat mehr erzeugen.** `ODW_Rdf` schreibt einen + Schlüssel nur noch aus, wenn er ein IRI oder ein Präfixname ist, und lässt ihn sonst weg. Eine + einzelne verlorene Zusatzangabe ist ungleich harmloser als ein Katalog, den niemand einlesen + kann. Ein vollständiges IRI als Schlüssel wird dabei korrekt in spitze Klammern gesetzt. +- **Die Datensatz-Fixtures hatten keine `@prefix`-Zeilen.** Sie wurden ohne `@context` + serialisiert und waren damit ebenfalls unparsbar — was nie auffiel, siehe unten. Der neue + `ODW_Rest_API::build_dataset_document()` setzt den Kontext an einer Stelle, die Fixture und + Endpunkt gemeinsam nutzen. + +### 🧪 Warum das grün durch die CI kam +Der SHACL-Job erzeugte zwar Turtle-Dateien, **validierte aber nur die JSON-LD-Fixtures** — die +`.ttl`-Dateien wurden nie geparst. Dazu kam, dass der Qualitätsblock in den Fixtures überhaupt +nicht vorkam, weil der Generator den Filter `odw_dataset_jsonld` nicht durchlief. Die einzige +kaputte Stelle lag damit exakt im blinden Fleck. Beides ist behoben: +- `tests/shacl/validate.mjs` prüft jetzt **beide Serialisierungen**; ein Syntaxfehler in der + Turtle-Datei lässt den Lauf scheitern. +- Die maximale Fixture trägt einen gespeicherten MQA-Stand und durchläuft + `ODW_Quality::append_to_jsonld()` — der Qualitätsblock steht also in der geprüften Ausgabe. +- Zwei Unit-Tests halten die Stellen fest: die Dimensionsnamen als Präfixnamen und das + Verwerfen ungültiger Prädikate im Serializer. Der Serializer-Schutz allein hätte den Fehler + in ein stilles `odw:dimensions []` verwandelt — deshalb beides. +- Ein bestehender Test *hatte* den Qualitätsblock geprüft — und die falsche Form festgeschrieben: + Er verlangte den Schlüssel `findability`. Eine Zusicherung, die den Ist-Zustand abbildet statt + einer Anforderung, meldet den Fehler nicht, sondern hält ihn fest. Die Zusicherung steht jetzt + auf `odw:findability`. + +--- + ## [2.42.0] — 2026-09-17 Beim Testen des Harvestings kam ein nacktes `rest_no_route` zurück — ohne jeden Hinweis, woran diff --git a/CLAUDE.md b/CLAUDE.md index b4b6efe..2054e6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -765,7 +765,7 @@ Update **both** locations: - MINOR: New features (backward-compatible) - PATCH: Bug fixes only -Current: **v2.42.0** +Current: **v2.42.1** --- diff --git a/README.md b/README.md index cffa32f..80445a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Lizenz - Version + Version DCAT-AP 3.0 PHP >= 8.1 WordPress 6.4+ diff --git a/includes/class-quality.php b/includes/class-quality.php index 6c0d94f..7cdc972 100644 --- a/includes/class-quality.php +++ b/includes/class-quality.php @@ -677,9 +677,14 @@ public static function append_to_jsonld( array $dataset, int $post_id ): array { return $dataset; } + // Der Dimensionsname muss ein Präfixname sein, kein nackter Bezeichner: + // In JSON-LD fällt ein Schlüssel ohne Zuordnung im @context stillschweigend + // weg, in Turtle ergäbe er ein ungültiges Prädikat — und damit ein Dokument, + // das kein Harvester mehr liest. Bis v2.42.0 stand hier „findability" statt + // „odw:findability", wodurch der gesamte Turtle-Katalog unparsbar war. $dimensions = array(); foreach ( (array) ( $quality['dimensions'] ?? array() ) as $dim => $data ) { - $dimensions[ $dim ] = array( + $dimensions[ 'odw:' . $dim ] = array( 'odw:score' => (int) ( $data['achieved'] ?? 0 ), 'odw:assessable' => (int) ( $data['assessable'] ?? 0 ), 'odw:maxScore' => (int) ( $data['max'] ?? 0 ), diff --git a/includes/class-rdf.php b/includes/class-rdf.php index 46e87c1..afcb86c 100644 --- a/includes/class-rdf.php +++ b/includes/class-rdf.php @@ -141,6 +141,15 @@ private static function render_predicates( array $node, array &$subjects, array continue; } + // Ein Prädikat muss in Turtle ein IRI oder ein Präfixname sein. Ein + // nackter Bezeichner ist beides nicht — und er beschädigt nicht bloß + // seine eigene Zeile, sondern macht das ganze Dokument unparsbar. + // Lieber die eine Aussage auslassen als den Katalog unbrauchbar machen. + $predicate = self::predicate( (string) $key ); + if ( '' === $predicate ) { + continue; + } + $object = self::render_object( $value, $subjects, $seen ); // Eine leere Liste (z. B. ein Katalog ohne veröffentlichte Datensätze) @@ -150,7 +159,7 @@ private static function render_predicates( array $node, array &$subjects, array continue; } - $parts[] = $key . ' ' . $object; + $parts[] = $predicate . ' ' . $object; } return $parts; @@ -259,6 +268,36 @@ private static function has_properties( array $node ): bool { return false; } + /** + * Einen Schlüssel als Turtle-Prädikat ausgeben — oder '' , wenn er keines sein kann. + * + * Zulässig sind ein absolutes IRI (kommt in spitze Klammern) und ein + * Präfixname wie `dcat:theme`. Bewusst enger gefasst als die Turtle-Grammatik + * (keine Escape-Sequenzen, kein Punkt am Ende des lokalen Teils): Was hier + * durchfällt, stammt aus einem Fehler im Aufbau des Dokuments und gehört + * nicht in die Ausgabe. + * + * @param string $key Schlüssel aus dem JSON-LD-Knoten. + * @return string Prädikat oder '' , wenn der Schlüssel keines ergibt. + */ + private static function predicate( string $key ): string { + $key = trim( $key ); + + if ( '' === $key ) { + return ''; + } + + if ( preg_match( '#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $key ) ) { + return '<' . self::escape_iri( $key ) . '>'; + } + + if ( preg_match( '/^[A-Za-z][A-Za-z0-9._-]*:[A-Za-z0-9_](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/', $key ) ) { + return $key; + } + + return ''; + } + /** * Render a type/predicate term: a CURIE (`dcat:Dataset`) stays as-is, a full * IRI becomes ``. diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index b39cb19..f860a72 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -274,6 +274,21 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response return self::catalog_response( $catalog, $total, $pages, $format, 'MISS', $cache_key ); } + /** + * Setzt den @context vor einen Datensatz-Knoten. + * + * Ausgelagert, damit die SHACL-Fixtures denselben Weg gehen wie die Antwort + * des Endpunkts. Vorher baute die Fixture den Knoten ohne Kontext — die + * erzeugte Turtle-Datei hatte dadurch keine @prefix-Zeilen und war gar nicht + * parsbar, was niemandem auffiel, weil die Prüfung nur das JSON-LD ansah. + * + * @param array $dataset Dataset JSON-LD node. + * @return array + */ + public static function build_dataset_document( array $dataset ): array { + return array_merge( array( '@context' => self::JSONLD_CONTEXT ), $dataset ); + } + /** * Assemble the dcat:Catalog JSON-LD document from the given dataset nodes. * @@ -617,10 +632,7 @@ public static function get_dataset( WP_REST_Request $request ): WP_REST_Response ); } - $body = array_merge( - array( '@context' => self::JSONLD_CONTEXT ), - $dataset - ); + $body = self::build_dataset_document( $dataset ); set_transient( $cache_key, $body, self::get_cache_ttl() ); diff --git a/open-data-wizard.php b/open-data-wizard.php index 3911f50..58d971e 100644 --- a/open-data-wizard.php +++ b/open-data-wizard.php @@ -3,7 +3,7 @@ * Plugin Name: Open Data Wizard * Plugin URI: https://github.com/daimpad/OpenDataWizard * Description: DCAT-AP 3.0 konforme Open Data Metadatenverwaltung für WordPress. Bereitstellung als maschinenlesbarer JSON-LD-Endpoint für offene Daten. - * Version: 2.42.0 + * Version: 2.42.1 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: nozilla @@ -26,7 +26,7 @@ exit; } -define( 'ODW_VERSION', '2.42.0' ); +define( 'ODW_VERSION', '2.42.1' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); diff --git a/package.json b/package.json index e318b19..0976c39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-data-wizard", - "version": "2.42.0", + "version": "2.42.1", "description": "WordPress plugin for publishing open data as DCAT-AP 3.0 compliant metadata", "author": "Open Data Wizard Team", "license": "GPL-2.0-or-later", diff --git a/tests/shacl/generate-fixtures.php b/tests/shacl/generate-fixtures.php index c6c75e2..907ef88 100755 --- a/tests/shacl/generate-fixtures.php +++ b/tests/shacl/generate-fixtures.php @@ -192,6 +192,7 @@ function set_transient( string $key, $value, int $expiry = 0 ): bool { // phpcs: require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; require_once ODW_PLUGIN_DIR . 'includes/class-rdf.php'; require_once ODW_PLUGIN_DIR . 'includes/class-rest-api.php'; +require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; // ---------------------------------------------------------- // Fixture data definitions @@ -284,6 +285,45 @@ function fixture_maximal(): array { 'odw_conforms_to' => 'https://schema.org/Dataset', 'odw_provenance' => 'Daten aus amtlichen Erhebungen gemäß Bundesstatistikgesetz', 'odw_access_rights' => 'http://publications.europa.eu/resource/authority/access-right/PUBLIC', + + // Gespeicherter MQA-Stand: ODW_Quality::append_to_jsonld() hängt daraus den + // odw:qualityScore-Block an. Der gehört in die Fixture, weil er in echten + // Antworten steht — und weil genau dieser Block in v2.42.0 ungültiges + // Turtle erzeugte, ohne dass eine Prüfung es bemerkt hätte. + '_odw_mqa' => array( + 'achieved' => 165, + 'assessable' => 295, + 'max' => 405, + 'rating' => 'good', + 'calculated_at' => '2026-09-17 12:00:00', + 'dimensions' => array( + 'findability' => array( + 'achieved' => 30, + 'assessable' => 100, + 'max' => 100, + ), + 'accessibility' => array( + 'achieved' => 0, + 'assessable' => 20, + 'max' => 100, + ), + 'interoperability' => array( + 'achieved' => 70, + 'assessable' => 80, + 'max' => 110, + ), + 'reusability' => array( + 'achieved' => 55, + 'assessable' => 75, + 'max' => 75, + ), + 'contextuality' => array( + 'achieved' => 10, + 'assessable' => 20, + 'max' => 20, + ), + ), + ), 'odw_availability' => 'http://publications.europa.eu/resource/authority/planned-availability/STABLE', // Distribution fields. @@ -387,6 +427,13 @@ function generate_fixture( string $name, array $meta, bool $is_catalog = false ) $doc = build_catalog_fixture( $datasets ); } else { $doc = odw_build_dataset_jsonld( 1 ); + if ( null !== $doc ) { + // Denselben Weg gehen wie der Endpunkt: erst der Qualitätsblock (den + // das Plugin über den Filter odw_dataset_jsonld anhängt), dann der + // @context. Ohne den Kontext hätte die Turtle-Datei keine Präfixe. + $doc = ODW_Quality::append_to_jsonld( $doc, 1 ); + $doc = ODW_Rest_API::build_dataset_document( $doc ); + } } if ( null === $doc ) { diff --git a/tests/shacl/validate.mjs b/tests/shacl/validate.mjs index db27505..3869c31 100644 --- a/tests/shacl/validate.mjs +++ b/tests/shacl/validate.mjs @@ -57,10 +57,20 @@ const TIERS = { }; // Fixtures to validate. +// +// Beide Serialisierungen, nicht nur JSON-LD: Die Turtle-Ausgabe ist das, was der +// Harvest-Endpunkt als empfohlenes Format liefert, und sie hatte bis v2.42.0 +// überhaupt keine Prüfung. Ein ungültiges Prädikat macht in Turtle das *ganze* +// Dokument unparsbar, während JSON-LD denselben Schlüssel stillschweigend +// verwirft — grüne JSON-LD-Prüfung bei kaputtem Turtle ist also kein Widerspruch, +// sondern genau der Fall, der eintrat. const FIXTURES = [ 'build/shacl/dataset-minimal.jsonld', 'build/shacl/dataset-maximal.jsonld', 'build/shacl/catalog.jsonld', + 'build/shacl/dataset-minimal.ttl', + 'build/shacl/dataset-maximal.ttl', + 'build/shacl/catalog.ttl', ]; /** @@ -280,7 +290,12 @@ async function main() { } console.log(`Validating ${fixturePath}...`); - const dataGraph = await loadJsonLd(fixturePath); + // Ein Syntaxfehler in der Turtle-Datei wirft hier — und soll das auch: + // Ein Dokument, das kein Parser liest, ist schlimmer als eines mit + // SHACL-Verstößen. + const dataGraph = fixturePath.endsWith('.ttl') + ? await loadRdf(fixturePath) + : await loadJsonLd(fixturePath); const violationCount = await validateFixture(dataGraph, shapesGraph, fixturePath, allowlist); totalViolations += violationCount; } diff --git a/tests/test-quality.php b/tests/test-quality.php index 42a0124..5ca91bf 100644 --- a/tests/test-quality.php +++ b/tests/test-quality.php @@ -420,7 +420,11 @@ public function test_append_to_jsonld_adds_mqa_data(): void { $this->assertSame( 200, $result['odw:qualityScore']['odw:score'] ); $this->assertSame( 'good', $result['odw:qualityScore']['odw:rating'] ); $this->assertSame( 405, $result['odw:qualityScore']['odw:maxScore'] ); - $this->assertArrayHasKey( 'findability', $result['odw:qualityScore']['odw:dimensions'] ); + // Mit Präfix: Ohne ihn ist der Dimensionsname in Turtle kein gültiges + // Prädikat und in JSON-LD kein bekannter Begriff. Diese Zusicherung stand + // bis v2.42.0 auf dem nackten Namen und hat den Fehler dadurch festgehalten, + // statt ihn zu melden. + $this->assertArrayHasKey( 'odw:findability', $result['odw:qualityScore']['odw:dimensions'] ); } /** @@ -658,4 +662,58 @@ public function test_calculate_scores_a_fresh_form_at_zero(): void { $this->assertSame( 0, $result['score'] ); $this->assertGreaterThan( 0, $result['assessable'], 'Die Metriken bleiben bewertbar, sie sind nur nicht erfüllt.' ); } + + /** + * Die Dimensionsnamen im Qualitätsblock müssen Präfixnamen sein. + * + * Bis v2.42.0 standen dort nackte Bezeichner („findability"). In JSON-LD fällt + * ein Schlüssel ohne Zuordnung im @context stillschweigend weg, in Turtle + * ergibt er ein ungültiges Prädikat — das gesamte Dokument war damit für + * Harvester unlesbar. Der Serializer wirft solche Schlüssel inzwischen weg, + * das rettet aber nur die Syntax: Ohne diesen Test verschwänden die + * Dimensionen wieder unbemerkt aus der Ausgabe. + */ + public function test_quality_dimensions_use_prefixed_predicates(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_post_meta' )->andReturn( + array( + 'achieved' => 165, + 'assessable' => 295, + 'max' => 405, + 'rating' => 'good', + 'calculated_at' => '2026-09-17 12:00:00', + 'dimensions' => array( + 'findability' => array( + 'achieved' => 30, + 'assessable' => 100, + 'max' => 100, + ), + 'accessibility' => array( + 'achieved' => 0, + 'assessable' => 20, + 'max' => 100, + ), + ), + ) + ); + + $dataset = ODW_Quality::append_to_jsonld( array( '@type' => 'dcat:Dataset' ), 42 ); + + $this->assertArrayHasKey( 'odw:qualityScore', $dataset ); + + $dimensionen = $dataset['odw:qualityScore']['odw:dimensions']; + $this->assertNotEmpty( $dimensionen ); + + foreach ( array_keys( $dimensionen ) as $schluessel ) { + $this->assertMatchesRegularExpression( + '/^odw:[A-Za-z]+$/', + (string) $schluessel, + 'Dimensionsname ist kein Präfixname und damit kein gültiges Prädikat: ' . $schluessel + ); + } + + $this->assertArrayHasKey( 'odw:findability', $dimensionen ); + $this->assertSame( 30, $dimensionen['odw:findability']['odw:score'] ); + } } diff --git a/tests/test-rdf.php b/tests/test-rdf.php index bd4c31c..dbbe910 100644 --- a/tests/test-rdf.php +++ b/tests/test-rdf.php @@ -174,4 +174,43 @@ public function test_multiple_types(): void { $this->assertStringContainsString( ' a foaf:Agent, foaf:Organization .', $ttl ); } + + /** + * Ein Schlüssel, der kein Prädikat sein kann, fliegt raus — statt das ganze + * Dokument zu zerstören. + * + * In Turtle muss ein Prädikat ein IRI oder ein Präfixname sein. Ein nackter + * Bezeichner beschädigt nicht nur seine Zeile: Der Parser bricht ab und liest + * das komplette Dokument nicht mehr. Genau das passierte in v2.42.0 mit den + * Dimensionsnamen des Qualitätsblocks, wodurch der empfohlene Turtle-Katalog + * für jeden Harvester unbrauchbar war. + */ + public function test_invalid_predicate_is_dropped_not_emitted(): void { + $doc = array( + '@context' => array( 'dct' => 'http://purl.org/dc/terms/' ), + '@id' => 'https://example.org/x', + 'dct:title' => 'Titel', + 'kaputt' => 'Wert', + ); + + $turtle = ODW_Rdf::to_turtle( $doc ); + + $this->assertStringContainsString( 'dct:title "Titel"', $turtle ); + $this->assertStringNotContainsString( 'kaputt', $turtle ); + } + + /** + * Ein Schlüssel als vollständiges IRI kommt in spitze Klammern. + */ + public function test_full_iri_predicate_is_bracketed(): void { + $doc = array( + '@context' => array( 'dct' => 'http://purl.org/dc/terms/' ), + '@id' => 'https://example.org/x', + 'http://example.org/ns#gewicht' => '5', + ); + + $turtle = ODW_Rdf::to_turtle( $doc ); + + $this->assertStringContainsString( ' "5"', $turtle ); + } }