diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62a8e5d..6eedee8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
---
+## [2.11.3] — 2026-06-28
+
+UX-/Korrektur-Fixes aus Praxis-Feedback bei der Live-Installation.
+
+### 🐛 Fixed
+- **Hilfe-Tooltip (ⓘ) sprang in die nächste Zeile:** Das ⓘ-Symbol wird jetzt **innerhalb des Feld-Labels** verankert und sitzt damit in derselben Zeile wie die Formularfrage (zuvor als Geschwister-Element unter dem block-level Label umgebrochen).
+- **CESSDA-Themenklassifikation ohne Erklärung:** Da das eigentliche CF-Feld (mit Hilfetext) ausgeblendet ist, hatte das JS-gebaute Eingabe-Widget keinen Tooltip. Es erhält nun denselben ⓘ-Tooltip mit Erklärung **(dct:subject)** inkl. Handlungsaufforderung („Thema eintippen oder auswählen"). Die URI wird weiterhin automatisch verknüpft.
+- **DCAT-AP-Namen in der Qualitätsprüfung ergänzt:** Die Indikatoren *Beschreibung*, *Herausgeber* und *Lizenz* trugen in der Registry (`config/dcat-ap-fields.php`) noch die reine Formularfrage als Label; sie heißen nun einheitlich `Beschreibung (dct:description)`, `Herausgeber (dct:publisher)` und `Lizenz (dct:license)` — konsistent mit den übrigen Indikatoren (wirkt auch auf Validierungsmeldungen).
+- **Qualitäts-Score wird beim ersten Veröffentlichen/Speichern korrekt berechnet:** Der Neuberechnungs-Hook lief auf `save_post_odw_dataset`, das WordPress **vor** dem generischen `save_post` feuert — genau dort speichert Carbon Fields aber erst seine Meta-Werte (Priorität 10). Dadurch wurde der Score aus veralteten Daten berechnet und stimmte erst nach dem **zweiten** Speichern. Der Hook läuft nun auf `save_post` (Priorität 30, nach dem CF-Save) mit Post-Type-Guard. Batch-Importe berechnen die Qualität nun ebenfalls explizit nach dem Schreiben der Metadaten.
+
+### ✅ Tests
+- Neuer Registry-Test stellt sicher, dass jedes Label seinen DCAT-AP-Property-Namen trägt.
+
+---
+
## [2.11.2] — 2026-06-28
### 🔧 Changed
diff --git a/README.md b/README.md
index 42b55d7..e6c56d2 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@



-
+

📖 [Dokumentation](DOCUMENTATION.md) · 📐 [Technische Spezifikation](TECHNICAL-SPEC.md) · 📝 [Changelog](CHANGELOG.md) · 🛡️ [Security](SECURITY.md) · ⚖️ [Lizenz](LICENSE)
diff --git a/assets/js/odw-admin-fields.js b/assets/js/odw-admin-fields.js
index 67c2c9c..d0069e7 100644
--- a/assets/js/odw-admin-fields.js
+++ b/assets/js/odw-admin-fields.js
@@ -115,6 +115,13 @@
lbl.className = 'odw-cessda-label';
lbl.textContent = w.label || '';
+ // The real CF field (with its help text) is hidden, so attach the same
+ // ⓘ tooltip here to explain what the CESSDA classification is.
+ if ( w.help ) {
+ var tipLabel = ( data.helpTip && data.helpTip.label ) || '';
+ lbl.appendChild( buildHelpTip( helpTextNode( w.help ), tipLabel ) );
+ }
+
var input = document.createElement( 'input' );
input.type = 'text';
input.className = 'odw-cessda-input';
@@ -415,6 +422,66 @@
// label one hover/click away. Progressive enhancement: without JS the help
// text simply stays inline.
// -------------------------------------------------------------------------
+ // One document-level handler closes any open click-tooltip on outside tap.
+ function ensureTipDocHandler() {
+ if ( document.body.dataset.odwTipDocBound ) {
+ return;
+ }
+ document.body.dataset.odwTipDocBound = '1';
+ document.addEventListener( 'click', function ( e ) {
+ document.querySelectorAll( '.odw-help-tip-wrap.is-open' ).forEach( function ( w ) {
+ if ( ! w.contains( e.target ) ) {
+ w.classList.remove( 'is-open' );
+ var b = w.querySelector( '.odw-help-tip' );
+ if ( b ) {
+ b.setAttribute( 'aria-expanded', 'false' );
+ }
+ }
+ } );
+ } );
+ }
+
+ // Build an ⓘ tooltip wrapper around a content node. Reused by the field help
+ // tooltips and the JS-built widgets (e.g. CESSDA) so they look identical.
+ function buildHelpTip( contentNode, tipLabel ) {
+ ensureTipDocHandler();
+
+ var wrap = document.createElement( 'span' );
+ wrap.className = 'odw-help-tip-wrap';
+
+ var btn = document.createElement( 'button' );
+ btn.type = 'button';
+ btn.className = 'odw-help-tip';
+ btn.setAttribute( 'aria-label', tipLabel || 'Hilfe' );
+ btn.setAttribute( 'aria-expanded', 'false' );
+ btn.innerHTML = 'i';
+
+ var pop = document.createElement( 'span' );
+ pop.className = 'odw-help-pop';
+ pop.setAttribute( 'role', 'tooltip' );
+ pop.appendChild( contentNode );
+
+ wrap.appendChild( btn );
+ wrap.appendChild( pop );
+
+ // Click toggles for touch/keyboard; hover/focus is handled in CSS.
+ btn.addEventListener( 'click', function ( e ) {
+ e.preventDefault();
+ var open = wrap.classList.toggle( 'is-open' );
+ btn.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
+ } );
+
+ return wrap;
+ }
+
+ // Build a popup content node from a plain (multi-line) help string.
+ function helpTextNode( text ) {
+ var span = document.createElement( 'span' );
+ span.className = 'odw-help-pop__content';
+ span.textContent = text; // CSS white-space:pre-line preserves line breaks.
+ return span;
+ }
+
function initHelpTooltips() {
var tip = data.helpTip || {};
document.querySelectorAll( '.cf-field' ).forEach( function ( field ) {
@@ -427,62 +494,26 @@
if ( ! help || ! ( help.textContent || '' ).trim() ) {
return;
}
- var head = field.querySelector( ':scope > .cf-field__head' ) || field.querySelector( ':scope > label' );
- if ( ! head ) {
+ // Anchor the ⓘ inside the label so it sits inline with the question
+ // text (the head/label is block-level, so a sibling would wrap below).
+ var anchor = field.querySelector( ':scope > .cf-field__head > .cf-field__label' ) ||
+ field.querySelector( ':scope > .cf-field__head' ) ||
+ field.querySelector( ':scope > label' );
+ if ( ! anchor ) {
return;
}
// The attribute both guards re-processing and drives the CSS rule that
// hides the inline help (robust even if React re-renders the ).
field.dataset.odwTipInit = '1';
- var wrap = document.createElement( 'span' );
- wrap.className = 'odw-help-tip-wrap';
-
- var btn = document.createElement( 'button' );
- btn.type = 'button';
- btn.className = 'odw-help-tip';
- btn.setAttribute( 'aria-label', tip.label || 'Hilfe' );
- btn.setAttribute( 'aria-expanded', 'false' );
- btn.innerHTML = 'i';
-
- var pop = document.createElement( 'span' );
- pop.className = 'odw-help-pop';
- pop.setAttribute( 'role', 'tooltip' );
-
// Clone (not move) the React-owned help node into the popup so its
// exact content/formatting is preserved while React keeps owning the
- // original, which the CSS rule above hides from the inline flow.
+ // original, which the CSS rule hides from the inline flow.
var content = help.cloneNode( true );
content.className = 'odw-help-pop__content';
- pop.appendChild( content );
-
- wrap.appendChild( btn );
- wrap.appendChild( pop );
- head.appendChild( wrap );
- // Click toggles for touch/keyboard; hover/focus is handled in CSS.
- btn.addEventListener( 'click', function ( e ) {
- e.preventDefault();
- var open = wrap.classList.toggle( 'is-open' );
- btn.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
- } );
+ anchor.appendChild( buildHelpTip( content, tip.label ) );
} );
-
- // One document-level handler closes any open click-tooltip on outside tap.
- if ( ! document.body.dataset.odwTipDocBound ) {
- document.body.dataset.odwTipDocBound = '1';
- document.addEventListener( 'click', function ( e ) {
- document.querySelectorAll( '.odw-help-tip-wrap.is-open' ).forEach( function ( w ) {
- if ( ! w.contains( e.target ) ) {
- w.classList.remove( 'is-open' );
- var b = w.querySelector( '.odw-help-tip' );
- if ( b ) {
- b.setAttribute( 'aria-expanded', 'false' );
- }
- }
- } );
- } );
- }
}
// -------------------------------------------------------------------------
diff --git a/config/dcat-ap-fields.php b/config/dcat-ap-fields.php
index 7c802ea..5d2d3cf 100644
--- a/config/dcat-ap-fields.php
+++ b/config/dcat-ap-fields.php
@@ -59,7 +59,7 @@
'key' => 'description',
'meta_key' => '_odw_description',
'dcat_prop' => 'dct:description',
- 'label' => __( 'Worum geht es in diesem Datensatz?', 'open-data-wizard' ),
+ 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ),
'points' => 10,
'required' => true,
'profile' => 'ap',
@@ -73,7 +73,7 @@
'key' => 'publisher',
'meta_key' => '_odw_publisher',
'dcat_prop' => 'dct:publisher',
- 'label' => __( 'Wer gibt diese Daten heraus?', 'open-data-wizard' ),
+ 'label' => __( 'Herausgeber (dct:publisher)', 'open-data-wizard' ),
'points' => 10,
'required' => true,
'profile' => 'ap',
@@ -87,7 +87,7 @@
'key' => 'license',
'meta_key' => '',
'dcat_prop' => 'dct:license',
- 'label' => __( 'Unter welcher Lizenz sind diese Daten verfügbar?', 'open-data-wizard' ),
+ 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ),
'points' => 10,
'required' => true,
'profile' => 'ap',
diff --git a/includes/class-admin.php b/includes/class-admin.php
index be781fe..0f502d0 100644
--- a/includes/class-admin.php
+++ b/includes/class-admin.php
@@ -378,6 +378,7 @@ public static function enqueue_assets( string $hook ): void {
'label' => __( 'CESSDA Themenklassifikation', 'open-data-wizard' ),
'placeholder' => __( 'Thema eintippen oder auswählen…', 'open-data-wizard' ),
'linkLabel' => __( 'Verknüpfte URI:', 'open-data-wizard' ),
+ 'help' => __( 'CESSDA-THEMENKLASSIFIKATION (dct:subject)', 'open-data-wizard' ) . "\n\n" . __( 'Fachliche Einordnung Ihres Datensatzes aus dem CESSDA Controlled Vocabulary (Version 4.2.3, Deutsch). Tippen Sie ein Thema ein oder wählen Sie eines aus der Liste — die passende URI wird automatisch verknüpft. Beispiel: Volkszählungen, Migration, Wirtschaftspolitik.', 'open-data-wizard' ),
),
'helpTip' => array(
'label' => __( 'Hilfe anzeigen', 'open-data-wizard' ),
diff --git a/includes/class-batch-import.php b/includes/class-batch-import.php
index ec86d6b..385e14d 100644
--- a/includes/class-batch-import.php
+++ b/includes/class-batch-import.php
@@ -489,6 +489,13 @@ private static function create_dataset_from_record( array $record, int $row_inde
// Set import tracking.
update_post_meta( $post_id, '_odw_imported', current_time( 'mysql' ) );
+ // Recalculate quality now that all meta is written. wp_insert_post() above
+ // fired save_post before these meta values existed, so the score stored then
+ // would be stale; recompute explicitly to reflect the imported metadata.
+ if ( class_exists( 'ODW_Quality' ) ) {
+ ODW_Quality::store( $post_id, ODW_Quality::calculate( $post_id ) );
+ }
+
return array(
'success' => true,
'post_id' => $post_id,
diff --git a/includes/class-quality.php b/includes/class-quality.php
index 2fb87d8..2dac924 100644
--- a/includes/class-quality.php
+++ b/includes/class-quality.php
@@ -39,8 +39,13 @@ class ODW_Quality {
* Registers WordPress hooks.
*/
public static function init(): void {
- // Qualität nach jedem echten Speichern neu berechnen (nach CF-Save und set_modified_date).
- add_action( 'save_post_odw_dataset', array( self::class, 'recalculate_on_save' ), 30 );
+ // Qualität nach jedem echten Speichern neu berechnen. Wichtig: das generische
+ // save_post (Priorität 30) statt save_post_odw_dataset verwenden — WordPress
+ // feuert save_post_{post_type} VOR dem generischen save_post, an dem Carbon
+ // Fields seine Meta-Werte erst speichert (Priorität 10). Auf save_post_odw_dataset
+ // liefen wir vor dem CF-Save und lasen veraltete Meta (Score erst beim 2. Speichern
+ // korrekt). Priorität 30 garantiert, dass CF (10) bereits geschrieben hat.
+ add_action( 'save_post', array( self::class, 'recalculate_on_save' ), 30 );
// Meta-Box auf dem Edit-Screen registrieren.
add_action( 'add_meta_boxes', array( self::class, 'register_meta_box' ) );
@@ -332,6 +337,11 @@ public static function recalculate_on_save( int $post_id ): void {
return;
}
+ // Now hooked on the generic save_post, so guard the post type explicitly.
+ if ( 'odw_dataset' !== get_post_type( $post_id ) ) {
+ return;
+ }
+
$result = self::calculate( $post_id );
self::store( $post_id, $result );
}
diff --git a/languages/open-data-wizard-en_US.mo b/languages/open-data-wizard-en_US.mo
index 53bbdaf..da61890 100644
Binary files a/languages/open-data-wizard-en_US.mo and b/languages/open-data-wizard-en_US.mo differ
diff --git a/languages/open-data-wizard-en_US.po b/languages/open-data-wizard-en_US.po
index 8384f60..738f069 100644
--- a/languages/open-data-wizard-en_US.po
+++ b/languages/open-data-wizard-en_US.po
@@ -1235,3 +1235,9 @@ msgstr "The JSON-LD view below shows the last saved result."
msgid "Speichern Sie den Datensatz, um sie zu aktualisieren."
msgstr "Save the dataset to update it."
+
+msgid "CESSDA-THEMENKLASSIFIKATION (dct:subject)"
+msgstr "CESSDA TOPIC CLASSIFICATION (dct:subject)"
+
+msgid "Fachliche Einordnung Ihres Datensatzes aus dem CESSDA Controlled Vocabulary (Version 4.2.3, Deutsch). Tippen Sie ein Thema ein oder wählen Sie eines aus der Liste — die passende URI wird automatisch verknüpft. Beispiel: Volkszählungen, Migration, Wirtschaftspolitik."
+msgstr "A subject classification of your dataset from the CESSDA Controlled Vocabulary (version 4.2.3, German). Type a topic or pick one from the list — the matching URI is linked automatically. Example: censuses, migration, economic policy."
diff --git a/languages/open-data-wizard.pot b/languages/open-data-wizard.pot
index 2c54c2f..db45c79 100644
--- a/languages/open-data-wizard.pot
+++ b/languages/open-data-wizard.pot
@@ -1232,3 +1232,9 @@ msgstr ""
msgid "Speichern Sie den Datensatz, um sie zu aktualisieren."
msgstr ""
+
+msgid "CESSDA-THEMENKLASSIFIKATION (dct:subject)"
+msgstr ""
+
+msgid "Fachliche Einordnung Ihres Datensatzes aus dem CESSDA Controlled Vocabulary (Version 4.2.3, Deutsch). Tippen Sie ein Thema ein oder wählen Sie eines aus der Liste — die passende URI wird automatisch verknüpft. Beispiel: Volkszählungen, Migration, Wirtschaftspolitik."
+msgstr ""
diff --git a/open-data-wizard.php b/open-data-wizard.php
index b485cf5..825e383 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.11.2
+ * Version: 2.11.3
* Requires at least: 6.4
* Requires PHP: 8.1
* Author: nozilla
@@ -26,7 +26,7 @@
exit;
}
-define( 'ODW_VERSION', '2.11.2' );
+define( 'ODW_VERSION', '2.11.3' );
define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'ODW_PLUGIN_FILE', __FILE__ );
diff --git a/tests/test-registry-schema.php b/tests/test-registry-schema.php
index 6ea2e21..fc778a1 100644
--- a/tests/test-registry-schema.php
+++ b/tests/test-registry-schema.php
@@ -109,4 +109,23 @@ public function test_keys_are_unique(): void {
$keys = array_column( $this->defs, 'key' );
$this->assertSame( count( $keys ), count( array_unique( $keys ) ) );
}
+
+ /**
+ * Every label carries its DCAT-AP property name so the quality report and
+ * validation messages stay consistent (regression: description/publisher/
+ * license previously used the bare form question without the dct/dcat name).
+ */
+ public function test_labels_include_dcat_property_name(): void {
+ foreach ( $this->defs as $entry ) {
+ $prop = (string) ( $entry['dcat_prop'] ?? '' );
+ if ( '' === $prop ) {
+ continue;
+ }
+ $this->assertStringContainsString(
+ '(' . $prop . ')',
+ (string) $entry['label'],
+ "Label for '{$entry['key']}' must include its DCAT-AP property name {$prop}."
+ );
+ }
+ }
}