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 @@
-
+
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 );
+ }
}