From a4e9b75db0b8cca945c71098d2be8d6cfeaa4fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:05:25 +0000 Subject: [PATCH 1/4] v1.1: Bugfixes, Sicherheit, Caching und Erweiterbarkeit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priorität 1 — Bugfixes & Sicherheit: - Zeitzone: current_time() statt gmdate() für _odw_modified (class-fields.php) - Sortierbare Spalte Thema: pre_get_posts Hook mit meta_key/meta_value (class-admin.php) - $_GET Sanitization: wp_unslash() + sanitize_text_field() konsequent, absint() für post_id (class-admin.php, class-validation.php) - Byte-Size Validierung: is_numeric() + >= 0 Prüfung vor JSON-LD Ausgabe (class-fields.php) Priorität 2 — Code-Qualität: - License Single Source of Truth: ODW_Fields::get_license_label() eingeführt (class-fields.php, class-admin.php) - sessionStorage: try/catch Wrapper + post_id-spezifischer Key odw_active_tab_ (wizard-tabs.js) - MutationObserver: disconnect() via beforeunload Event (wizard-tabs.js) - Transient-TTL: 60s → 300s für Validierungsnotices (class-validation.php) - Carbon Fields Boot: try/catch um boot() mit Admin-Notice bei Fehler (open-data-wizard.php) Priorität 2 — Activation/Deactivation: - register_activation_hook: CPT registrieren, Rewrite Rules flush, Capabilities setzen - register_deactivation_hook: Rewrite Rules flush - uninstall.php: Opt-in Datenlöschung (odw_delete_data_on_uninstall Option) Priorität 3 — Features: - REST API Transient-Cache: 5 Minuten TTL, Cache-Invalidierung bei save_post/trash, X-ODW-Cache Header (class-rest-api.php) - Capability manage_open_data: Administrator und Editor erhalten die Capability bei Aktivierung - Filter-Hooks: odw_license_options, odw_theme_options, odw_dataset_jsonld, odw_catalog_title (class-fields.php, class-rest-api.php) - Admin Help Tabs: DCAT-AP Feldbeschreibungen + Harvest-Endpoint Doku auf Edit-Screen (class-admin.php) - CSS Custom Properties: --odw-color-* Variablen statt Hard-coded Hex-Werte (admin.css) https://claude.ai/code/session_013ma6QYffgnE2eKgDfh1Qgn --- assets/css/admin.css | 75 +++++++++++++-------- assets/js/wizard-tabs.js | 49 +++++++++++--- includes/class-admin.php | 112 +++++++++++++++++++++++++------ includes/class-fields.php | 48 ++++++++++---- includes/class-rest-api.php | 120 +++++++++++++++++++++++++++++----- includes/class-validation.php | 5 +- open-data-wizard.php | 66 +++++++++++++++++-- uninstall.php | 59 +++++++++++++++++ 8 files changed, 445 insertions(+), 89 deletions(-) create mode 100644 uninstall.php diff --git a/assets/css/admin.css b/assets/css/admin.css index 3abdac6..1de1e18 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -2,6 +2,28 @@ * Open Data Wizard — Admin Styles */ +:root { + --odw-color-primary: #2271b1; + --odw-color-primary-bg: #fff; + --odw-color-border: #c3c4c7; + --odw-color-bg-light: #f6f7f7; + --odw-color-text: #1d2327; + --odw-color-text-muted: #50575e; + + --odw-color-published-bg: #d1e7dd; + --odw-color-published-text: #0a5c36; + --odw-color-draft-bg: #e9ecef; + --odw-color-draft-text: #495057; + + --odw-color-error: #d63638; + --odw-color-code-bg: #1e1e1e; + --odw-color-code-text: #d4d4d4; + --odw-color-code-border: #3c3c3c; + + --odw-radius: 4px; + --odw-transition: 0.15s ease; +} + /* ========================================================================= Tab Navigation ========================================================================= */ @@ -13,7 +35,7 @@ margin: 0 0 0 -1px; padding: 0; list-style: none; - border-bottom: 2px solid #2271b1; + border-bottom: 2px solid var(--odw-color-primary); } .cf-container__tabs-nav li { @@ -28,31 +50,31 @@ padding: 10px 18px; font-size: 13px; font-weight: 500; - color: #50575e; - background: #f6f7f7; - border: 1px solid #c3c4c7; + color: var(--odw-color-text-muted); + background: var(--odw-color-bg-light); + border: 1px solid var(--odw-color-border); border-bottom: none; - border-radius: 4px 4px 0 0; - transition: background 0.15s, color 0.15s; + border-radius: var(--odw-radius) var(--odw-radius) 0 0; + transition: background var(--odw-transition), color var(--odw-transition); user-select: none; } .cf-container__tabs-nav li:hover .cf-tab__label { - background: #fff; - color: #1d2327; + background: var(--odw-color-primary-bg); + color: var(--odw-color-text); } .cf-container__tabs-nav li.cf-tab--active .cf-tab__label { - background: #fff; - color: #2271b1; + background: var(--odw-color-primary-bg); + color: var(--odw-color-primary); font-weight: 600; - border-bottom: 2px solid #fff; + border-bottom: 2px solid var(--odw-color-primary-bg); margin-bottom: -2px; z-index: 1; } .cf-container__tabs-nav li:focus-visible { - outline: 2px solid #2271b1; + outline: 2px solid var(--odw-color-primary); outline-offset: 2px; } @@ -71,7 +93,7 @@ .cf-field--required > label::after, .cf-field--required > .cf-field__label::after { content: ' *'; - color: #d63638; + color: var(--odw-color-error); font-weight: 700; } @@ -84,16 +106,17 @@ } .odw-jsonld-code { - background: #1e1e1e; - color: #d4d4d4; + background: var(--odw-color-code-bg); + color: var(--odw-color-code-text); font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; line-height: 1.6; padding: 16px 20px; - border-radius: 6px; + border-radius: var(--odw-radius); overflow-x: auto; max-height: 480px; - border: 1px solid #3c3c3c; + overflow-y: auto; + border: 1px solid var(--odw-color-code-border); white-space: pre; } @@ -126,13 +149,13 @@ } .odw-status-badge--published { - background: #d1e7dd; - color: #0a5c36; + background: var(--odw-color-published-bg); + color: var(--odw-color-published-text); } .odw-status-badge--draft { - background: #e9ecef; - color: #495057; + background: var(--odw-color-draft-bg); + color: var(--odw-color-draft-text); } /* ========================================================================= @@ -140,7 +163,7 @@ ========================================================================= */ .odw-validation-notice { - border-left-color: #d63638; + border-left-color: var(--odw-color-error); } .odw-validation-notice .odw-missing-fields { @@ -162,7 +185,7 @@ } /* ========================================================================= - Responsive + Responsive — WordPress admin breakpoint (782px) ========================================================================= */ @media screen and (max-width: 782px) { @@ -172,13 +195,13 @@ } .cf-container__tabs-nav li .cf-tab__label { - border-radius: 4px; - border: 1px solid #c3c4c7; + border-radius: var(--odw-radius); + border: 1px solid var(--odw-color-border); margin-bottom: 4px; } .cf-container__tabs-nav li.cf-tab--active .cf-tab__label { - border: 2px solid #2271b1; + border: 2px solid var(--odw-color-primary); margin-bottom: 4px; } } diff --git a/assets/js/wizard-tabs.js b/assets/js/wizard-tabs.js index 0428ac0..935b7bc 100644 --- a/assets/js/wizard-tabs.js +++ b/assets/js/wizard-tabs.js @@ -7,7 +7,33 @@ (function () { 'use strict'; - var SESSION_KEY = 'odw_active_tab'; + // Post-ID aus URL für post-spezifischen Storage-Schlüssel. + var postId = (function () { + var match = window.location.search.match(/[?&]post=(\d+)/); + return match ? match[1] : 'new'; + })(); + + var SESSION_KEY = 'odw_active_tab_' + postId; + + /** + * sessionStorage-Wrapper mit Fehlerbehandlung für Private-Browsing-Modus. + */ + var storage = { + get: function (key) { + try { + return sessionStorage.getItem(key); + } catch (e) { + return null; + } + }, + set: function (key, value) { + try { + sessionStorage.setItem(key, value); + } catch (e) { + // Quota exceeded oder Private-Browsing — stumm ignorieren. + } + } + }; /** * Warte auf Carbon Fields Tab-Rendering. @@ -29,7 +55,7 @@ * Initialisiert Tab-Zustand aus sessionStorage. */ function restoreTab(tabs) { - var savedLabel = sessionStorage.getItem(SESSION_KEY); + var savedLabel = storage.get(SESSION_KEY); if (!savedLabel) { return; } @@ -43,33 +69,35 @@ } /** - * Speichert aktiven Tab-Namen in sessionStorage. + * Speichert aktiven Tab-Namen in sessionStorage beim Klick. */ function persistTab(tabs) { tabs.forEach(function (tab) { tab.addEventListener('click', function () { var labelEl = tab.querySelector('.cf-tab__label'); if (labelEl) { - sessionStorage.setItem(SESSION_KEY, labelEl.textContent.trim()); + storage.set(SESSION_KEY, labelEl.textContent.trim()); } }); }); } /** - * Fügt aktive Klasse für visuelle Hervorhebung hinzu. + * Setzt aria-selected Attribut via MutationObserver. + * Gibt den Observer zurück damit er beim Entladen getrennt werden kann. */ function enhanceActiveStyle(tabs) { var observer = new MutationObserver(function () { tabs.forEach(function (tab) { - var isActive = tab.classList.contains('cf-tab--active'); - tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + tab.setAttribute('aria-selected', tab.classList.contains('cf-tab--active') ? 'true' : 'false'); }); }); tabs.forEach(function (tab) { observer.observe(tab, { attributes: true, attributeFilter: ['class'] }); }); + + return observer; } /** @@ -104,9 +132,14 @@ waitForTabs(function (tabs) { var tabsArray = Array.prototype.slice.call(tabs); persistTab(tabsArray); - enhanceActiveStyle(tabsArray); + var observer = enhanceActiveStyle(tabsArray); addKeyboardNav(tabsArray); restoreTab(tabsArray); + + // Observer beim Verlassen der Seite trennen (Speicherleck vermeiden). + window.addEventListener('beforeunload', function () { + observer.disconnect(); + }, { once: true }); }); } diff --git a/includes/class-admin.php b/includes/class-admin.php index 538db66..b5b285f 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -1,6 +1,6 @@ '; $new_columns['title'] = __( 'Titel', 'open-data-wizard' ); $new_columns['odw_license'] = __( 'Lizenz', 'open-data-wizard' ); @@ -45,8 +48,8 @@ public static function set_columns( array $columns ): array { public static function render_column( string $column, int $post_id ): void { switch ( $column ) { case 'odw_license': - $license = carbon_get_post_meta( $post_id, 'odw_license' ); - echo esc_html( self::license_label( (string) $license ) ); + $license = (string) carbon_get_post_meta( $post_id, 'odw_license' ); + echo esc_html( ODW_Fields::get_license_label( $license ) ); break; case 'odw_theme': @@ -81,17 +84,36 @@ public static function sortable_columns( array $columns ): array { return $columns; } + /** + * Enable meta-based ordering for the Thema column. + */ + public static function handle_meta_orderby( WP_Query $query ): void { + if ( ! is_admin() || ! $query->is_main_query() ) { + return; + } + + if ( 'odw_dataset' !== $query->get( 'post_type' ) ) { + return; + } + + if ( 'odw_theme' === $query->get( 'orderby' ) ) { + $query->set( 'meta_key', '_odw_theme' ); + $query->set( 'orderby', 'meta_value' ); + } + } + /** * Status filter dropdown above list table. */ public static function status_filter_dropdown(): void { global $typenow; - if ( 'odw_dataset' !== $typenow ) { + if ( ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { return; } - $selected = sanitize_text_field( $_GET['odw_status_filter'] ?? '' ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $selected = isset( $_GET['odw_status_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['odw_status_filter'] ) ) : ''; $options = [ '' => __( 'Alle Status', 'open-data-wizard' ), @@ -117,7 +139,7 @@ public static function status_filter_dropdown(): void { public static function apply_status_filter( WP_Query $query ): void { global $pagenow, $typenow; - if ( ! is_admin() || 'edit.php' !== $pagenow || 'odw_dataset' !== $typenow ) { + if ( ! is_admin() || 'edit.php' !== $pagenow || ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { return; } @@ -125,12 +147,12 @@ public static function apply_status_filter( WP_Query $query ): void { return; } - $filter = sanitize_text_field( $_GET['odw_status_filter'] ?? '' ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $filter = isset( $_GET['odw_status_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['odw_status_filter'] ) ) : ''; if ( in_array( $filter, [ 'publish', 'draft' ], true ) ) { $query->set( 'post_status', $filter ); } else { - // Show both draft and published in "all" view. $query->set( 'post_status', [ 'publish', 'draft' ] ); } } @@ -152,7 +174,6 @@ public static function enqueue_assets( string $hook ): void { ODW_VERSION ); - // Tab JS only needed on single post edit screen. if ( in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) { wp_enqueue_script( 'odw-wizard-tabs', @@ -165,16 +186,69 @@ public static function enqueue_assets( string $hook ): void { } /** - * Translate license URI to human-readable label. + * Register Help Tabs on the odw_dataset edit screen. */ - private static function license_label( string $uri ): string { - $labels = [ - 'https://creativecommons.org/publicdomain/zero/1.0/' => 'CC0 1.0', - 'https://creativecommons.org/licenses/by/4.0/' => 'CC-BY 4.0', - 'https://creativecommons.org/licenses/by-sa/4.0/' => 'CC-BY-SA 4.0', - 'https://www.govdata.de/dl-de/by-2-0' => 'DL-DE BY 2.0', - ]; + public static function register_help_tabs(): void { + $screen = get_current_screen(); + + if ( ! $screen || 'odw_dataset' !== $screen->post_type ) { + return; + } + + $screen->add_help_tab( [ + 'id' => 'odw-help-fields', + 'title' => __( 'Felder', 'open-data-wizard' ), + 'content' => self::help_content_fields(), + ] ); + + $screen->add_help_tab( [ + 'id' => 'odw-help-api', + 'title' => __( 'Harvest-Endpoint', 'open-data-wizard' ), + 'content' => self::help_content_api(), + ] ); + + $screen->set_help_sidebar( + '

' . esc_html__( 'Weitere Informationen:', 'open-data-wizard' ) . '

' . + '

DCAT-AP 3.0 Spezifikation

' . + '

Plugin-Dokumentation

' + ); + } + + private static function help_content_fields(): string { + ob_start(); + ?> +

+ +

+

+

+

+ +

+

+

+ +

+

+ set_attribute( 'placeholder', __( 'optional, z.B. 204800', 'open-data-wizard' ) ) - ->set_attribute( 'type', 'number' ), + ->set_attribute( 'type', 'number' ) + ->set_attribute( 'min', '0' ), ] ), ] ) @@ -133,7 +134,7 @@ public static function set_modified_date( int $post_id, \WP_Post $post ): void { // Update without triggering infinite loop remove_action( 'save_post_odw_dataset', [ self::class, 'set_modified_date' ], 10 ); - update_post_meta( $post_id, '_odw_modified', gmdate( 'Y-m-d' ) ); + update_post_meta( $post_id, '_odw_modified', current_time( 'Y-m-d' ) ); add_action( 'save_post_odw_dataset', [ self::class, 'set_modified_date' ], 10, 2 ); } @@ -143,27 +144,40 @@ public static function set_modified_date( int $post_id, \WP_Post $post ): void { // ------------------------------------------------------------------------- public static function get_license_options(): array { - return [ - '' => __( '— Bitte wählen —', 'open-data-wizard' ), + $options = [ + '' => __( '— Bitte wählen —', 'open-data-wizard' ), 'https://creativecommons.org/publicdomain/zero/1.0/' => 'CC0 1.0', 'https://creativecommons.org/licenses/by/4.0/' => 'CC-BY 4.0', 'https://creativecommons.org/licenses/by-sa/4.0/' => 'CC-BY-SA 4.0', 'https://www.govdata.de/dl-de/by-2-0' => 'Datenlizenz Deutschland Namensnennung 2.0', ]; + + return (array) apply_filters( 'odw_license_options', $options ); + } + + /** + * Translate a license URI to its human-readable label. + * Single source of truth — used by Fields and Admin classes. + */ + public static function get_license_label( string $uri ): string { + $options = self::get_license_options(); + return $options[ $uri ] ?? $uri; } public static function get_theme_options(): array { - return [ - '' => __( '— Bitte wählen —', 'open-data-wizard' ), - 'Bildung' => __( 'Bildung', 'open-data-wizard' ), + $options = [ + '' => __( '— Bitte wählen —', 'open-data-wizard' ), + 'Bildung' => __( 'Bildung', 'open-data-wizard' ), 'Gesundheit' => __( 'Gesundheit', 'open-data-wizard' ), - 'Soziales' => __( 'Soziales', 'open-data-wizard' ), - 'Umwelt' => __( 'Umwelt', 'open-data-wizard' ), + 'Soziales' => __( 'Soziales', 'open-data-wizard' ), + 'Umwelt' => __( 'Umwelt', 'open-data-wizard' ), 'Wirtschaft' => __( 'Wirtschaft', 'open-data-wizard' ), - 'Kultur' => __( 'Kultur', 'open-data-wizard' ), - 'Sport' => __( 'Sport', 'open-data-wizard' ), - 'Sonstiges' => __( 'Sonstiges', 'open-data-wizard' ), + 'Kultur' => __( 'Kultur', 'open-data-wizard' ), + 'Sport' => __( 'Sport', 'open-data-wizard' ), + 'Sonstiges' => __( 'Sonstiges', 'open-data-wizard' ), ]; + + return (array) apply_filters( 'odw_theme_options', $options ); } public static function get_format_options(): array { @@ -310,7 +324,7 @@ function odw_build_dataset_jsonld( int $post_id ): ?array { $dist_item['dct:format'] = ODW_Fields::get_format_mime( $dist['format'] ); } - if ( isset( $dist['byte_size'] ) && '' !== $dist['byte_size'] ) { + if ( isset( $dist['byte_size'] ) && '' !== $dist['byte_size'] && is_numeric( $dist['byte_size'] ) && (int) $dist['byte_size'] >= 0 ) { $dist_item['dcat:byteSize'] = (int) $dist['byte_size']; } @@ -322,5 +336,11 @@ function odw_build_dataset_jsonld( int $post_id ): ?array { } } - return $dataset; + /** + * Filters the complete DCAT-AP JSON-LD array before output. + * + * @param array $dataset The JSON-LD dataset array. + * @param int $post_id The dataset post ID. + */ + return (array) apply_filters( 'odw_dataset_jsonld', $dataset, $post_id ); } diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 8a18062..206c3fa 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -18,6 +18,9 @@ class ODW_Rest_API { private const NAMESPACE = 'datenatlas/v1'; + /** Cache-TTL in Sekunden (5 Minuten). */ + private const CACHE_TTL = 300; + /** * DCAT-AP 3.0 JSON-LD @context */ @@ -30,6 +33,10 @@ class ODW_Rest_API { public static function init(): void { add_action( 'rest_api_init', [ self::class, 'register_routes' ] ); + + // Cache invalidieren wenn ein Datensatz gespeichert oder gelöscht wird. + add_action( 'save_post_odw_dataset', [ self::class, 'invalidate_cache' ] ); + add_action( 'trashed_post', [ self::class, 'invalidate_cache_on_trash' ] ); } public static function register_routes(): void { @@ -90,6 +97,18 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response $theme = (string) $request->get_param( 'theme' ); $license = (string) $request->get_param( 'license' ); + $cache_key = 'odw_catalog_' . md5( serialize( [ $page, $per_page, $theme, $license ] ) ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached && is_array( $cached ) ) { + $response = new WP_REST_Response( $cached['body'], 200 ); + $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $response->header( 'X-WP-Total', (string) $cached['total'] ); + $response->header( 'X-WP-TotalPages', (string) $cached['pages'] ); + $response->header( 'X-ODW-Cache', 'HIT' ); + return $response; + } + $query_args = [ 'post_type' => 'odw_dataset', 'post_status' => 'publish', @@ -100,7 +119,6 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response 'no_found_rows' => false, ]; - // Apply filters via meta queries. $meta_query = []; if ( ! empty( $theme ) ) { @@ -111,7 +129,6 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response } if ( ! empty( $license ) ) { - // Support alias shorthand, e.g. "cc-by" → "cc-by 4.0" → full URL $license_map = self::get_license_alias_map(); $license_url = $license_map[ strtolower( $license ) ] ?? $license; @@ -125,10 +142,10 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response $query_args['meta_query'] = $meta_query; } - $query = new WP_Query( $query_args ); - $posts = $query->posts; - $total = (int) $query->found_posts; - $pages = (int) $query->max_num_pages; + $query = new WP_Query( $query_args ); + $posts = $query->posts; + $total = (int) $query->found_posts; + $pages = (int) $query->max_num_pages; $datasets = []; foreach ( $posts as $post ) { @@ -138,21 +155,38 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response } } + /** + * Filters the catalog title in the JSON-LD output. + * + * @param string $title The catalog title. + */ + $catalog_title = (string) apply_filters( + 'odw_catalog_title', + get_bloginfo( 'name' ) . ' — Datenkatalog' + ); + $catalog = [ '@context' => self::JSONLD_CONTEXT, '@type' => 'dcat:Catalog', - 'dct:title' => get_bloginfo( 'name' ) . ' — Datenkatalog', + 'dct:title' => $catalog_title, 'dct:publisher' => [ - '@type' => 'foaf:Organization', + '@type' => 'foaf:Organization', 'foaf:name' => get_bloginfo( 'name' ), ], 'dcat:dataset' => $datasets, ]; + set_transient( $cache_key, [ + 'body' => $catalog, + 'total' => $total, + 'pages' => $pages, + ], self::CACHE_TTL ); + $response = new WP_REST_Response( $catalog, 200 ); $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) $pages ); + $response->header( 'X-ODW-Cache', 'MISS' ); return $response; } @@ -180,6 +214,16 @@ public static function get_dataset( WP_REST_Request $request ): WP_REST_Response ); } + $cache_key = 'odw_dataset_' . $post_id; + $cached = get_transient( $cache_key ); + + if ( false !== $cached && is_array( $cached ) ) { + $response = new WP_REST_Response( $cached, 200 ); + $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $response->header( 'X-ODW-Cache', 'HIT' ); + return $response; + } + $dataset = odw_build_dataset_jsonld( $post_id ); if ( ! $dataset ) { @@ -195,25 +239,69 @@ public static function get_dataset( WP_REST_Request $request ): WP_REST_Response $dataset ); + set_transient( $cache_key, $body, self::CACHE_TTL ); + $response = new WP_REST_Response( $body, 200 ); $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $response->header( 'X-ODW-Cache', 'MISS' ); return $response; } + /** + * Invalidate all catalog caches when a dataset is saved. + */ + public static function invalidate_cache( int $post_id ): void { + $post = get_post( $post_id ); + if ( ! $post || 'odw_dataset' !== $post->post_type ) { + return; + } + + // Invalidate the single-dataset cache. + delete_transient( 'odw_dataset_' . $post_id ); + + // Invalidate all catalog caches via pattern. + self::delete_catalog_transients(); + } + + /** + * Invalidate cache when a dataset is trashed. + */ + public static function invalidate_cache_on_trash( int $post_id ): void { + $post = get_post( $post_id ); + if ( $post && 'odw_dataset' === $post->post_type ) { + self::invalidate_cache( $post_id ); + } + } + + /** + * Delete all catalog transients using a direct DB query (no viable alternative for pattern delete). + */ + private static function delete_catalog_transients(): void { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + '_transient_odw_catalog_%', + '_transient_timeout_odw_catalog_%' + ) + ); + } + /** * Shorthand alias map for ?license= filter. - * Maps lowercase aliases to full license URIs. */ private static function get_license_alias_map(): array { return [ - 'cc0' => 'https://creativecommons.org/publicdomain/zero/1.0/', - 'cc0-1.0' => 'https://creativecommons.org/publicdomain/zero/1.0/', - 'cc-by' => 'https://creativecommons.org/licenses/by/4.0/', - 'cc-by-4.0' => 'https://creativecommons.org/licenses/by/4.0/', - 'cc-by-sa' => 'https://creativecommons.org/licenses/by-sa/4.0/', - 'cc-by-sa-4.0' => 'https://creativecommons.org/licenses/by-sa/4.0/', - 'dl-de-by-2.0' => 'https://www.govdata.de/dl-de/by-2-0', + 'cc0' => 'https://creativecommons.org/publicdomain/zero/1.0/', + 'cc0-1.0' => 'https://creativecommons.org/publicdomain/zero/1.0/', + 'cc-by' => 'https://creativecommons.org/licenses/by/4.0/', + 'cc-by-4.0' => 'https://creativecommons.org/licenses/by/4.0/', + 'cc-by-sa' => 'https://creativecommons.org/licenses/by-sa/4.0/', + 'cc-by-sa-4.0' => 'https://creativecommons.org/licenses/by-sa/4.0/', + 'dl-de-by-2.0' => 'https://www.govdata.de/dl-de/by-2-0', ]; } } diff --git a/includes/class-validation.php b/includes/class-validation.php index f056510..3f86ecb 100644 --- a/includes/class-validation.php +++ b/includes/class-validation.php @@ -63,7 +63,7 @@ public static function intercept_publish( array $data, array $postarr ): array { set_transient( self::TRANSIENT_PREFIX . $post_id, $errors, - 60 // seconds + 300 // 5 Minuten ); return $data; @@ -202,7 +202,8 @@ public static function show_validation_notice(): void { return; } - $post_id = (int) ( $_GET['post'] ?? $_POST['post_ID'] ?? 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : ( isset( $_POST['post_ID'] ) ? absint( $_POST['post_ID'] ) : 0 ); if ( ! $post_id ) { return; diff --git a/open-data-wizard.php b/open-data-wizard.php index ad61bd7..0af4de1 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 zivilgesellschaftliche Organisationen. Bereitstellung als maschinenlesbarer Endpoint für Civora/Piveau-Harvesting. - * Version: 1.0.0 + * Version: 1.1.0 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: Datenatlas Zivilgesellschaft @@ -19,13 +19,58 @@ exit; } -define( 'ODW_VERSION', '1.0.0' ); +define( 'ODW_VERSION', '1.1.0' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); +// --------------------------------------------------------------------------- +// Activation / Deactivation +// --------------------------------------------------------------------------- + +register_activation_hook( __FILE__, 'odw_activate' ); +register_deactivation_hook( __FILE__, 'odw_deactivate' ); + +function odw_activate(): void { + odw_register_cpt_static(); + flush_rewrite_rules(); + odw_add_capabilities(); +} + +function odw_deactivate(): void { + flush_rewrite_rules(); +} + +/** + * Grant manage_open_data capability to administrator and editor roles. + * Called on activation; removed by uninstall.php. + */ +function odw_add_capabilities(): void { + $roles = [ 'administrator', 'editor' ]; + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->add_cap( 'manage_open_data' ); + } + } +} + /** - * Bootstrap Carbon Fields. + * Minimal CPT registration used during activation (before theme is set up). + */ +function odw_register_cpt_static(): void { + register_post_type( 'odw_dataset', [ + 'public' => false, + 'supports' => [ 'title', 'revisions' ], + ] ); +} + +// --------------------------------------------------------------------------- +// Bootstrap +// --------------------------------------------------------------------------- + +/** + * Bootstrap Carbon Fields and all plugin modules. */ function odw_bootstrap(): void { $autoloader = ODW_PLUGIN_DIR . 'vendor/autoload.php'; @@ -44,7 +89,20 @@ function odw_bootstrap(): void { require_once $autoloader; - \Carbon_Fields\Carbon_Fields::boot(); + try { + \Carbon_Fields\Carbon_Fields::boot(); + } catch ( \Throwable $e ) { + add_action( 'admin_notices', function () use ( $e ): void { + echo '

'; + printf( + /* translators: %s: Error message */ + esc_html__( 'Open Data Wizard: Carbon Fields konnte nicht initialisiert werden — %s', 'open-data-wizard' ), + esc_html( $e->getMessage() ) + ); + echo '

'; + } ); + return; + } require_once ODW_PLUGIN_DIR . 'includes/class-post-types.php'; require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..406912d --- /dev/null +++ b/uninstall.php @@ -0,0 +1,59 @@ +remove_cap( 'manage_open_data' ); + } +} + +// Alle odw_dataset Posts inkl. Postmeta löschen. +$posts = get_posts( [ + 'post_type' => 'odw_dataset', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', +] ); + +foreach ( $posts as $post_id ) { + wp_delete_post( (int) $post_id, true ); +} + +// Plugin-Optionen löschen. +delete_option( 'odw_delete_data_on_uninstall' ); + +// Transients bereinigen. +global $wpdb; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching +$wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + '_transient_odw_%' + ) +); +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching +$wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + '_transient_timeout_odw_%' + ) +); From f46ec9a5ce26bc240a89e0c97d172518d0ff1665 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:11:46 +0000 Subject: [PATCH 2/4] =?UTF-8?q?v1.2:=20Code-Qualit=C3=A4t,=20Content=20Neg?= =?UTF-8?q?otiation=20und=20Dokumentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validierungslogik zentralisiert (4.2): - ODW_Fields::get_required_fields() als zentrale Pflichtfeld-Registry - class-validation.php iteriert über Registry statt Felder doppelt zu pflegen - get_field_value() vereinfacht: CF-Key-Parameter entfernt Content Negotiation ?format= (3.3): - ?format=jsonld (Standard) → application/ld+json - ?format=json → application/json - An beiden Endpoints /catalog und /datasets/ verfügbar - Grundlage für spätere Turtle/RDF-XML Unterstützung PHPStan + WordPress Coding Standards + CI (4.1): - phpstan.neon: Level 6 Analyse inkl. szepeviktor/phpstan-wordpress Stubs - composer.json: require-dev mit phpstan, wpcs, phpunit, wp_mock - phpunit.xml: PHPUnit Konfiguration für tests/ Verzeichnis - tests/bootstrap.php: WP_Mock Bootstrap + Konstanten-Definitionen - tests/test-fields.php: Tests für ODW_Fields (license options, labels, format MIME) - .github/workflows/ci.yml: PHPCS + PHPStan + PHPUnit auf PHP 8.1/8.2/8.3 Dokumentation (4.4): - CHANGELOG.md: Vollständiges Changelog für v1.0.0, v1.1.0, v1.2.0 - README.md: ?format= Parameter, korrekte Filter-Hook-Namen, Dev-Tools Sektion, aktualisierte Dateistruktur, Deinstallations-Anleitung, Lizenz korrigiert auf GPL-2.0 https://claude.ai/code/session_013ma6QYffgnE2eKgDfh1Qgn --- .github/workflows/ci.yml | 65 +++++++++++++++++++++++++ CHANGELOG.md | 71 +++++++++++++++++++++++++++ README.md | 90 +++++++++++++++++++++++++++-------- composer.json | 22 +++++++-- includes/class-fields.php | 18 +++++++ includes/class-rest-api.php | 42 +++++++++++++--- includes/class-validation.php | 35 +++++--------- phpstan.neon | 23 +++++++++ phpunit.xml | 23 +++++++++ tests/bootstrap.php | 31 ++++++++++++ tests/test-fields.php | 69 +++++++++++++++++++++++++++ 11 files changed, 434 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 tests/bootstrap.php create mode 100644 tests/test-fields.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..186acb0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["main", "master"] + +jobs: + phpcs: + name: WordPress Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + tools: composer:v2 + + - name: Install dev dependencies + run: composer install --no-interaction --prefer-dist --dev + + - name: Run PHPCS + run: composer phpcs + + phpstan: + name: PHPStan Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + tools: composer:v2 + + - name: Install dependencies (prod + dev) + run: composer install --no-interaction --prefer-dist + + - name: Run PHPStan + run: composer phpstan + + phpunit: + name: PHPUnit Tests + runs-on: ubuntu-latest + strategy: + matrix: + php: ["8.1", "8.2", "8.3"] + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + + - name: Install dev dependencies + run: composer install --no-interaction --prefer-dist --dev + + - name: Run PHPUnit + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4cfdc87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +Alle nennenswerten Änderungen an diesem Projekt sind in dieser Datei dokumentiert. + +Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). +Versionierung folgt [Semantic Versioning](https://semver.org/). + +--- + +## [1.2.0] — 2026-04-21 + +### Hinzugefügt +- **?format= Parameter** an beiden REST-Endpoints (`/catalog`, `/datasets/`): `jsonld` (Standard, `application/ld+json`) oder `json` (`application/json`) — Grundlage für spätere Content-Negotiation +- **PHPStan Level 6** Konfiguration (`phpstan.neon`) +- **WordPress Coding Standards** via WPCS (`phpcs`/`phpcbf` Scripts in composer.json) +- **PHPUnit** Test-Setup (`phpunit.xml`, `tests/bootstrap.php`, erste Test-Suite für `ODW_Fields`) +- **GitHub Actions CI** Workflow (`.github/workflows/ci.yml`): PHPCS, PHPStan, PHPUnit auf PHP 8.1/8.2/8.3 +- **`ODW_Fields::get_required_fields()`** — zentrale Pflichtfeld-Registry als Single Source of Truth + +### Geändert +- **Validierungslogik zentralisiert**: `class-validation.php` iteriert über `ODW_Fields::get_required_fields()` statt Felder doppelt zu pflegen +- **`get_field_value()`** vereinfacht: CF-Key-Parameter entfernt, meta_key reicht als Identifier +- **composer.json**: `require-dev` Sektion mit PHPStan, WPCS, PHPUnit hinzugefügt; `allow-plugins` Konfiguration ergänzt + +--- + +## [1.1.0] — 2026-04-21 + +### Hinzugefügt +- **Activation Hook**: CPT registrieren, Rewrite Rules flushen, Capability `manage_open_data` vergeben +- **Deactivation Hook**: Rewrite Rules flushen +- **`uninstall.php`**: Opt-in Datenlöschung bei Deinstallation (hinter `odw_delete_data_on_uninstall` Option) +- **REST API Transient-Cache**: 5 Minuten TTL für `/catalog` und `/datasets/`; Cache-Invalidierung bei `save_post_odw_dataset` und `trashed_post`; `X-ODW-Cache: HIT/MISS` Header +- **Capability `manage_open_data`**: Administrator und Editor erhalten die Capability bei Plugin-Aktivierung +- **Filter-Hooks**: `odw_license_options`, `odw_theme_options`, `odw_dataset_jsonld`, `odw_catalog_title` +- **Admin Help Tabs**: DCAT-AP Feldbeschreibungen und Harvest-Endpoint Doku auf dem Edit-Screen +- **`ODW_Fields::get_license_label()`**: Single Source of Truth für Lizenz-URI → Label Übersetzung +- **CSS Custom Properties**: `--odw-color-*` Variablen statt hard-codierter Hex-Werte + +### Behoben +- **Zeitzonen-Bug**: `gmdate()` → `current_time()` für `_odw_modified` (verhinderte Datums-Abweichung um 1 Tag bei Nicht-UTC-Servern) +- **Sortierbare Spalte „Thema"**: `pre_get_posts` Hook mit `meta_key`/`meta_value` — Sortierung war vorher defekt +- **`$_GET` Sanitization**: `wp_unslash()` + `sanitize_text_field()` konsequent; `absint()` für post_id (class-admin.php, class-validation.php) +- **Byte-Size Validierung**: `is_numeric()` + `>= 0` Prüfung vor JSON-LD Ausgabe +- **Transient-TTL**: 60s → 300s für Validierungsnotices (verhindert Ablauf bei langsamen Servern) +- **sessionStorage Safety**: `try/catch` Wrapper für Private-Browsing-Modus und Quota-Überschreitung; post_id-spezifischer Key (`odw_active_tab_`) +- **MutationObserver Speicherleck**: `disconnect()` via `beforeunload` Event +- **Carbon Fields Boot-Fehler**: `try/catch` um `boot()` mit hilfreicher Admin-Notice statt fatalen PHP-Fehler + +--- + +## [1.0.0] — 2026-03-02 + +### Hinzugefügt +- **Custom Post Type `odw_dataset`** mit deutschen Labels und Dashicons-database Icon +- **Carbon Fields Formular** mit 4 Tabs: + - Tab 1: Pflichtfelder (Titel, Beschreibung, Publisher, Lizenz) + - Tab 2: Optionale Felder (Sprache, Schlagworte, Thema, Datum) + - Tab 3: Distributionen (accessURL, Format, byteSize) — wiederholbares Complex Field + - Tab 4: JSON-LD Vorschau (read-only) +- **REST API**: + - `GET /wp-json/datenatlas/v1/catalog` mit Paginierung und Filtern (`?theme=`, `?license=`) + - `GET /wp-json/datenatlas/v1/datasets/` + - Content-Type `application/ld+json`, DCAT-AP 3.0 `@context` +- **Admin-Listenansicht**: Spalten Titel, Lizenz, Thema, Status, Änderungsdatum; Status-Dropdown-Filter +- **Pflichtfeldvalidierung**: Blockiert Veröffentlichung bei fehlenden Pflichtfeldern; Admin-Notice mit Feldnamen +- **Tab-Navigation** (Vanilla JS, kein jQuery): sessionStorage-Persistenz, Keyboard-Navigation +- **Carbon Fields** v3.6 via Composer im Plugin gebündelt (kein Composer-Wissen nötig) +- **DCAT-AP 3.0 JSON-LD** Ausgabe mit allen Pflicht- und empfohlenen Feldern +- **Lizenz-Kurzaliase** im API-Filter (`?license=cc-by`, `?license=cc0` etc.) +- Automatische Aktualisierung von `dct:modified` bei jedem Speichern diff --git a/README.md b/README.md index 4fd7c59..d08cb8f 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ Das Plugin stellt einen öffentlichen REST API Endpoint bereit: ``` GET https://deine-website.de/wp-json/datenatlas/v1/catalog +GET https://deine-website.de/wp-json/datenatlas/v1/datasets/ ``` Diese URL kann bei einer Open-Data-Plattform als Harvest-Quelle eingetragen werden — einmalig, ohne weiteren Aufwand. +Parameter: `page`, `per_page`, `theme`, `license`, `format` (`jsonld` oder `json`) + ### ✅ DCAT-AP 3.0 Konformität Alle Ausgaben sind DCAT-AP 3.0 konform und in JSON-LD serialisiert. @@ -90,16 +93,27 @@ Keine weiteren Abhängigkeiten. Keine Programmierkenntnisse erforderlich. ```bash git clone https://github.com/daimpad/OpenDataWizard.git cd OpenDataWizard -composer install +composer install # inkl. PHPStan, WPCS, PHPUnit ``` Den Plugin-Ordner in eine lokale WordPress-Instanz einbinden (z.B. via [LocalWP](https://localwp.com)). **Systemvoraussetzungen:** -- WordPress ≥ aktuelle LTS-Version +- WordPress ≥ 6.4 - PHP ≥ 8.1 - Composer (nur für Entwicklung) +**Dev-Tools:** + +```bash +composer phpcs # WordPress Coding Standards prüfen +composer phpcbf # Automatisch korrigieren +composer phpstan # Statische Analyse (Level 6) +composer test # PHPUnit-Tests ausführen +``` + +CI läuft via GitHub Actions (`.github/workflows/ci.yml`) auf PHP 8.1, 8.2 und 8.3. + --- ## Technische Dokumentation @@ -117,17 +131,24 @@ Infrastruktur → REST API, JSON-LD Serialisierung, Custom Post Type ``` open-data-wizard/ ├── open-data-wizard.php # Plugin-Header & Bootstrap +├── uninstall.php # Opt-in Datenlöschung bei Deinstallation ├── composer.json +├── phpstan.neon +├── phpunit.xml ├── vendor/ # Carbon Fields (gebündelt) ├── includes/ │ ├── class-post-types.php # CPT-Registrierung: odw_dataset -│ ├── class-fields.php # Carbon Fields + DCAT-AP Mapping -│ ├── class-rest-api.php # REST Endpoints +│ ├── class-fields.php # Carbon Fields, DCAT-AP Mapping, Pflichtfeld-Registry +│ ├── class-rest-api.php # REST Endpoints mit Transient-Cache │ ├── class-validation.php # Pflichtfeldprüfung -│ └── class-admin.php # Listenansicht & Admin-Notices +│ └── class-admin.php # Listenansicht, Help Tabs, Assets ├── assets/ -│ ├── js/wizard-tabs.js -│ └── css/admin.css +│ ├── js/wizard-tabs.js # Tab-Navigation (Vanilla JS) +│ └── css/admin.css # Admin-Styles (CSS Custom Properties) +├── tests/ +│ ├── bootstrap.php +│ └── test-fields.php +├── .github/workflows/ci.yml └── languages/ ``` @@ -154,7 +175,17 @@ open-data-wizard/ ``` GET /wp-json/datenatlas/v1/catalog ``` -Liefert alle veröffentlichten Datasets als `dcat:Catalog` in JSON-LD. Parameter: `page`, `per_page`, `theme`, `license`. +Liefert alle veröffentlichten Datasets als `dcat:Catalog` in JSON-LD. + +| Parameter | Standard | Beschreibung | +|------------|----------|----------------------------------------------------| +| `page` | 1 | Seitennummer | +| `per_page` | 20 | Einträge pro Seite (max. 100) | +| `theme` | – | Filter nach Thema (z.B. `Bildung`) | +| `license` | – | Filter: Kurzform (`cc-by`) oder volle URI | +| `format` | `jsonld` | `jsonld` → `application/ld+json`, `json` → `application/json` | + +Response-Header: `X-WP-Total`, `X-WP-TotalPages`, `X-ODW-Cache` (`HIT`/`MISS`) #### Einzel-Dataset ``` @@ -195,25 +226,34 @@ GET /wp-json/datenatlas/v1/datasets/ ### Erweiterbarkeit -Das Plugin stellt Hooks für eigene Felder und Profile bereit: +Das Plugin stellt folgende WordPress-Filter zur Erweiterung bereit: -```php -// Eigene Felder hinzufügen -add_filter('odw_extra_fields', function($fields) { - return $fields; -}); +| Hook | Beschreibung | +|-----------------------|---------------------------------------------------| +| `odw_license_options` | Weitere Lizenz-Optionen hinzufügen | +| `odw_theme_options` | Weitere Thema-Optionen hinzufügen | +| `odw_dataset_jsonld` | JSON-LD Array vor Ausgabe anpassen | +| `odw_catalog_title` | Catalog-Titel anpassen | -// JSON-LD Output anpassen -add_filter('odw_jsonld_dataset', function($jsonld, $post_id) { +```php +// Eigene Lizenz hinzufügen +add_filter( 'odw_license_options', function( array $options ): array { + $options['https://example.com/custom-license'] = 'Custom License 1.0'; + return $options; +} ); + +// JSON-LD Dataset anpassen +add_filter( 'odw_dataset_jsonld', function( array $jsonld, int $post_id ): array { + $jsonld['dct:spatial'] = 'https://sws.geonames.org/2921044/'; // Deutschland return $jsonld; -}, 10, 2); +}, 10, 2 ); ``` ### Abhängigkeiten | Paket | Version | Lizenz | |---|---|---| -| [Carbon Fields](https://carbonfields.net/) | ^3.0 | MIT | +| [Carbon Fields](https://carbonfields.net/) | ^3.6 | MIT | --- @@ -243,6 +283,18 @@ git push origin feature/mein-feature --- +## Deinstallation + +Das Plugin löscht bei Deinstallation standardmäßig **keine** Daten (Opt-in). Um alle Plugin-Daten zu löschen, die Option `odw_delete_data_on_uninstall` auf `true` setzen: + +```php +update_option( 'odw_delete_data_on_uninstall', true ); +``` + +Danach das Plugin im WordPress-Backend deinstallieren. + +--- + ## Lizenz -MIT License — siehe [`LICENSE`](./LICENSE) +GPL-2.0-or-later — siehe [`LICENSE`](./LICENSE) diff --git a/composer.json b/composer.json index fe13d06..49f6b37 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,30 @@ { "name": "daimpad/open-data-wizard", - "description": "WordPress Plugin für DCAT-AP 3.0 konforme Open Data Metadaten", + "description": "DCAT-AP 3.0 Open Data Plugin für WordPress — maschinenlesbarer Endpoint für Civora/Piveau-Harvesting", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", "htmlburger/carbon-fields": "^3.6" }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.1", + "phpcsstandards/phpcsutils": "^1.0", + "phpunit/phpunit": "^10.5", + "10up/wp_mock": "^0.5" + }, "config": { - "optimize-autoloader": true + "optimize-autoloader": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } }, "scripts": { - "install-plugin": [ - "composer install --no-dev --optimize-autoloader" - ] + "phpcs": "phpcs --standard=WordPress --extensions=php --ignore=vendor/ .", + "phpcbf": "phpcbf --standard=WordPress --extensions=php --ignore=vendor/ .", + "phpstan": "phpstan analyse --configuration=phpstan.neon", + "test": "phpunit --configuration=phpunit.xml" } } diff --git a/includes/class-fields.php b/includes/class-fields.php index b06095c..68de4f8 100644 --- a/includes/class-fields.php +++ b/includes/class-fields.php @@ -139,6 +139,24 @@ public static function set_modified_date( int $post_id, \WP_Post $post ): void { add_action( 'save_post_odw_dataset', [ self::class, 'set_modified_date' ], 10, 2 ); } + // ------------------------------------------------------------------------- + // Required fields registry — single source of truth for validation + // ------------------------------------------------------------------------- + + /** + * Returns the required scalar fields definition used by both form rendering + * and the validation class. Each entry: [meta_key, label]. + * + * @return array + */ + public static function get_required_fields(): array { + return [ + [ 'meta_key' => '_odw_description', 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ) ], + [ 'meta_key' => '_odw_publisher', 'label' => __( 'Herausgebende Organisation (dct:publisher)', 'open-data-wizard' ) ], + [ 'meta_key' => '_odw_license', 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ) ], + ]; + } + // ------------------------------------------------------------------------- // Controlled vocabulary options // ------------------------------------------------------------------------- diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 206c3fa..ce6909a 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -40,6 +40,12 @@ public static function init(): void { } public static function register_routes(): void { + $format_arg = [ + 'default' => 'jsonld', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => fn( $v ) => in_array( $v, [ 'json', 'jsonld' ], true ), + ]; + register_rest_route( self::NAMESPACE, '/catalog', @@ -66,6 +72,7 @@ public static function register_routes(): void { 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ], + 'format' => $format_arg, ], ] ); @@ -78,11 +85,12 @@ public static function register_routes(): void { 'callback' => [ self::class, 'get_dataset' ], 'permission_callback' => '__return_true', 'args' => [ - 'id' => [ + 'id' => [ 'required' => true, 'sanitize_callback' => 'absint', 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v > 0, ], + 'format' => $format_arg, ], ] ); @@ -101,8 +109,9 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { - $response = new WP_REST_Response( $cached['body'], 200 ); - $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $content_type = self::resolve_content_type( (string) $request->get_param( 'format' ) ); + $response = new WP_REST_Response( $cached['body'], 200 ); + $response->header( 'Content-Type', $content_type ); $response->header( 'X-WP-Total', (string) $cached['total'] ); $response->header( 'X-WP-TotalPages', (string) $cached['pages'] ); $response->header( 'X-ODW-Cache', 'HIT' ); @@ -182,8 +191,10 @@ public static function get_catalog( WP_REST_Request $request ): WP_REST_Response 'pages' => $pages, ], self::CACHE_TTL ); + $content_type = self::resolve_content_type( (string) $request->get_param( 'format' ) ); + $response = new WP_REST_Response( $catalog, 200 ); - $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $response->header( 'Content-Type', $content_type ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) $pages ); $response->header( 'X-ODW-Cache', 'MISS' ); @@ -218,8 +229,9 @@ public static function get_dataset( WP_REST_Request $request ): WP_REST_Response $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { - $response = new WP_REST_Response( $cached, 200 ); - $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $content_type = self::resolve_content_type( (string) $request->get_param( 'format' ) ); + $response = new WP_REST_Response( $cached, 200 ); + $response->header( 'Content-Type', $content_type ); $response->header( 'X-ODW-Cache', 'HIT' ); return $response; } @@ -241,8 +253,10 @@ public static function get_dataset( WP_REST_Request $request ): WP_REST_Response set_transient( $cache_key, $body, self::CACHE_TTL ); + $content_type = self::resolve_content_type( (string) $request->get_param( 'format' ) ); + $response = new WP_REST_Response( $body, 200 ); - $response->header( 'Content-Type', 'application/ld+json; charset=UTF-8' ); + $response->header( 'Content-Type', $content_type ); $response->header( 'X-ODW-Cache', 'MISS' ); return $response; @@ -290,6 +304,20 @@ private static function delete_catalog_transients(): void { ); } + /** + * Resolve Content-Type header from ?format= parameter. + * + * ?format=jsonld (default) → application/ld+json + * ?format=json → application/json + * + * Foundation for future Turtle/RDF-XML content negotiation. + */ + private static function resolve_content_type( string $format ): string { + return 'json' === $format + ? 'application/json; charset=UTF-8' + : 'application/ld+json; charset=UTF-8'; + } + /** * Shorthand alias map for ?license= filter. */ diff --git a/includes/class-validation.php b/includes/class-validation.php index 3f86ecb..4ef3407 100644 --- a/includes/class-validation.php +++ b/includes/class-validation.php @@ -86,28 +86,18 @@ private static function validate( int $post_id, array $postarr ): array { // Carbon Fields stores compact input in a JSON blob during save. $cf_input = self::get_carbon_input( $postarr ); - // --- Titel --- + // --- Titel (WP-native, nicht in Carbon Fields) --- $title = trim( (string) ( $postarr['post_title'] ?? '' ) ); if ( '' === $title ) { $errors[] = __( 'Titel (dct:title)', 'open-data-wizard' ); } - // --- Beschreibung --- - $description = self::get_field_value( $post_id, $cf_input, '_odw_description', 'odw_description' ); - if ( '' === trim( (string) $description ) ) { - $errors[] = __( 'Beschreibung (dct:description)', 'open-data-wizard' ); - } - - // --- Publisher --- - $publisher = self::get_field_value( $post_id, $cf_input, '_odw_publisher', 'odw_publisher' ); - if ( '' === trim( (string) $publisher ) ) { - $errors[] = __( 'Herausgebende Organisation (dct:publisher)', 'open-data-wizard' ); - } - - // --- Lizenz --- - $license = self::get_field_value( $post_id, $cf_input, '_odw_license', 'odw_license' ); - if ( '' === trim( (string) $license ) ) { - $errors[] = __( 'Lizenz (dct:license)', 'open-data-wizard' ); + // --- Pflichtfelder aus zentraler Registry (ODW_Fields::get_required_fields) --- + foreach ( ODW_Fields::get_required_fields() as $field ) { + $value = self::get_field_value( $post_id, $cf_input, $field['meta_key'] ); + if ( '' === trim( (string) $value ) ) { + $errors[] = $field['label']; + } } // --- Mindestens 1 Distribution mit Zugriffs-URL --- @@ -122,19 +112,16 @@ private static function validate( int $post_id, array $postarr ): array { /** * Get a field value: prefer CF compact input (new save), fall back to existing meta. * - * @param int $post_id Post ID. - * @param array $cf_input Decoded Carbon Fields compact input. - * @param string $meta_key DB meta key (with underscore prefix). - * @param string $cf_key Carbon Fields key (without underscore). + * @param int $post_id Post ID. + * @param array $cf_input Decoded Carbon Fields compact input. + * @param string $meta_key DB meta key (underscore-prefixed, e.g. _odw_publisher). * @return mixed */ - private static function get_field_value( int $post_id, array $cf_input, string $meta_key, string $cf_key ): mixed { - // Prefer new POST data from Carbon Fields. + private static function get_field_value( int $post_id, array $cf_input, string $meta_key ): mixed { if ( isset( $cf_input[ $meta_key ] ) ) { return $cf_input[ $meta_key ]; } - // Fall back to existing meta (already saved). return get_post_meta( $post_id, $meta_key, true ); } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e78303b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +parameters: + level: 6 + paths: + - open-data-wizard.php + - uninstall.php + - includes/ + excludePaths: + - vendor/ + bootstrapFiles: + - vendor/autoload.php + extensions: + - szepeviktor/phpstan-wordpress + + # WordPress stubs werden via szepeviktor/phpstan-wordpress bereitgestellt. + # Eigene Globals deklarieren damit PHPStan sie kennt. + dynamicConstantNames: + - ABSPATH + - WP_UNINSTALL_PLUGIN + - DOING_AUTOSAVE + - ODW_VERSION + - ODW_PLUGIN_DIR + - ODW_PLUGIN_URL + - ODW_PLUGIN_FILE diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d62a205 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests/ + + + + + includes/ + open-data-wizard.php + + + vendor/ + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..7d353a4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,31 @@ +assertIsArray( $fields ); + $this->assertCount( 3, $fields ); + + $meta_keys = array_column( $fields, 'meta_key' ); + $this->assertContains( '_odw_description', $meta_keys ); + $this->assertContains( '_odw_publisher', $meta_keys ); + $this->assertContains( '_odw_license', $meta_keys ); + } + + public function test_get_license_options_has_empty_default(): void { + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $options = ODW_Fields::get_license_options(); + $this->assertArrayHasKey( '', $options ); + } + + public function test_get_license_label_returns_label_for_known_uri(): void { + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $label = ODW_Fields::get_license_label( 'https://creativecommons.org/licenses/by/4.0/' ); + $this->assertSame( 'CC-BY 4.0', $label ); + } + + public function test_get_license_label_returns_uri_for_unknown(): void { + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $unknown = 'https://example.com/custom-license'; + $this->assertSame( $unknown, ODW_Fields::get_license_label( $unknown ) ); + } + + public function test_get_format_mime_maps_csv(): void { + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $this->assertSame( 'text/csv', ODW_Fields::get_format_mime( 'CSV' ) ); + } + + public function test_get_format_mime_returns_format_for_unknown(): void { + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $this->assertSame( 'CUSTOM', ODW_Fields::get_format_mime( 'CUSTOM' ) ); + } +} From 7e96d67f9e4b8cd52db93e9a45a5402415636403 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:17:22 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Fix=201.2:=20URL-Validierung=20f=C3=BCr=20a?= =?UTF-8?q?ccess=5Furl=20in=20Validation-Klasse=20vervollst=C3=A4ndigt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_valid_url() Methode: filter_var(FILTER_VALIDATE_URL) + Schema-Whitelist (http, https, ftp, ftps) blockt javascript:, data: und andere unsichere Schemes - has_valid_distribution() prüft nun URLs aus CF compact input UND bestehenden Meta-Werten - Schließt den letzten offenen Punkt aus Plan-Eintrag 1.2 https://claude.ai/code/session_013ma6QYffgnE2eKgDfh1Qgn --- includes/class-validation.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/includes/class-validation.php b/includes/class-validation.php index 4ef3407..1f153b3 100644 --- a/includes/class-validation.php +++ b/includes/class-validation.php @@ -136,7 +136,7 @@ private static function has_valid_distribution( int $post_id, array $cf_input ): foreach ( $cf_input as $key => $value ) { // CF compact keys for complex fields look like: _odw_distributions[0][access_url] if ( str_contains( (string) $key, '_odw_distributions' ) && str_contains( (string) $key, 'access_url' ) ) { - if ( ! empty( $value ) ) { + if ( ! empty( $value ) && self::is_valid_url( (string) $value ) ) { return true; } } @@ -150,7 +150,7 @@ private static function has_valid_distribution( int $post_id, array $cf_input ): } foreach ( $distributions as $dist ) { - if ( ! empty( $dist['access_url'] ) ) { + if ( ! empty( $dist['access_url'] ) && self::is_valid_url( (string) $dist['access_url'] ) ) { return true; } } @@ -158,6 +158,19 @@ private static function has_valid_distribution( int $post_id, array $cf_input ): return false; } + /** + * Validate that a string is a safe HTTP(S) URL. + * Blocks javascript:, data:, and other non-HTTP schemes. + */ + private static function is_valid_url( string $url ): bool { + if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { + return false; + } + + $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); + return in_array( $scheme, [ 'http', 'https', 'ftp', 'ftps' ], true ); + } + /** * Parse the Carbon Fields compact JSON input from $_POST. * From cec7b1e206aed2c1edc21fc3c3cb586c8ff8c684 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:31:17 +0000 Subject: [PATCH 4/4] =?UTF-8?q?v1.3.0:=20Qualit=C3=A4tsindikatoren=20/=20A?= =?UTF-8?q?mpellogik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Scoring-System (0–100 Punkte, 3 Levels) bewertet die DCAT-AP Metadaten-Vollständigkeit automatisch nach jedem Speichern. - class-quality.php: 10 Indikatoren in 3 Gruppen (Pflicht 55 Pkt., Empfohlen 40 Pkt., Optional 5 Pkt.); Persistenz in 4 Meta-Keys; odw:qualityScore via odw_dataset_jsonld Filter in JSON-LD - class-admin.php: Qualitätsspalte mit farbigem Badge, sortierbar nach _odw_quality_score (meta_value_num); Meta-Box mit Gauge, Level-Badge und gruppierter Indikator-Tabelle - class-rest-api.php: odw:-Namespace in JSONLD_CONTEXT ergänzt - admin.css: --odw-color-quality-* Custom Properties; Badge, Gauge und Tabellen-Komponenten für Listenansicht und Edit-Screen https://claude.ai/code/session_013ma6QYffgnE2eKgDfh1Qgn --- CHANGELOG.md | 15 ++ assets/css/admin.css | 160 ++++++++++++++ includes/class-admin.php | 38 +++- includes/class-quality.php | 404 ++++++++++++++++++++++++++++++++++++ includes/class-rest-api.php | 3 +- open-data-wizard.php | 6 +- 6 files changed, 617 insertions(+), 9 deletions(-) create mode 100644 includes/class-quality.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cfdc87..9a23002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ Versionierung folgt [Semantic Versioning](https://semver.org/). --- +## [1.3.0] — 2026-04-21 + +### Hinzugefügt +- **Qualitätsindikatoren / Ampellogik** (`includes/class-quality.php`): Automatische Bewertung der Metadaten-Vollständigkeit (0–100 Punkte, 3 Levels: Gut/Mittel/Verbesserungsbedarf) + - 10 Indikatoren in 3 Gruppen: Pflichtfelder (55 Pkt.), Empfohlene Felder (40 Pkt.), Optionale Angaben (5 Pkt.) + - Automatische Neuberechnung nach jedem Speichern (`save_post_odw_dataset`, Priorität 30) + - Persistenz in 4 Meta-Keys: `_odw_quality_score`, `_odw_quality_level`, `_odw_quality_indicators`, `_odw_quality_calculated_at` +- **Qualitätsspalte in der Admin-Listenansicht**: Farbiger Badge (● 85) mit Tooltip; sortierbar +- **Qualitätsbericht-Meta-Box** auf dem Edit-Screen: Fortschrittsbalken, Ampel-Badge, gruppierte Indikator-Tabelle (✓/✗) mit Punkten, Zeitstempel der letzten Berechnung +- **`odw:qualityScore` im JSON-LD**: Qualitätsdaten werden via `odw_dataset_jsonld` Filter an den REST-API Output angehängt (`odw:score`, `odw:maxScore`, `odw:level`, `odw:calculatedAt`) +- **`odw:` JSON-LD Namespace** (`https://github.com/daimpad/OpenDataWizard/ns#`) in `JSONLD_CONTEXT` +- **CSS Qualitäts-Styles**: `--odw-color-quality-*` Custom Properties; `.odw-quality-badge`, `.odw-quality-gauge`, `.odw-quality-table` Komponenten + +--- + ## [1.2.0] — 2026-04-21 ### Hinzugefügt diff --git a/assets/css/admin.css b/assets/css/admin.css index 1de1e18..db269fd 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -20,6 +20,19 @@ --odw-color-code-text: #d4d4d4; --odw-color-code-border: #3c3c3c; + /* Qualitäts-Ampel */ + --odw-color-quality-high-dot: #1a7f37; + --odw-color-quality-high-bg: #dafbe1; + --odw-color-quality-high-text: #0f5323; + + --odw-color-quality-medium-dot: #9a6700; + --odw-color-quality-medium-bg: #fff3cd; + --odw-color-quality-medium-text: #6a4500; + + --odw-color-quality-low-dot: #c1272d; + --odw-color-quality-low-bg: #ffd7d5; + --odw-color-quality-low-text: #7d1212; + --odw-radius: 4px; --odw-transition: 0.15s ease; } @@ -158,6 +171,153 @@ color: var(--odw-color-draft-text); } +/* ========================================================================= + Qualitätsbadge (Listenansicht) + ========================================================================= */ + +.column-odw_quality { + width: 80px; + text-align: center; +} + +.odw-quality-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: default; +} + +.odw-quality-badge .odw-quality-dot { + font-size: 10px; + line-height: 1; +} + +.odw-quality--high { background: var(--odw-color-quality-high-bg); color: var(--odw-color-quality-high-text); } +.odw-quality--medium { background: var(--odw-color-quality-medium-bg); color: var(--odw-color-quality-medium-text); } +.odw-quality--low { background: var(--odw-color-quality-low-bg); color: var(--odw-color-quality-low-text); } +.odw-quality--unknown { color: var(--odw-color-text-muted); } + +.odw-quality--high .odw-quality-dot { color: var(--odw-color-quality-high-dot); } +.odw-quality--medium .odw-quality-dot { color: var(--odw-color-quality-medium-dot); } +.odw-quality--low .odw-quality-dot { color: var(--odw-color-quality-low-dot); } + +/* ========================================================================= + Qualitätsprüfungs-Meta-Box (Edit-Screen) + ========================================================================= */ + +.odw-quality-report { + padding: 4px 0; +} + +/* Zusammenfassung: Balken + Score + Ampel-Badge */ +.odw-quality-summary { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0 16px; + border-bottom: 1px solid var(--odw-color-border); + margin-bottom: 16px; +} + +.odw-quality-gauge-wrap { + display: flex; + align-items: center; + gap: 10px; + flex: 1; +} + +.odw-quality-gauge { + flex: 1; + max-width: 220px; + height: 10px; + background: var(--odw-color-border); + border-radius: 5px; + overflow: hidden; +} + +.odw-quality-bar { + height: 100%; + border-radius: 5px; + transition: width 0.4s ease; +} + +.odw-quality-bar--high { background: var(--odw-color-quality-high-dot); } +.odw-quality-bar--medium { background: var(--odw-color-quality-medium-dot); } +.odw-quality-bar--low { background: var(--odw-color-quality-low-dot); } + +.odw-quality-score-number { + font-size: 14px; + font-weight: 600; + color: var(--odw-color-text); + white-space: nowrap; +} + +.odw-quality-level-badge { + display: inline-block; + padding: 3px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.odw-quality-level-badge.odw-quality--high { background: var(--odw-color-quality-high-bg); color: var(--odw-color-quality-high-text); } +.odw-quality-level-badge.odw-quality--medium { background: var(--odw-color-quality-medium-bg); color: var(--odw-color-quality-medium-text); } +.odw-quality-level-badge.odw-quality--low { background: var(--odw-color-quality-low-bg); color: var(--odw-color-quality-low-text); } + +/* Indikator-Tabelle */ +.odw-quality-table { + border-collapse: collapse; + font-size: 13px; +} + +.odw-quality-table th, +.odw-quality-table td { + padding: 6px 10px; + vertical-align: middle; +} + +.odw-quality-section-row th { + background: var(--odw-color-bg-light); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--odw-color-text-muted); + border-top: 1px solid var(--odw-color-border); +} + +.odw-quality-col-pts { + width: 80px; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--odw-color-text-muted); +} + +.odw-quality-col-status { + width: 44px; + text-align: center; + font-size: 14px; + font-weight: 700; +} + +.odw-quality-pass .odw-quality-col-status { color: var(--odw-color-quality-high-dot); } +.odw-quality-fail .odw-quality-col-status { color: var(--odw-color-quality-low-dot); } +.odw-quality-fail { color: var(--odw-color-text-muted); } + +/* Footer */ +.odw-quality-footer { + margin-top: 12px; + font-size: 12px; + color: var(--odw-color-text-muted); +} + /* ========================================================================= Validation Admin Notice ========================================================================= */ diff --git a/includes/class-admin.php b/includes/class-admin.php index b5b285f..1771a3d 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -32,12 +32,13 @@ public static function init(): void { public static function set_columns( array $columns ): array { $new_columns = []; - $new_columns['cb'] = $columns['cb'] ?? ''; - $new_columns['title'] = __( 'Titel', 'open-data-wizard' ); - $new_columns['odw_license'] = __( 'Lizenz', 'open-data-wizard' ); - $new_columns['odw_theme'] = __( 'Thema', 'open-data-wizard' ); - $new_columns['odw_status'] = __( 'Status', 'open-data-wizard' ); - $new_columns['odw_modified'] = __( 'Änderungsdatum', 'open-data-wizard' ); + $new_columns['cb'] = $columns['cb'] ?? ''; + $new_columns['title'] = __( 'Titel', 'open-data-wizard' ); + $new_columns['odw_license'] = __( 'Lizenz', 'open-data-wizard' ); + $new_columns['odw_theme'] = __( 'Thema', 'open-data-wizard' ); + $new_columns['odw_quality'] = __( 'Qualität', 'open-data-wizard' ); + $new_columns['odw_status'] = __( 'Status', 'open-data-wizard' ); + $new_columns['odw_modified'] = __( 'Änderungsdatum', 'open-data-wizard' ); return $new_columns; } @@ -68,6 +69,25 @@ public static function render_column( string $column, int $post_id ): void { } break; + case 'odw_quality': + $quality = ODW_Quality::get( $post_id ); + + if ( '' === $quality['level'] ) { + echo ''; + } else { + $level = $quality['level']; + $score = $quality['score']; + $label = ODW_Quality::get_level_label( $level ); + $title_attr = sprintf( '%s · %d/100 %s', $label, $score, __( 'Punkte', 'open-data-wizard' ) ); + printf( + ' %d', + esc_attr( $level ), + esc_attr( $title_attr ), + (int) $score + ); + } + break; + case 'odw_modified': $modified = get_post_meta( $post_id, '_odw_modified', true ); echo esc_html( $modified ?: '—' ); @@ -81,6 +101,7 @@ public static function render_column( string $column, int $post_id ): void { public static function sortable_columns( array $columns ): array { $columns['odw_modified'] = 'modified'; $columns['odw_theme'] = 'odw_theme'; + $columns['odw_quality'] = 'odw_quality'; return $columns; } @@ -100,6 +121,11 @@ public static function handle_meta_orderby( WP_Query $query ): void { $query->set( 'meta_key', '_odw_theme' ); $query->set( 'orderby', 'meta_value' ); } + + if ( 'odw_quality' === $query->get( 'orderby' ) ) { + $query->set( 'meta_key', '_odw_quality_score' ); + $query->set( 'orderby', 'meta_value_num' ); + } } /** diff --git a/includes/class-quality.php b/includes/class-quality.php new file mode 100644 index 0000000..a3e5dad --- /dev/null +++ b/includes/class-quality.php @@ -0,0 +1,404 @@ + + */ + public static function get_indicators(): array { + return [ + // Pflichtfelder (DCAT-AP 3.0 mandatory) — 55 Punkte + [ 'key' => 'title', 'label' => __( 'Titel (dct:title)', 'open-data-wizard' ), 'points' => 10, 'required' => true ], + [ 'key' => 'description', 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ), 'points' => 10, 'required' => true ], + [ 'key' => 'publisher', 'label' => __( 'Herausgeber (dct:publisher)', 'open-data-wizard' ), 'points' => 10, 'required' => true ], + [ 'key' => 'license', 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ), 'points' => 10, 'required' => true ], + [ 'key' => 'distribution', 'label' => __( 'Distribution mit URL (dcat:accessURL)', 'open-data-wizard' ), 'points' => 15, 'required' => true ], + + // Empfohlene Felder (DCAT-AP 3.0 recommended) — 40 Punkte + [ 'key' => 'language', 'label' => __( 'Sprache (dct:language)', 'open-data-wizard' ), 'points' => 10, 'required' => false ], + [ 'key' => 'keywords', 'label' => __( 'Schlagworte (dcat:keyword)', 'open-data-wizard' ), 'points' => 10, 'required' => false ], + [ 'key' => 'theme', 'label' => __( 'Thema (dcat:theme)', 'open-data-wizard' ), 'points' => 10, 'required' => false ], + [ 'key' => 'issued', 'label' => __( 'Veröffentlichungsdatum (dct:issued)', 'open-data-wizard' ), 'points' => 10, 'required' => false ], + + // Optionale Angaben — 5 Punkte + [ 'key' => 'dist_format', 'label' => __( 'Format der Distribution (dct:format)', 'open-data-wizard' ), 'points' => 5, 'required' => false ], + ]; + // Summe: 55 + 40 + 5 = 100 + } + + // ------------------------------------------------------------------------- + // Scoring + // ------------------------------------------------------------------------- + + /** + * Berechnet den Qualitätsscore für einen Datensatz. + * + * @return array{score: int, level: string, indicators: array, calculated_at: string} + */ + public static function calculate( int $post_id ): array { + $post = get_post( $post_id ); + + if ( ! $post || 'odw_dataset' !== $post->post_type ) { + return self::empty_result(); + } + + $total = 0; + $breakdown = []; + + foreach ( self::get_indicators() as $indicator ) { + $passed = self::check_indicator( $indicator['key'], $post ); + $earned = $passed ? $indicator['points'] : 0; + $total += $earned; + + $breakdown[ $indicator['key'] ] = [ + 'label' => $indicator['label'], + 'points' => $indicator['points'], + 'earned' => $earned, + 'passed' => $passed, + 'required' => $indicator['required'], + ]; + } + + return [ + 'score' => $total, + 'level' => self::get_level( $total ), + 'indicators' => $breakdown, + 'calculated_at' => current_time( 'Y-m-d H:i:s' ), + ]; + } + + /** + * Prüft einen einzelnen Indikator am WP_Post-Objekt. + */ + private static function check_indicator( string $key, \WP_Post $post ): bool { + switch ( $key ) { + case 'title': + return '' !== trim( $post->post_title ); + + case 'description': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_description' ) ); + + case 'publisher': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_publisher' ) ); + + case 'license': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_license' ) ); + + case 'distribution': + $dists = carbon_get_post_meta( $post->ID, 'odw_distributions' ); + if ( ! is_array( $dists ) ) { + return false; + } + foreach ( $dists as $dist ) { + if ( ! empty( $dist['access_url'] ) ) { + return true; + } + } + return false; + + case 'language': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_language' ) ); + + case 'keywords': + $raw = (string) carbon_get_post_meta( $post->ID, 'odw_keywords' ); + $keywords = array_filter( array_map( 'trim', preg_split( '/\r?\n/', $raw ) ) ); + return ! empty( $keywords ); + + case 'theme': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_theme' ) ); + + case 'issued': + return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_issued' ) ); + + case 'dist_format': + $dists = carbon_get_post_meta( $post->ID, 'odw_distributions' ); + if ( ! is_array( $dists ) ) { + return false; + } + foreach ( $dists as $dist ) { + if ( ! empty( $dist['format'] ) ) { + return true; + } + } + return false; + } + + return false; + } + + /** + * Ermittelt das Ampel-Level aus dem Score. + */ + public static function get_level( int $score ): string { + if ( $score >= 80 ) { + return self::LEVEL_HIGH; + } + if ( $score >= 50 ) { + return self::LEVEL_MEDIUM; + } + return self::LEVEL_LOW; + } + + // ------------------------------------------------------------------------- + // Persistierung + // ------------------------------------------------------------------------- + + /** + * Holt gespeicherte Qualitätsdaten aus Post-Meta. + * + * @return array{score: int, level: string, indicators: array, calculated_at: string} + */ + public static function get( int $post_id ): array { + $level = (string) get_post_meta( $post_id, '_odw_quality_level', true ); + + if ( '' === $level ) { + return self::empty_result(); + } + + $indicators = get_post_meta( $post_id, '_odw_quality_indicators', true ); + + return [ + 'score' => (int) get_post_meta( $post_id, '_odw_quality_score', true ), + 'level' => $level, + 'indicators' => is_array( $indicators ) ? $indicators : [], + 'calculated_at' => (string) get_post_meta( $post_id, '_odw_quality_calculated_at', true ), + ]; + } + + /** + * Speichert Qualitätsdaten in Post-Meta. + */ + public static function store( int $post_id, array $result ): void { + update_post_meta( $post_id, '_odw_quality_score', $result['score'] ); + update_post_meta( $post_id, '_odw_quality_level', $result['level'] ); + update_post_meta( $post_id, '_odw_quality_indicators', $result['indicators'] ); + update_post_meta( $post_id, '_odw_quality_calculated_at', $result['calculated_at'] ); + } + + /** + * Hook-Callback: Qualität nach jedem Speichern neu berechnen. + */ + public static function recalculate_on_save( int $post_id ): void { + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + $result = self::calculate( $post_id ); + self::store( $post_id, $result ); + } + + // ------------------------------------------------------------------------- + // REST API Integration + // ------------------------------------------------------------------------- + + /** + * Hängt Qualitätsdaten an den JSON-LD Dataset-Array an. + * + * @param array $dataset Der JSON-LD Array. + * @param int $post_id Post-ID. + * @return array + */ + public static function append_to_jsonld( array $dataset, int $post_id ): array { + $quality = self::get( $post_id ); + + if ( '' === $quality['level'] ) { + return $dataset; + } + + $dataset['odw:qualityScore'] = [ + '@type' => 'odw:QualityScore', + 'odw:score' => $quality['score'], + 'odw:maxScore' => 100, + 'odw:level' => $quality['level'], + 'odw:calculatedAt' => $quality['calculated_at'], + ]; + + return $dataset; + } + + // ------------------------------------------------------------------------- + // Admin Meta-Box + // ------------------------------------------------------------------------- + + /** + * Registriert die Qualitäts-Meta-Box auf dem Edit-Screen. + */ + public static function register_meta_box(): void { + add_meta_box( + 'odw-quality-report', + __( 'Qualitätsprüfung', 'open-data-wizard' ), + [ self::class, 'render_meta_box' ], + 'odw_dataset', + 'normal', + 'default' + ); + } + + /** + * Rendert den Inhalt der Qualitäts-Meta-Box. + */ + public static function render_meta_box( \WP_Post $post ): void { + $quality = self::get( $post->ID ); + $indicators = self::get_indicators(); + + if ( '' === $quality['level'] ) { + echo '

' . esc_html__( 'Noch keine Qualitätsanalyse vorhanden. Datensatz speichern, um die Prüfung auszuführen.', 'open-data-wizard' ) . '

'; + return; + } + + $score = $quality['score']; + $level = $quality['level']; + $level_label = self::get_level_label( $level ); + $level_class = 'odw-quality--' . $level; + $stored = $quality['indicators']; + ?> +
+ +
+
+
+
+
+
+ / 100 +
+ + + +
+ + + + + + + + + + + '; + } elseif ( false === $required && null !== $prev_required ) { + $section = ( $pts >= 10 ) + ? __( 'Empfohlene Felder', 'open-data-wizard' ) + : __( 'Optionale Angaben', 'open-data-wizard' ); + echo ''; + } + $prev_required = $required; + } elseif ( false === $required && $pts < 10 && $prev_pts >= 10 ) { + // Wechsel von Empfohlen zu Optional innerhalb derselben required=false Gruppe. + echo ''; + } + + $prev_pts = $pts; + + $status_icon = $passed ? '✓' : '✗'; + $status_class = $passed ? 'odw-quality-pass' : 'odw-quality-fail'; + $pts_display = $passed ? $pts : "0 / {$pts}"; + ?> + + + + + + + +
' . esc_html__( 'Pflichtfelder', 'open-data-wizard' ) . '
' . esc_html( $section ) . '
' . esc_html__( 'Optionale Angaben', 'open-data-wizard' ) . '
+ + +
+ __( 'Gut', 'open-data-wizard' ), + self::LEVEL_MEDIUM => __( 'Mittel', 'open-data-wizard' ), + self::LEVEL_LOW => __( 'Verbesserungsbedarf', 'open-data-wizard' ), + ][ $level ] ?? __( 'Unbekannt', 'open-data-wizard' ); + } + + // ------------------------------------------------------------------------- + // Intern + // ------------------------------------------------------------------------- + + /** @return array{score: int, level: string, indicators: array, calculated_at: string} */ + private static function empty_result(): array { + return [ 'score' => 0, 'level' => '', 'indicators' => [], 'calculated_at' => '' ]; + } +} diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index ce6909a..9a14489 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -22,13 +22,14 @@ class ODW_Rest_API { private const CACHE_TTL = 300; /** - * DCAT-AP 3.0 JSON-LD @context + * DCAT-AP 3.0 JSON-LD @context inkl. Plugin-eigenem odw:-Namespace für Qualitätsdaten. */ private const JSONLD_CONTEXT = [ 'dcat' => 'https://www.w3.org/ns/dcat#', 'dct' => 'http://purl.org/dc/terms/', 'foaf' => 'http://xmlns.com/foaf/0.1/', 'xsd' => 'http://www.w3.org/2001/XMLSchema#', + 'odw' => 'https://github.com/daimpad/OpenDataWizard/ns#', ]; public static function init(): void { diff --git a/open-data-wizard.php b/open-data-wizard.php index 0af4de1..5f12841 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 zivilgesellschaftliche Organisationen. Bereitstellung als maschinenlesbarer Endpoint für Civora/Piveau-Harvesting. - * Version: 1.1.0 + * Version: 1.3.0 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: Datenatlas Zivilgesellschaft @@ -19,7 +19,7 @@ exit; } -define( 'ODW_VERSION', '1.1.0' ); +define( 'ODW_VERSION', '1.3.0' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); @@ -108,12 +108,14 @@ function odw_bootstrap(): void { require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; require_once ODW_PLUGIN_DIR . 'includes/class-rest-api.php'; require_once ODW_PLUGIN_DIR . 'includes/class-validation.php'; + require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; require_once ODW_PLUGIN_DIR . 'includes/class-admin.php'; ODW_Post_Types::init(); ODW_Fields::init(); ODW_Rest_API::init(); ODW_Validation::init(); + ODW_Quality::init(); ODW_Admin::init(); } add_action( 'after_setup_theme', 'odw_bootstrap' );