Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

---

## [2.15.0] — 2026-07-07

MQA-Qualitäts-Scoring **Phase 3a**: optionale URL-Erreichbarkeitsprüfung.

### ✨ Added — URL-Erreichbarkeit (opt-in)
- Neue Einstellung **„URL-Erreichbarkeit prüfen"** (Bereich *Qualitätsprüfung (MQA)*,
standardmäßig deaktiviert). Ist sie aktiv, prüft das Plugin `dcat:accessURL` und
`dcat:downloadURL` beim Speichern per **HTTP HEAD** (mit GET-Fallback bei 405/501) auf
Erreichbarkeit (Statuscode 200–399).
- Ergebnisse werden **24 Stunden** als Transient zwischengespeichert (Cache-Key je URL),
sodass wiederholte Speichervorgänge keine erneuten Requests auslösen.
- Damit steigt das bewertbare Maximum bei aktivierter Prüfung von ~295 auf **~375 von 405**.
Es verbleibt nur die **DCAT-AP-SHACL-Konformität** (30 P) als „nicht bewertet".

### 🔒 Datenschutz/Performance
- Die Prüfung ist **opt-in** und sendet ausgehende Anfragen ausschließlich an die im Datensatz
hinterlegten URLs (kein Fremddienst). Kurzer Timeout (5 s) + 24h-Cache.

### 🧾 i18n
- 4 neue UI-Strings; `en_US.mo` neu kompiliert.

### ✅ Tests
- Erreichbarkeitslogik: Cache-Hit (kein Request) und 2xx→erreichbar (mit Caching).

---

## [2.14.0] — 2026-07-07

MQA-Qualitäts-Scoring **Phase 2**: Vokabular-Prüfungen (offline). Siehe `docs/MQA-KONZEPT.md`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-8892BF?style=flat-square&logo=php&logoColor=white)
![WordPress](https://img.shields.io/badge/WordPress-compatible-21759B?style=flat-square&logo=wordpress&logoColor=white)
![DCAT-AP](https://img.shields.io/badge/DCAT--AP-3.0-brightgreen?style=flat-square)
![Version](https://img.shields.io/badge/Version-2.14.0-brightgreen?style=flat-square)
![Version](https://img.shields.io/badge/Version-2.15.0-brightgreen?style=flat-square)
![PRs Welcome](https://img.shields.io/badge/PRs-willkommen-brightgreen?style=flat-square)

📖 [Dokumentation](DOCUMENTATION.md) · 📐 [Technische Spezifikation](TECHNICAL-SPEC.md) · 📝 [Changelog](CHANGELOG.md) · 🛡️ [Security](SECURITY.md) · ⚖️ [Lizenz](LICENSE)
Expand Down
69 changes: 67 additions & 2 deletions includes/class-quality.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,23 @@ private static function evaluate_metric( array $metric, \WP_Post $post ): ?bool
return self::check_vocab_metric( (string) $metric['check'], $post );
}

// Erreichbarkeits- und SHACL-Prüfungen folgen in Phase 3.
if ( 'reachable' === $type && self::url_checks_enabled() ) {
return self::check_reachable_metric( (string) $metric['check'], $post );
}

// SHACL (und Erreichbarkeit bei deaktivierter Einstellung) folgt in Phase 3+.
return null;
}

/**
* Ist die opt-in-URL-Erreichbarkeitsprüfung aktiviert?
*
* @return bool
*/
private static function url_checks_enabled(): bool {
return class_exists( 'ODW_Settings' ) && (bool) ODW_Settings::get( 'mqa_check_urls' );
}

/**
* „Ist die Eigenschaft gesetzt?"-Prüfung je Metrik.
*
Expand Down Expand Up @@ -361,6 +374,58 @@ private static function license_in_vocab( string $uri ): bool {
return isset( $extended[ $uri ] );
}

/**
* „Ist die referenzierte URL erreichbar?"-Prüfung (MQA Phase 3, opt-in).
*
* @param string $check Check-Schlüssel (access_url | download_url).
* @param \WP_Post $post Dataset post object.
* @return bool True wenn die URL per HTTP-HEAD einen 2xx/3xx-Status liefert.
*/
private static function check_reachable_metric( string $check, \WP_Post $post ): bool {
$field = 'download_url' === $check ? 'odw_download_url' : 'odw_access_url';
$url = trim( (string) carbon_get_post_meta( $post->ID, $field ) );

if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) {
return false;
}

return self::url_is_reachable( $url );
}

/**
* Prüft die Erreichbarkeit einer URL per HTTP HEAD (mit GET-Fallback), 24h gecacht.
*
* @param string $url Zu prüfende URL.
* @return bool True bei Statuscode 200–399.
*/
private static function url_is_reachable( string $url ): bool {
$cache_key = 'odw_mqa_reach_' . md5( $url );
$cached = get_transient( $cache_key );
if ( '1' === $cached || '0' === $cached ) {
return '1' === $cached;
}

$args = array(
'timeout' => 5,
'redirection' => 3,
'user-agent' => 'OpenDataWizard-MQA/1.0',
);

$response = wp_remote_head( $url, $args );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );

// Manche Server lehnen HEAD ab (405/501) — dann ein leichtgewichtiges GET versuchen.
if ( 405 === $code || 501 === $code || 0 === $code ) {
$response = wp_remote_get( $url, $args );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
}

$ok = $code >= 200 && $code < 400;
set_transient( $cache_key, $ok ? '1' : '0', DAY_IN_SECONDS );

return $ok;
}

/**
* Ermittelt die MQA-Bewertungsstufe aus erreichten/bewertbaren Punkten.
* Die MQA-Schwellen (351/221/121 von 405) werden proportional auf das
Expand Down Expand Up @@ -587,7 +652,7 @@ public static function render_meta_box( \WP_Post $post ): void {
echo esc_html(
sprintf(
/* translators: %d: number of points not yet assessed */
__( '%d Punkte werden derzeit nicht bewertet (URL-Erreichbarkeit und DCAT-AP-SHACL-Prüfung folgen).', 'open-data-wizard' ),
__( '%d Punkte konnten nicht automatisch bewertet werden (in der Tabelle mit „–" markiert).', 'open-data-wizard' ),
$not_assessed
)
);
Expand Down
33 changes: 33 additions & 0 deletions includes/class-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ static function (): void {

add_settings_field( 'cache_ttl', __( 'Cache-Laufzeit (Sekunden)', 'open-data-wizard' ), array( self::class, 'field_cache_ttl' ), 'odw-settings', 'odw_section_api' );

// --- Qualität (MQA) ---
add_settings_section(
'odw_section_quality',
__( 'Qualitätsprüfung (MQA)', 'open-data-wizard' ),
null,
'odw-settings'
);

add_settings_field( 'mqa_check_urls', __( 'URL-Erreichbarkeit prüfen', 'open-data-wizard' ), array( self::class, 'field_mqa_check_urls' ), 'odw-settings', 'odw_section_quality' );

// --- Deinstallation ---
add_settings_section(
'odw_section_uninstall',
Expand Down Expand Up @@ -290,6 +300,27 @@ class="small-text"
<?php
}

/**
* Renders the MQA URL-reachability settings field.
*/
public static function field_mqa_check_urls(): void {
$checked = (bool) self::get( 'mqa_check_urls' );
?>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( self::OPTION_KEY . '[mqa_check_urls]' ); ?>"
value="1"
<?php checked( $checked ); ?>
>
<?php esc_html_e( 'Zugriffs- und Download-URLs beim Speichern per HTTP-HEAD auf Erreichbarkeit prüfen (MQA-Zugänglichkeit, +80 Punkte).', 'open-data-wizard' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Sendet ausgehende Anfragen an die angegebenen Datensatz-URLs. Ergebnisse werden 24 Stunden zwischengespeichert. Standardmäßig deaktiviert.', 'open-data-wizard' ); ?>
</p>
<?php
}

/**
* Renders the delete-on-uninstall settings field.
*/
Expand Down Expand Up @@ -327,6 +358,7 @@ public static function sanitize( array $input ): array {
$output['default_publisher'] = sanitize_text_field( $input['default_publisher'] ?? '' );
$output['default_license'] = sanitize_text_field( $input['default_license'] ?? '' );
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] ) ? '1' : '0';
$output['mqa_check_urls'] = ! empty( $input['mqa_check_urls'] ) ? '1' : '0';

// Migrate legacy ISO language codes to EU language URIs.
$lang_raw = sanitize_text_field( $input['default_language'] ?? '' );
Expand Down Expand Up @@ -447,6 +479,7 @@ private static function get_defaults(): array {
'default_language' => '',
'cache_ttl' => 300,
'delete_on_uninstall' => '0',
'mqa_check_urls' => '0',
);
}
}
Binary file modified languages/open-data-wizard-en_US.mo
Binary file not shown.
12 changes: 12 additions & 0 deletions languages/open-data-wizard-en_US.po
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ msgstr " created and are then available for editing."
msgid "%1$d von %2$d Pflichtangaben ausgefüllt"
msgstr "%1$d of %2$d required fields completed"

msgid "%d Punkte konnten nicht automatisch bewertet werden (in der Tabelle mit „–\" markiert)."
msgstr "%d points could not be assessed automatically (marked with “–” in the table)."

msgid "%d Punkte werden derzeit nicht bewertet (URL-Erreichbarkeit und DCAT-AP-SHACL-Prüfung folgen)."
msgstr "%d points are currently not assessed (URL reachability and DCAT-AP SHACL checks are coming)."

Expand Down Expand Up @@ -1035,6 +1038,9 @@ msgstr "Keywords (dcat:keyword)"
msgid "Sekunden"
msgstr "Seconds"

msgid "Sendet ausgehende Anfragen an die angegebenen Datensatz-URLs. Ergebnisse werden 24 Stunden zwischengespeichert. Standardmäßig deaktiviert."
msgstr "Sends outbound requests to the given dataset URLs. Results are cached for 24 hours. Disabled by default."

msgid "Shortcode"
msgstr "Shortcode"

Expand Down Expand Up @@ -1143,6 +1149,9 @@ msgstr "URL of the project website or data portal with further information about
msgid "URL zur Beschreibung des Qualitätssicherungs-Prozesses (optional)."
msgstr "URL describing the quality-assurance process (optional)."

msgid "URL-Erreichbarkeit prüfen"
msgstr "Check URL reachability"

msgid "Um welchen Typ von Datensatz handelt es sich?"
msgstr "What type of dataset is this?"

Expand Down Expand Up @@ -1413,6 +1422,9 @@ msgstr "Access & further discoverability"
msgid "Zugriff verweigert."
msgstr "Access denied."

msgid "Zugriffs- und Download-URLs beim Speichern per HTTP-HEAD auf Erreichbarkeit prüfen (MQA-Zugänglichkeit, +80 Punkte)."
msgstr "Check access and download URLs for reachability via HTTP HEAD on save (MQA accessibility, +80 points)."

msgid "Zugriffs-Klassifikation des Datensatzes. Beispiel: Öffentlich, Eingeschränkt, Nicht öffentlich"
msgstr "Access classification of the dataset. Example: Public, Restricted, Non-public"

Expand Down
4 changes: 2 additions & 2 deletions open-data-wizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.14.0
* Version: 2.15.0
* Requires at least: 6.4
* Requires PHP: 8.1
* Author: nozilla
Expand All @@ -26,7 +26,7 @@
exit;
}

define( 'ODW_VERSION', '2.14.0' );
define( 'ODW_VERSION', '2.15.0' );
define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'ODW_PLUGIN_FILE', __FILE__ );
Expand Down
46 changes: 46 additions & 0 deletions tests/test-quality.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,50 @@ public function test_append_to_jsonld_skips_when_no_data(): void {

$this->assertArrayNotHasKey( 'odw:qualityScore', $result );
}

// -------------------------------------------------------------------------
// url_is_reachable() — MQA Phase 3 (opt-in reachability)
// -------------------------------------------------------------------------

/**
* Invokes the private static url_is_reachable() helper.
*
* @param string $url URL to check.
* @return bool
*/
private function call_url_is_reachable( string $url ): bool {
$ref = new \ReflectionMethod( 'ODW_Quality', 'url_is_reachable' );
$ref->setAccessible( true );
return (bool) $ref->invoke( null, $url );
}

/**
* A cached negative result short-circuits without a network request.
*/
public function test_url_is_reachable_uses_cached_result(): void {
$this->load_class();

\WP_Mock::userFunction( 'get_transient' )->andReturn( '0' );

$this->assertFalse( $this->call_url_is_reachable( 'https://example.com/data.csv' ) );
}

/**
* A 200 HEAD response marks the URL reachable and caches the result.
*/
public function test_url_is_reachable_maps_2xx_to_true(): void {
$this->load_class();

if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}

\WP_Mock::userFunction( 'get_transient' )->andReturn( false );
\WP_Mock::userFunction( 'wp_remote_head' )->andReturn( array( 'response' => array( 'code' => 200 ) ) );
\WP_Mock::userFunction( 'is_wp_error' )->andReturn( false );
\WP_Mock::userFunction( 'wp_remote_retrieve_response_code' )->andReturn( 200 );
\WP_Mock::userFunction( 'set_transient' )->once()->andReturn( true );

$this->assertTrue( $this->call_url_is_reachable( 'https://example.com/data.csv' ) );
}
}
Loading