diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b81dea..24a4c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ Versionierung folgt [Semantic Versioning](https://semver.org/). --- +## [1.8.0] — 2026-04-21 + +### Hinzugefügt +- **Native wp.media Upload-Widget** als Sidebar-Meta-Box auf dem Datensatz-Edit-Screen: + - Button „Datei auswählen / hochladen" öffnet den nativen WordPress Media Library Frame + - Dateivorschau zeigt den Dateinamen (oder „Keine Datei ausgewählt") mit Dokumenten-Icon + - „Entfernen"-Button löscht die Verknüpfung und leert alle abhängigen Meta-Felder +- **Automatische Meta-Berechnung beim Speichern**: Nach jeder Dateiauswahl werden `_odw_file_size` (Bytes als Integer) und `_odw_file_format` (z.B. „CSV") direkt aus der Mediathek-Datei ausgelesen und gespeichert — kein Runtime-`filesize()`-Aufruf mehr beim Shortcode-Rendering nötig +- **`assets/js/odw-file-upload.js`** — jQuery + wp.media Integration; UI-Zustand wird serverseitig via `wp_localize_script` initialisiert; Media-Frame-Instanz wird wiederverwendet + +### Geändert +- **Shortcode** `[odw_dataset]` liest `_odw_file_size` und `_odw_file_format` jetzt aus vorberechnetem Post-Meta (mit Fallback auf Runtime-Berechnung für ältere Datensätze) +- Carbon Fields `Field::make('file', 'odw_file_id')` in Tab 3 entfernt — ersetzt durch die native Meta-Box-Implementierung in der Sidebar + +### Sicherheit +- `save_file_attachment()` prüft `wp_verify_nonce('odw_save_file_attachment')` und `current_user_can('edit_post')` vor jeder Speicherung + +--- + ## [1.7.0] — 2026-04-21 ### Hinzugefügt diff --git a/README.md b/README.md index d08cb8f..1e9ed92 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,40 @@ Open Data Wizard implementiert **DCAT-AP 3.0** und erzeugt valide **JSON-LD**-Au Eigener Bereich im WordPress-Backend mit Übersicht, Filterung und Statusverwaltung (Entwurf / Veröffentlicht). ### 🧭 Geführter Wizard -Vier-Schritt-Assistent mit Pflichtfeldprüfung, Hilfetexten und Inline-Validierung: +Fünf-Tab-Assistent mit Pflichtfeldprüfung, Hilfetexten und Inline-Validierung: 1. **Pflichtangaben** — Titel, Beschreibung, Herausgeber, Lizenz 2. **Optionale Angaben** — Sprache, Schlagworte, Thema, Zeitraum -3. **Distribution** — Zugriffs-URL, Format, Dateigröße -4. **Vorschau** — generiertes JSON-LD live einsehen +3. **Distribution** — Zugriffs-URL, Format, Dateigröße (wiederholbar) +4. **Erweiterte Angaben** — Projektseite, Aktualisierungsfrequenz, geograph. Abdeckung, Zeitraum, Kontaktpunkt +5. **Vorschau** — generiertes JSON-LD live einsehen + +### 📎 Download-Datei (nativer wp.media Upload) +Sidebar-Meta-Box auf dem Edit-Screen — vollständig unabhängig von Carbon Fields: +- **„Datei auswählen / hochladen"**-Button öffnet den nativen WordPress Media Library Frame (`wp.media`) +- Dateivorschau zeigt den Attachment-Titel mit Dokumenten-Icon; „Entfernen"-Button mit Capability-Check +- Beim Speichern werden `_odw_file_size` (Bytes) und `_odw_file_format` (z.B. „CSV") automatisch aus der Datei berechnet und gespeichert — kein Runtime-I/O beim Shortcode-Rendering nötig +- JavaScript (`assets/js/odw-file-upload.js`, jQuery): Media-Frame-Instanz wird wiederverwendet; aktueller Dateiname via `wp_localize_script` aus PHP initialisiert +- Sicherheit: `wp_verify_nonce` + `current_user_can('edit_post')` im PHP-Save-Handler + +### ⚙️ Einstellungsseite +Untermenü unter *Datensätze → Einstellungen* mit vier Bereichen: +- **Katalog** — Titel (überschreibt DCAT-AP `dct:title` im Catalog-Endpoint) und Herausgebende Organisation +- **Standardwerte** — Standard-Lizenz und -Sprache (werden bei neuen Datensätzen vorausgefüllt) +- **REST API** — Cache-Laufzeit (60–86400 Sekunden) +- **Deinstallation** — Opt-in Checkbox für vollständige Datenlöschung + +### 📊 Qualitätsindikatoren +Automatische Metadaten-Vollständigkeitsprüfung nach DCAT-AP 3.0 (0–100 Punkte, Ampellogik): +- **Grün** (≥ 80 Pkt.) · **Gelb** (50–79 Pkt.) · **Rot** (< 50 Pkt.) +- Berechnung nach jedem Speichern; Ergebnis in der Admin-Listenansicht und als Meta-Box sichtbar +- Score wird im JSON-LD als `odw:qualityScore` mitgeliefert + +### 📥 Download-Card Shortcode +``` +[odw_dataset id="123"] +``` +Rendert eine strukturierte Download-Card im Frontend: Titel, Thema-Badge, Lizenz, DCAT-Qualitätsscore, Dateigröße/-format und Download-Button. CSS (`assets/css/frontend.css`) wird nur auf Seiten geladen, die den Shortcode enthalten. ### 🔗 Maschinenlesbarer Endpoint Das Plugin stellt einen öffentlichen REST API Endpoint bereit: @@ -130,44 +158,91 @@ 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 +├── open-data-wizard.php # Plugin-Header & Bootstrap (v1.8.0) +├── uninstall.php # Opt-in Datenlöschung ├── composer.json -├── phpstan.neon -├── phpunit.xml -├── vendor/ # Carbon Fields (gebündelt) +├── phpstan.neon # Statische Analyse Level 6 +├── phpunit.xml # PHPUnit 9 Konfiguration +├── vendor/ # Carbon Fields + Dev-Dependencies (gebündelt) ├── includes/ -│ ├── class-post-types.php # CPT-Registrierung: odw_dataset -│ ├── 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, Help Tabs, Assets +│ ├── class-post-types.php # CPT-Registrierung: odw_dataset +│ ├── class-fields.php # Carbon Fields (5 Tabs), DCAT-AP Mapping, JSON-LD Builder +│ ├── class-rest-api.php # REST Endpoints /catalog + /datasets/, Transient-Cache +│ ├── class-validation.php # Pflichtfeldprüfung vor Veröffentlichung +│ ├── class-quality.php # Qualitätsindikatoren (0–100 Punkte, Ampellogik) [v1.3.0] +│ ├── class-admin.php # Listenansicht, wp.media Meta-Box, Help Tabs, Assets +│ ├── class-shortcode.php # [odw_dataset]-Shortcode, Download-Card [v1.4.0] +│ ├── class-setup.php # Demo-Datensatz bei Aktivierung, Willkommens-Notice [v1.5.0] +│ └── class-settings.php # Einstellungsseite (Catalog, Defaults, API, Cleanup) [v1.6.0] ├── assets/ -│ ├── js/wizard-tabs.js # Tab-Navigation (Vanilla JS) -│ └── css/admin.css # Admin-Styles (CSS Custom Properties) +│ ├── js/ +│ │ ├── wizard-tabs.js # Tab-Navigation (Vanilla JS, kein jQuery) +│ │ └── odw-file-upload.js # wp.media Upload-Widget (jQuery) [v1.8.0] +│ ├── css/ +│ │ ├── admin.css # Admin-Styles (CSS Custom Properties) +│ │ └── frontend.css # Shortcode Download-Card (strukturell, kein Theme-Overhead) +│ └── sample/ +│ └── beispiel-datensatz.csv # Demo-Datensatz für die Aktivierungs-Installation ├── tests/ -│ ├── bootstrap.php -│ └── test-fields.php -├── .github/workflows/ci.yml +│ ├── bootstrap.php # PHPUnit + WP_Mock Bootstrap +│ ├── test-fields.php # Tests: ODW_Fields (Lizenz, Format, Pflichtfelder) +│ ├── test-settings.php # Tests: ODW_Settings (get(), filter_catalog_title()) +│ ├── test-quality.php # Tests: ODW_Quality (Scoring, Level, Meta) +│ ├── test-shortcode.php # Tests: ODW_Shortcode (format_bytes, render edge cases) +│ └── test-fields-extended.php # Tests: JSON-LD Builder v1.7.0 Felder +├── .github/workflows/ci.yml # CI: PHPCS + PHPStan + PHPUnit (PHP 8.1–8.3) └── languages/ ``` ### Feldmapping DCAT-AP 3.0 +#### Tab 1 — Pflichtangaben + | Feld | DCAT-AP Prädikat | Pflicht | |---|---|---| | Titel | `dct:title` | ✓ | | Beschreibung | `dct:description` | ✓ | -| Herausgeber | `dct:publisher` | ✓ | -| Lizenz | `dct:license` | ✓ | +| Herausgeber | `dct:publisher` → `foaf:Organization` | ✓ | +| Lizenz | `dct:license` (URI) | ✓ | + +#### Tab 2 — Optionale Angaben + +| Feld | DCAT-AP Prädikat | Pflicht | +|---|---|---| | Sprache | `dct:language` | — | -| Schlagworte | `dcat:keyword` | — | +| Schlagworte (eine pro Zeile) | `dcat:keyword` | — | | Thema | `dcat:theme` | — | | Veröffentlichungsdatum | `dct:issued` | — | -| Änderungsdatum | `dct:modified` | — | -| Zugriffs-URL (Distribution) | `dcat:accessURL` | ✓ (min. 1) | -| Format (Distribution) | `dct:format` | — | -| Dateigröße (Distribution) | `dcat:byteSize` | — | +| Änderungsdatum | `dct:modified` (auto) | — | + +#### Tab 3 — Distribution + +| Feld | DCAT-AP Prädikat | Pflicht | +|---|---|---| +| Zugriffs-URL | `dcat:accessURL` | ✓ (min. 1) | +| Format | `dct:format` (MIME) | — | +| Dateigröße in Bytes | `dcat:byteSize` | — | + +#### Tab 4 — Erweiterte Angaben + +| Feld | DCAT-AP Prädikat | Pflicht | +|---|---|---| +| Projektseite | `dcat:landingPage` (`@id`) | — | +| Aktualisierungsfrequenz | `dct:accrualPeriodicity` (EU-URI) | — | +| Geographische Abdeckung | `dct:spatial` → `dct:Location` + `skos:prefLabel` | — | +| Zeitlicher Bezug Start | `dct:temporal` → `dcat:startDate` | — | +| Zeitlicher Bezug Ende | `dct:temporal` → `dcat:endDate` | — | +| Kontaktpunkt Name | `dcat:contactPoint` → `vcard:fn` | — | +| Kontaktpunkt E-Mail | `vcard:hasEmail` (mit `mailto:`-Prefix) | — | +| Kontaktpunkt Website | `vcard:hasURL` (`@id`) | — | + +#### Sidebar — Download-Datei + +| Feld | Interner Meta-Key | Beschreibung | +|---|---|---| +| Attachment-ID | `_odw_file_id` | Mediathek-Datei (wird als Download-Button im Shortcode verwendet) | +| Dateigröße (auto) | `_odw_file_size` | Bytes, auto-berechnet beim Speichern | +| Dateiformat (auto) | `_odw_file_format` | Großbuchstaben-Extension (z.B. „CSV"), auto-berechnet | ### REST API @@ -197,28 +272,37 @@ GET /wp-json/datenatlas/v1/datasets/ ```json { "@context": { - "dcat": "https://www.w3.org/ns/dcat#", - "dct": "http://purl.org/dc/terms/", - "foaf": "http://xmlns.com/foaf/0.1/" + "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#", + "vcard": "http://www.w3.org/2006/vcard/ns#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "odw": "https://github.com/daimpad/OpenDataWizard/ns#" }, "@type": "dcat:Catalog", + "dct:title": "Mein Datenkatalog", "dcat:dataset": [ { "@type": "dcat:Dataset", "dct:title": "Mitgliederdaten 2023", "dct:description": "Anonymisierte Mitgliederstatistik.", - "dct:publisher": { - "@type": "foaf:Organization", - "foaf:name": "Musterorganisation e.V." - }, + "dct:publisher": { "@type": "foaf:Organization", "foaf:name": "Musterorganisation e.V." }, "dct:license": "https://creativecommons.org/licenses/by/4.0/", "dcat:distribution": [ { "@type": "dcat:Distribution", "dcat:accessURL": "https://organisation.de/daten/mitglieder.csv", - "dct:format": "text/csv" + "dct:format": "text/csv", + "dcat:byteSize": 20480 } - ] + ], + "dcat:contactPoint": { + "@type": "vcard:Organization", + "vcard:fn": "Open Data Team", + "vcard:hasEmail": "mailto:opendata@organisation.de" + }, + "odw:qualityScore": { "odw:score": 85, "odw:maxScore": 100, "odw:level": "high" } } ] } @@ -251,19 +335,22 @@ add_filter( 'odw_dataset_jsonld', function( array $jsonld, int $post_id ): array ### Abhängigkeiten -| Paket | Version | Lizenz | +| Paket | Version | Zweck | |---|---|---| -| [Carbon Fields](https://carbonfields.net/) | ^3.6 | MIT | +| [Carbon Fields](https://carbonfields.net/) | ^3.6 | Admin-Formular (5-Tab-Wizard) | +| [PHPUnit](https://phpunit.de/) | ^9.6 | Unit-Tests (dev) | +| [WP_Mock](https://github.com/10up/wp_mock) | ^1.0 | WordPress-Stubs für Tests (dev) | +| [PHPStan](https://phpstan.org/) + WordPress-Stubs | ^2.0 | Statische Analyse Level 6 (dev) | +| [WPCS](https://github.com/WordPress/WordPress-Coding-Standards) | ^3.1 | Coding Standards (dev) | --- ## Roadmap - [ ] Delta-Harvesting Endpoint (`/changes?since=`) -- [ ] Push/Webhook bei Statusänderung -- [ ] Content Negotiation: Turtle / RDF-XML -- [ ] Automatische Metadatenextraktion aus hochgeladenen Dateien -- [ ] Qualitätsindikatoren (Vollständigkeit, Lizenzklarheit) +- [ ] Push/Webhook bei Statusänderung an Civora/Piveau +- [ ] Content Negotiation: Turtle / RDF-XML Ausgabe +- [ ] Gutenberg Block für die Download-Card - [ ] Mehrsprachigkeit (WPML/Polylang) - [ ] CESSDA-Felder als optionales Profil @@ -285,13 +372,9 @@ 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 ); -``` +Das Plugin löscht bei Deinstallation standardmäßig **keine** Daten (Opt-in). -Danach das Plugin im WordPress-Backend deinstallieren. +Um alle Plugin-Daten zu löschen, die Checkbox unter **Datensätze → Einstellungen → Deinstallation** aktivieren und dann das Plugin im WordPress-Backend deinstallieren. `uninstall.php` entfernt in diesem Fall alle `odw_dataset`-Posts, alle `_odw_*`-Metafelder sowie die Plugin-Optionen (`odw_settings`, `odw_demo_post_id`, `odw_show_welcome`). --- diff --git a/assets/js/odw-file-upload.js b/assets/js/odw-file-upload.js index fd10273..bcfdc36 100644 --- a/assets/js/odw-file-upload.js +++ b/assets/js/odw-file-upload.js @@ -51,6 +51,11 @@ _setEmpty(); } ); + /** + * Setzt die UI in den „Datei ausgewählt"-Zustand. + * + * @param {string} name Dateiname oder Attachment-Titel aus der Mediathek. + */ function _setHasFile( name ) { $fileName.text( name ); $preview @@ -59,6 +64,10 @@ $removeBtn.prop( 'disabled', false ); } + /** + * Setzt die UI in den leeren Zustand (keine Datei ausgewählt). + * Wird beim Klick auf „Entfernen" aufgerufen. + */ function _setEmpty() { $fileName.text( odwFileUpload.labels.noFile ); $preview diff --git a/composer.json b/composer.json index 2aa21c5..308ed9d 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,14 @@ "optimize-autoloader": true, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true + }, + "platform": { + "php": "8.1.0" } }, "scripts": { - "phpcs": "phpcs --standard=WordPress --extensions=php --ignore=vendor/ .", - "phpcbf": "phpcbf --standard=WordPress --extensions=php --ignore=vendor/ .", + "phpcs": "phpcs --standard=phpcs.xml", + "phpcbf": "phpcbf --standard=phpcs.xml", "phpstan": "phpstan analyse --configuration=phpstan.neon", "test": "phpunit --configuration=phpunit.xml" } diff --git a/composer.lock b/composer.lock index a884c7b..626b092 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8db8ddd282fd67a6acb66b205e25aa54", + "content-hash": "fe5ac247fc338e671591457a4cd0d9e0", "packages": [ { "name": "htmlburger/carbon-fields", @@ -348,29 +348,30 @@ }, { "name": "doctrine/instantiator", - "version": "2.1.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -397,7 +398,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -413,7 +414,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T06:47:08+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -2774,5 +2775,8 @@ "php": ">=8.1" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.1.0" + }, "plugin-api-version": "2.6.0" } diff --git a/includes/class-admin.php b/includes/class-admin.php index ee8e376..71dfd8c 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -1,6 +1,13 @@ '; - $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' ); - $new_columns['odw_shortcode'] = __( 'Shortcode', 'open-data-wizard' ); - - return $new_columns; - } - - /** - * Render custom column content. - */ - public static function render_column( string $column, int $post_id ): void { - switch ( $column ) { - case 'odw_license': - $license = (string) carbon_get_post_meta( $post_id, 'odw_license' ); - echo esc_html( ODW_Fields::get_license_label( $license ) ); - break; - - case 'odw_theme': - $theme = carbon_get_post_meta( $post_id, 'odw_theme' ); - echo esc_html( (string) $theme ); - break; - - case 'odw_status': - $post = get_post( $post_id ); - $status = $post ? $post->post_status : ''; - - if ( 'publish' === $status ) { - echo '' . esc_html__( 'Veröffentlicht', 'open-data-wizard' ) . ''; - } else { - echo '' . esc_html__( 'Entwurf', 'open-data-wizard' ) . ''; - } - 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 ?: '—' ); - break; - - case 'odw_shortcode': - $shortcode = '[odw_dataset id="' . $post_id . '"]'; - printf( - '', - esc_attr( $shortcode ), - esc_attr__( 'Klicken zum Markieren', 'open-data-wizard' ) - ); - break; - } - } - - /** - * Define sortable columns. - */ - public static function sortable_columns( array $columns ): array { - $columns['odw_modified'] = 'modified'; - $columns['odw_theme'] = 'odw_theme'; - $columns['odw_quality'] = 'odw_quality'; - 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' ); - } - - if ( 'odw_quality' === $query->get( 'orderby' ) ) { - $query->set( 'meta_key', '_odw_quality_score' ); - $query->set( 'orderby', 'meta_value_num' ); - } - } - - /** - * Status filter dropdown above list table. - */ - public static function status_filter_dropdown(): void { - global $typenow; - - if ( ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { - return; - } + /** + * Registers all WordPress hooks for the admin UI. + */ + public static function init(): void { + add_filter( 'manage_odw_dataset_posts_columns', array( self::class, 'set_columns' ) ); + add_action( 'manage_odw_dataset_posts_custom_column', array( self::class, 'render_column' ), 10, 2 ); + add_filter( 'manage_edit-odw_dataset_sortable_columns', array( self::class, 'sortable_columns' ) ); + add_action( 'pre_get_posts', array( self::class, 'handle_meta_orderby' ) ); + add_action( 'restrict_manage_posts', array( self::class, 'status_filter_dropdown' ) ); + add_filter( 'parse_query', array( self::class, 'apply_status_filter' ) ); + add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_assets' ) ); + add_action( 'add_meta_boxes', array( self::class, 'register_help_tabs' ) ); + add_action( 'load-post.php', array( self::class, 'register_help_tabs' ) ); + add_action( 'load-post-new.php', array( self::class, 'register_help_tabs' ) ); + add_action( 'add_meta_boxes', array( self::class, 'register_file_meta_box' ) ); + add_action( 'save_post_odw_dataset', array( self::class, 'save_file_attachment' ), 20, 2 ); + } + + /** + * Define list table columns. + * + * @param array $columns Default columns. + * @return array Modified columns. + */ + public static function set_columns( array $columns ): array { + $new_columns = array(); + + $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' ); + $new_columns['odw_shortcode'] = __( 'Shortcode', 'open-data-wizard' ); + + return $new_columns; + } + + /** + * Render custom column content. + * + * @param string $column Column slug. + * @param int $post_id Post ID. + */ + public static function render_column( string $column, int $post_id ): void { + switch ( $column ) { + case 'odw_license': + $license = (string) carbon_get_post_meta( $post_id, 'odw_license' ); + echo esc_html( ODW_Fields::get_license_label( $license ) ); + break; + + case 'odw_theme': + $theme = carbon_get_post_meta( $post_id, 'odw_theme' ); + echo esc_html( (string) $theme ); + break; + + case 'odw_status': + $post = get_post( $post_id ); + $status = $post ? $post->post_status : ''; + + if ( 'publish' === $status ) { + echo '' . esc_html__( 'Veröffentlicht', 'open-data-wizard' ) . ''; + } else { + echo '' . esc_html__( 'Entwurf', 'open-data-wizard' ) . ''; + } + 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 ? $modified : '—' ); + break; + + case 'odw_shortcode': + $shortcode = '[odw_dataset id="' . $post_id . '"]'; + printf( + '', + esc_attr( $shortcode ), + esc_attr__( 'Klicken zum Markieren', 'open-data-wizard' ) + ); + break; + } + } + + /** + * Define sortable columns. + * + * @param array $columns Existing sortable columns. + * @return array Extended sortable columns. + */ + public static function sortable_columns( array $columns ): array { + $columns['odw_modified'] = 'modified'; + $columns['odw_theme'] = 'odw_theme'; + $columns['odw_quality'] = 'odw_quality'; + return $columns; + } + + /** + * Enable meta-based ordering for the Thema column. + * + * @param WP_Query $query Current query object. + */ + 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' ); + } + + if ( 'odw_quality' === $query->get( 'orderby' ) ) { + $query->set( 'meta_key', '_odw_quality_score' ); + $query->set( 'orderby', 'meta_value_num' ); + } + } + + /** + * Status filter dropdown above list table. + */ + public static function status_filter_dropdown(): void { + global $typenow; + + if ( ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { + return; + } // 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' ), - 'publish' => __( 'Veröffentlicht', 'open-data-wizard' ), - 'draft' => __( 'Entwurf', 'open-data-wizard' ), - ]; - - echo ''; - } - - /** - * Apply status filter to query. - */ - public static function apply_status_filter( WP_Query $query ): void { - global $pagenow, $typenow; - - if ( ! is_admin() || 'edit.php' !== $pagenow || ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { - return; - } - - if ( ! $query->is_main_query() ) { - return; - } + $selected = isset( $_GET['odw_status_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['odw_status_filter'] ) ) : ''; + + $options = array( + '' => __( 'Alle Status', 'open-data-wizard' ), + 'publish' => __( 'Veröffentlicht', 'open-data-wizard' ), + 'draft' => __( 'Entwurf', 'open-data-wizard' ), + ); + + echo ''; + } + + /** + * Apply status filter to query. + * + * @param WP_Query $query Current query object. + */ + public static function apply_status_filter( WP_Query $query ): void { + global $pagenow, $typenow; + + if ( ! is_admin() || 'edit.php' !== $pagenow || ! isset( $typenow ) || 'odw_dataset' !== $typenow ) { + return; + } + + if ( ! $query->is_main_query() ) { + return; + } // 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 { - $query->set( 'post_status', [ 'publish', 'draft' ] ); - } - } - - /** - * Enqueue admin assets (only on odw_dataset screens). - */ - public static function enqueue_assets( string $hook ): void { - $screen = get_current_screen(); - - if ( ! $screen || 'odw_dataset' !== $screen->post_type ) { - return; - } - - wp_enqueue_style( - 'odw-admin', - ODW_PLUGIN_URL . 'assets/css/admin.css', - [], - ODW_VERSION - ); - - if ( in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) { - wp_enqueue_script( - 'odw-wizard-tabs', - ODW_PLUGIN_URL . 'assets/js/wizard-tabs.js', - [], - ODW_VERSION, - true - ); - - wp_enqueue_media(); - - wp_enqueue_script( - 'odw-file-upload', - ODW_PLUGIN_URL . 'assets/js/odw-file-upload.js', - [ 'jquery' ], - ODW_VERSION, - true - ); - - global $post; - $file_id = $post ? (int) get_post_meta( $post->ID, '_odw_file_id', true ) : 0; - $file_name = ''; - if ( $file_id > 0 ) { - $attachment = get_post( $file_id ); - $file_name = $attachment instanceof \WP_Post ? $attachment->post_title : ''; - } - - wp_localize_script( 'odw-file-upload', 'odwFileUpload', [ - 'currentId' => $file_id, - 'currentName' => $file_name, - 'labels' => [ - 'frameTitle' => __( 'Datei auswählen oder hochladen', 'open-data-wizard' ), - 'frameButton' => __( 'Auswählen', 'open-data-wizard' ), - 'noFile' => __( 'Keine Datei ausgewählt', 'open-data-wizard' ), - ], - ] ); - } - } - - // ------------------------------------------------------------------------- - // Download-Datei — Native Media Library Meta Box - // ------------------------------------------------------------------------- - - /** - * Register the file-upload meta box on the dataset edit screen. - */ - public static function register_file_meta_box(): void { - add_meta_box( - 'odw-file-upload', - __( 'Download-Datei (Mediathek)', 'open-data-wizard' ), - [ self::class, 'render_file_meta_box' ], - 'odw_dataset', - 'side', - 'default' - ); - } - - /** - * Render the file-upload meta box. - */ - public static function render_file_meta_box( \WP_Post $post ): void { - $file_id = (int) get_post_meta( $post->ID, '_odw_file_id', true ); - $has_file = $file_id > 0; - - $file_name = ''; - if ( $has_file ) { - $attachment = get_post( $file_id ); - $file_name = $attachment instanceof \WP_Post ? $attachment->post_title : ''; - } - - wp_nonce_field( 'odw_save_file_attachment', 'odw_file_upload_nonce' ); - ?> -
- - - -
- - - - -
- -
- - -
- -

- -

- -
- 0 ) { - $file_path = get_attached_file( $file_id ); - if ( $file_path && file_exists( $file_path ) ) { - update_post_meta( $post_id, '_odw_file_size', (int) filesize( $file_path ) ); - update_post_meta( $post_id, '_odw_file_format', strtoupper( (string) pathinfo( $file_path, PATHINFO_EXTENSION ) ) ); - } - } else { - delete_post_meta( $post_id, '_odw_file_size' ); - delete_post_meta( $post_id, '_odw_file_format' ); - } - } - - /** - * Register Help Tabs on the odw_dataset edit screen. - */ - 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(); - ?> -

-
    -
  • dct:title
  • -
  • dct:description
  • -
  • dct:publisher
  • -
  • dct:license
  • -
-

-

-

-

-

-

- -

-

-

-
    -
  • ?page=1&per_page=20
  • -
  • ?theme=Bildung
  • -
  • ?license=cc-by
  • -
-

-

- set( 'post_status', $filter ); + } else { + $query->set( 'post_status', array( 'publish', 'draft' ) ); + } + } + + /** + * Enqueue admin assets (only on odw_dataset screens). + * + * @param string $hook Current admin page hook. + */ + public static function enqueue_assets( string $hook ): void { + $screen = get_current_screen(); + + if ( ! $screen || 'odw_dataset' !== $screen->post_type ) { + return; + } + + wp_enqueue_style( + 'odw-admin', + ODW_PLUGIN_URL . 'assets/css/admin.css', + array(), + ODW_VERSION + ); + + if ( in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) { + wp_enqueue_script( + 'odw-wizard-tabs', + ODW_PLUGIN_URL . 'assets/js/wizard-tabs.js', + array(), + ODW_VERSION, + true + ); + + wp_enqueue_media(); + + wp_enqueue_script( + 'odw-file-upload', + ODW_PLUGIN_URL . 'assets/js/odw-file-upload.js', + array( 'jquery' ), + ODW_VERSION, + true + ); + + global $post; + $file_id = $post ? (int) get_post_meta( $post->ID, '_odw_file_id', true ) : 0; + $file_name = ''; + if ( $file_id > 0 ) { + $attachment = get_post( $file_id ); + $file_name = $attachment instanceof \WP_Post ? $attachment->post_title : ''; + } + + wp_localize_script( + 'odw-file-upload', + 'odwFileUpload', + array( + 'currentId' => $file_id, + 'currentName' => $file_name, + 'labels' => array( + 'frameTitle' => __( 'Datei auswählen oder hochladen', 'open-data-wizard' ), + 'frameButton' => __( 'Auswählen', 'open-data-wizard' ), + 'noFile' => __( 'Keine Datei ausgewählt', 'open-data-wizard' ), + ), + ) + ); + } + } + + // ------------------------------------------------------------------------- + // Download-Datei — Native Media Library Meta Box + // ------------------------------------------------------------------------- + + /** + * Register the file-upload meta box on the dataset edit screen. + */ + public static function register_file_meta_box(): void { + add_meta_box( + 'odw-file-upload', + __( 'Download-Datei (Mediathek)', 'open-data-wizard' ), + array( self::class, 'render_file_meta_box' ), + 'odw_dataset', + 'side', + 'default' + ); + } + + /** + * Render the file-upload meta box. + * + * @param \WP_Post $post Current post object. + */ + public static function render_file_meta_box( \WP_Post $post ): void { + $file_id = (int) get_post_meta( $post->ID, '_odw_file_id', true ); + $has_file = $file_id > 0; + + $file_name = ''; + if ( $has_file ) { + $attachment = get_post( $file_id ); + $file_name = $attachment instanceof \WP_Post ? $attachment->post_title : ''; + } + + wp_nonce_field( 'odw_save_file_attachment', 'odw_file_upload_nonce' ); + ?> +
+ + + +
+ + + + +
+ +
+ + +
+ +

+ +

+ +
+ 0 ) { + $file_path = get_attached_file( $file_id ); + if ( $file_path && file_exists( $file_path ) ) { + update_post_meta( $post_id, '_odw_file_size', (int) filesize( $file_path ) ); + update_post_meta( $post_id, '_odw_file_format', strtoupper( (string) pathinfo( $file_path, PATHINFO_EXTENSION ) ) ); + } + } else { + delete_post_meta( $post_id, '_odw_file_size' ); + delete_post_meta( $post_id, '_odw_file_format' ); + } + } + + /** + * Register Help Tabs on the odw_dataset edit screen. + */ + public static function register_help_tabs(): void { + $screen = get_current_screen(); + + if ( ! $screen || 'odw_dataset' !== $screen->post_type ) { + return; + } + + $screen->add_help_tab( + array( + 'id' => 'odw-help-fields', + 'title' => __( 'Felder', 'open-data-wizard' ), + 'content' => self::help_content_fields(), + ) + ); + + $screen->add_help_tab( + array( + '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

' + ); + } + + /** + * Returns HTML for the Fields help tab. + * + * @return string HTML content. + */ + private static function help_content_fields(): string { + ob_start(); + ?> +

+
    +
  • dct:title
  • +
  • dct:description
  • +
  • dct:publisher
  • +
  • dct:license
  • +
+

+

+

+

+

+

+ +

+

+

+
    +
  • ?page=1&per_page=20
  • +
  • ?theme=Bildung
  • +
  • ?license=cc-by
  • +
+

+

+ where( 'post_type', '=', 'odw_dataset' ) - ->set_priority( 'high' ) - ->add_tab( - __( '1 — Pflichtangaben', 'open-data-wizard' ), - [ - Field::make( 'html', 'odw_description_tab1_hint' ) - ->set_html( '

' . esc_html__( 'Pflichtfelder gemäß DCAT-AP 3.0. Ohne diese Angaben kann der Datensatz nicht veröffentlicht werden.', 'open-data-wizard' ) . '

' ), - - Field::make( 'text', 'odw_publisher', __( 'Herausgebende Organisation (dct:publisher)', 'open-data-wizard' ) ) - ->set_required( true ) - ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_publisher' ) : '' ) - ->set_attribute( 'placeholder', __( 'z.B. Musterorganisation e.V.', 'open-data-wizard' ) ), - - Field::make( 'textarea', 'odw_description', __( 'Beschreibung (dct:description)', 'open-data-wizard' ) ) - ->set_required( true ) - ->set_rows( 5 ) - ->set_attribute( 'placeholder', __( 'Kurze Beschreibung des Datensatzes…', 'open-data-wizard' ) ), - - Field::make( 'select', 'odw_license', __( 'Lizenz (dct:license)', 'open-data-wizard' ) ) - ->set_required( true ) - ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_license' ) : '' ) - ->add_options( self::get_license_options() ), - ] - ) - ->add_tab( - __( '2 — Optionale Angaben', 'open-data-wizard' ), - [ - Field::make( 'select', 'odw_language', __( 'Sprache (dct:language)', 'open-data-wizard' ) ) - ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_language' ) : '' ) - ->add_options( [ - '' => __( '— Bitte wählen —', 'open-data-wizard' ), - 'de' => __( 'Deutsch (DE)', 'open-data-wizard' ), - 'en' => __( 'Englisch (EN)', 'open-data-wizard' ), - ] ), - - Field::make( 'textarea', 'odw_keywords', __( 'Schlagworte (dcat:keyword)', 'open-data-wizard' ) ) - ->set_rows( 3 ) - ->set_attribute( 'placeholder', __( 'z.B. Umwelt', 'open-data-wizard' ) ) - ->set_help_text( __( 'Geben Sie jedes Schlagwort in einer eigenen Zeile ein.', 'open-data-wizard' ) ), - - Field::make( 'select', 'odw_theme', __( 'Thema (dcat:theme)', 'open-data-wizard' ) ) - ->add_options( self::get_theme_options() ), - - Field::make( 'date', 'odw_issued', __( 'Veröffentlichungsdatum (dct:issued)', 'open-data-wizard' ) ) - ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( [ 'dateFormat' => 'Y-m-d' ] ), - - Field::make( 'date', 'odw_modified', __( 'Änderungsdatum (dct:modified)', 'open-data-wizard' ) ) - ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( [ 'dateFormat' => 'Y-m-d' ] ) - ->set_help_text( __( 'Wird automatisch bei jeder Speicherung aktualisiert.', 'open-data-wizard' ) ), - ] - ) - ->add_tab( - __( '3 — Distribution', 'open-data-wizard' ), - [ - Field::make( 'complex', 'odw_distributions', __( 'Distributionen (dcat:distribution)', 'open-data-wizard' ) ) - ->set_min( 1 ) - ->set_collapsed( false ) - ->add_fields( [ - Field::make( 'text', 'access_url', __( 'Zugriffs-URL (dcat:accessURL)', 'open-data-wizard' ) ) - ->set_required( true ) - ->set_attribute( 'placeholder', 'https://beispiel.de/daten/datei.csv' ) - ->set_attribute( 'type', 'url' ), - - Field::make( 'select', 'format', __( 'Format (dct:format)', 'open-data-wizard' ) ) - ->add_options( self::get_format_options() ), - - Field::make( 'text', 'byte_size', __( 'Dateigröße in Bytes (dcat:byteSize)', 'open-data-wizard' ) ) - ->set_attribute( 'placeholder', __( 'optional, z.B. 204800', 'open-data-wizard' ) ) - ->set_attribute( 'type', 'number' ) - ->set_attribute( 'min', '0' ), - ] ), - - ] - ) - ->add_tab( - __( '4 — Erweiterte Angaben', 'open-data-wizard' ), - [ - // --- Projektseite & Aktualität --- - Field::make( 'html', 'odw_ext_hint_landing' ) - ->set_html( '

' . esc_html__( 'Projektseite & Aktualität', 'open-data-wizard' ) . '

' ), - - Field::make( 'text', 'odw_landing_page', __( 'Projektseite (dcat:landingPage)', 'open-data-wizard' ) ) - ->set_attribute( 'type', 'url' ) - ->set_attribute( 'placeholder', 'https://beispiel.de/projekt' ) - ->set_help_text( __( 'URL der Projektwebsite oder des Datenportals, auf der weitere Informationen zum Datensatz zu finden sind.', 'open-data-wizard' ) ), - - Field::make( 'select', 'odw_accrual_periodicity', __( 'Aktualisierungsfrequenz (dct:accrualPeriodicity)', 'open-data-wizard' ) ) - ->add_options( self::get_periodicity_options() ), - - // --- Abdeckung --- - Field::make( 'html', 'odw_ext_hint_coverage' ) - ->set_html( '

' . esc_html__( 'Abdeckung', 'open-data-wizard' ) . '

' ), - - Field::make( 'text', 'odw_spatial', __( 'Geographische Abdeckung (dct:spatial)', 'open-data-wizard' ) ) - ->set_attribute( 'placeholder', __( 'z.B. Deutschland, Berlin oder GeoNames-URI', 'open-data-wizard' ) ) - ->set_help_text( __( 'Freitext oder URI (z.B. https://sws.geonames.org/2950159/).', 'open-data-wizard' ) ), - - Field::make( 'date', 'odw_temporal_start', __( 'Zeitlicher Bezug — Start (dct:temporal)', 'open-data-wizard' ) ) - ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( [ 'dateFormat' => 'Y-m-d' ] ), - - Field::make( 'date', 'odw_temporal_end', __( 'Zeitlicher Bezug — Ende (dct:temporal)', 'open-data-wizard' ) ) - ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( [ 'dateFormat' => 'Y-m-d' ] ), - - // --- Kontaktpunkt --- - Field::make( 'html', 'odw_ext_hint_contact' ) - ->set_html( '

' . esc_html__( 'Kontaktpunkt (dcat:contactPoint)', 'open-data-wizard' ) . '

' ), - - Field::make( 'text', 'odw_contact_name', __( 'Name / Organisation', 'open-data-wizard' ) ) - ->set_attribute( 'placeholder', __( 'z.B. Open Data Team', 'open-data-wizard' ) ), - - Field::make( 'text', 'odw_contact_email', __( 'E-Mail-Adresse', 'open-data-wizard' ) ) - ->set_attribute( 'type', 'email' ) - ->set_attribute( 'placeholder', 'opendata@beispiel.de' ), - - Field::make( 'text', 'odw_contact_url', __( 'Website', 'open-data-wizard' ) ) - ->set_attribute( 'type', 'url' ) - ->set_attribute( 'placeholder', 'https://beispiel.de/kontakt' ), - ] - ) - ->add_tab( - __( '5 — Vorschau', 'open-data-wizard' ), - [ - Field::make( 'html', 'odw_preview_html' ) - ->set_html( self::get_preview_html() ), - ] - ); - } - - private static function register_optional_fields(): void { - // Fields are bundled in the tabbed container above. - // This method is kept for structural clarity. - } - - private static function register_distributions(): void { - // Distributions are part of the tabbed container above. - } - - /** - * Auto-update odw_modified on every save. - */ - public static function set_modified_date( int $post_id, \WP_Post $post ): void { - if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { - return; - } - - if ( 'odw_dataset' !== $post->post_type ) { - return; - } - - // Update without triggering infinite loop - remove_action( 'save_post_odw_dataset', [ self::class, 'set_modified_date' ], 10 ); - - 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 ); - } - - // ------------------------------------------------------------------------- - // 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 - // ------------------------------------------------------------------------- - - public static function get_license_options(): array { - $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 { - $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' ), - 'Wirtschaft' => __( 'Wirtschaft', '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_periodicity_options(): array { - $base = 'http://publications.europa.eu/resource/authority/frequency/'; - return [ - '' => __( '— Bitte wählen —', 'open-data-wizard' ), - $base . 'DAILY' => __( 'Täglich', 'open-data-wizard' ), - $base . 'WEEKLY' => __( 'Wöchentlich', 'open-data-wizard' ), - $base . 'MONTHLY' => __( 'Monatlich', 'open-data-wizard' ), - $base . 'QUARTERLY' => __( 'Vierteljährlich', 'open-data-wizard' ), - $base . 'ANNUAL' => __( 'Jährlich', 'open-data-wizard' ), - $base . 'BIENNIAL' => __( 'Zweijährlich', 'open-data-wizard' ), - $base . 'IRREG' => __( 'Unregelmäßig', 'open-data-wizard' ), - $base . 'UNKNOWN' => __( 'Unbekannt', 'open-data-wizard' ), - ]; - } - - public static function get_format_options(): array { - return [ - '' => __( '— Bitte wählen —', 'open-data-wizard' ), - 'CSV' => 'CSV', - 'JSON' => 'JSON', - 'XLSX' => 'XLSX', - 'PDF' => 'PDF', - 'GeoJSON' => 'GeoJSON', - 'XML' => 'XML', - 'Sonstiges' => __( 'Sonstiges', 'open-data-wizard' ), - ]; - } - - /** - * Format MIME-type mapping for JSON-LD output. - */ - public static function get_format_mime( string $format ): string { - $map = [ - 'CSV' => 'text/csv', - 'JSON' => 'application/json', - 'XLSX' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'PDF' => 'application/pdf', - 'GeoJSON' => 'application/geo+json', - 'XML' => 'application/xml', - ]; - - return $map[ $format ] ?? $format; - } - - private static function get_preview_html(): string { - ob_start(); - ?> -
-

- - -

-
- '; - echo esc_html( wp_json_encode( $json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); - echo ''; - } else { - echo '

' . esc_html__( 'Noch keine Daten vorhanden. Bitte erst Pflichtfelder befüllen und speichern.', 'open-data-wizard' ) . '

'; - } - } - ?> -
- -

- - - -

- -
- where( 'post_type', '=', 'odw_dataset' ) + ->set_priority( 'high' ) + ->add_tab( + __( '1 — Pflichtangaben', 'open-data-wizard' ), + array( + Field::make( 'html', 'odw_description_tab1_hint' ) + ->set_html( '

' . esc_html__( 'Pflichtfelder gemäß DCAT-AP 3.0. Ohne diese Angaben kann der Datensatz nicht veröffentlicht werden.', 'open-data-wizard' ) . '

' ), + + Field::make( 'text', 'odw_publisher', __( 'Herausgebende Organisation (dct:publisher)', 'open-data-wizard' ) ) + ->set_required( true ) + ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_publisher' ) : '' ) + ->set_attribute( 'placeholder', __( 'z.B. Musterorganisation e.V.', 'open-data-wizard' ) ), + + Field::make( 'textarea', 'odw_description', __( 'Beschreibung (dct:description)', 'open-data-wizard' ) ) + ->set_required( true ) + ->set_rows( 5 ) + ->set_attribute( 'placeholder', __( 'Kurze Beschreibung des Datensatzes…', 'open-data-wizard' ) ), + + Field::make( 'select', 'odw_license', __( 'Lizenz (dct:license)', 'open-data-wizard' ) ) + ->set_required( true ) + ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_license' ) : '' ) + ->add_options( self::get_license_options() ), + ) + ) + ->add_tab( + __( '2 — Optionale Angaben', 'open-data-wizard' ), + array( + Field::make( 'select', 'odw_language', __( 'Sprache (dct:language)', 'open-data-wizard' ) ) + ->set_default_value( class_exists( 'ODW_Settings' ) ? (string) ODW_Settings::get( 'default_language' ) : '' ) + ->add_options( + array( + '' => __( '— Bitte wählen —', 'open-data-wizard' ), + 'de' => __( 'Deutsch (DE)', 'open-data-wizard' ), + 'en' => __( 'Englisch (EN)', 'open-data-wizard' ), + ) + ), + + Field::make( 'textarea', 'odw_keywords', __( 'Schlagworte (dcat:keyword)', 'open-data-wizard' ) ) + ->set_rows( 3 ) + ->set_attribute( 'placeholder', __( 'z.B. Umwelt', 'open-data-wizard' ) ) + ->set_help_text( __( 'Geben Sie jedes Schlagwort in einer eigenen Zeile ein.', 'open-data-wizard' ) ), + + Field::make( 'select', 'odw_theme', __( 'Thema (dcat:theme)', 'open-data-wizard' ) ) + ->add_options( self::get_theme_options() ), + + Field::make( 'date', 'odw_issued', __( 'Veröffentlichungsdatum (dct:issued)', 'open-data-wizard' ) ) + ->set_storage_format( 'Y-m-d' ) + ->set_picker_options( array( 'dateFormat' => 'Y-m-d' ) ), + + Field::make( 'date', 'odw_modified', __( 'Änderungsdatum (dct:modified)', 'open-data-wizard' ) ) + ->set_storage_format( 'Y-m-d' ) + ->set_picker_options( array( 'dateFormat' => 'Y-m-d' ) ) + ->set_help_text( __( 'Wird automatisch bei jeder Speicherung aktualisiert.', 'open-data-wizard' ) ), + ) + ) + ->add_tab( + __( '3 — Distribution', 'open-data-wizard' ), + array( + Field::make( 'complex', 'odw_distributions', __( 'Distributionen (dcat:distribution)', 'open-data-wizard' ) ) + ->set_min( 1 ) + ->set_collapsed( false ) + ->add_fields( + array( + Field::make( 'text', 'access_url', __( 'Zugriffs-URL (dcat:accessURL)', 'open-data-wizard' ) ) + ->set_required( true ) + ->set_attribute( 'placeholder', 'https://beispiel.de/daten/datei.csv' ) + ->set_attribute( 'type', 'url' ), + + Field::make( 'select', 'format', __( 'Format (dct:format)', 'open-data-wizard' ) ) + ->add_options( self::get_format_options() ), + + Field::make( 'text', 'byte_size', __( 'Dateigröße in Bytes (dcat:byteSize)', 'open-data-wizard' ) ) + ->set_attribute( 'placeholder', __( 'optional, z.B. 204800', 'open-data-wizard' ) ) + ->set_attribute( 'type', 'number' ) + ->set_attribute( 'min', '0' ), + ) + ), + + ) + ) + ->add_tab( + __( '4 — Erweiterte Angaben', 'open-data-wizard' ), + array( + // --- Projektseite & Aktualität --- + Field::make( 'html', 'odw_ext_hint_landing' ) + ->set_html( '

' . esc_html__( 'Projektseite & Aktualität', 'open-data-wizard' ) . '

' ), + + Field::make( 'text', 'odw_landing_page', __( 'Projektseite (dcat:landingPage)', 'open-data-wizard' ) ) + ->set_attribute( 'type', 'url' ) + ->set_attribute( 'placeholder', 'https://beispiel.de/projekt' ) + ->set_help_text( __( 'URL der Projektwebsite oder des Datenportals, auf der weitere Informationen zum Datensatz zu finden sind.', 'open-data-wizard' ) ), + + Field::make( 'select', 'odw_accrual_periodicity', __( 'Aktualisierungsfrequenz (dct:accrualPeriodicity)', 'open-data-wizard' ) ) + ->add_options( self::get_periodicity_options() ), + + // --- Abdeckung --- + Field::make( 'html', 'odw_ext_hint_coverage' ) + ->set_html( '

' . esc_html__( 'Abdeckung', 'open-data-wizard' ) . '

' ), + + Field::make( 'text', 'odw_spatial', __( 'Geographische Abdeckung (dct:spatial)', 'open-data-wizard' ) ) + ->set_attribute( 'placeholder', __( 'z.B. Deutschland, Berlin oder GeoNames-URI', 'open-data-wizard' ) ) + ->set_help_text( __( 'Freitext oder URI (z.B. https://sws.geonames.org/2950159/).', 'open-data-wizard' ) ), + + Field::make( 'date', 'odw_temporal_start', __( 'Zeitlicher Bezug — Start (dct:temporal)', 'open-data-wizard' ) ) + ->set_storage_format( 'Y-m-d' ) + ->set_picker_options( array( 'dateFormat' => 'Y-m-d' ) ), + + Field::make( 'date', 'odw_temporal_end', __( 'Zeitlicher Bezug — Ende (dct:temporal)', 'open-data-wizard' ) ) + ->set_storage_format( 'Y-m-d' ) + ->set_picker_options( array( 'dateFormat' => 'Y-m-d' ) ), + + // --- Kontaktpunkt --- + Field::make( 'html', 'odw_ext_hint_contact' ) + ->set_html( '

' . esc_html__( 'Kontaktpunkt (dcat:contactPoint)', 'open-data-wizard' ) . '

' ), + + Field::make( 'text', 'odw_contact_name', __( 'Name / Organisation', 'open-data-wizard' ) ) + ->set_attribute( 'placeholder', __( 'z.B. Open Data Team', 'open-data-wizard' ) ), + + Field::make( 'text', 'odw_contact_email', __( 'E-Mail-Adresse', 'open-data-wizard' ) ) + ->set_attribute( 'type', 'email' ) + ->set_attribute( 'placeholder', 'opendata@beispiel.de' ), + + Field::make( 'text', 'odw_contact_url', __( 'Website', 'open-data-wizard' ) ) + ->set_attribute( 'type', 'url' ) + ->set_attribute( 'placeholder', 'https://beispiel.de/kontakt' ), + ) + ) + ->add_tab( + __( '5 — Vorschau', 'open-data-wizard' ), + array( + Field::make( 'html', 'odw_preview_html' ) + ->set_html( self::get_preview_html() ), + ) + ); + } + + /** + * Optional fields are bundled in the tabbed container in register_required_fields(). + */ + private static function register_optional_fields(): void { + // Fields are bundled in the tabbed container above. + } + + /** + * Distributions are part of the tabbed container in register_required_fields(). + */ + private static function register_distributions(): void { + // Distributions are part of the tabbed container above. + } + + /** + * Auto-update odw_modified on every save. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + */ + public static function set_modified_date( int $post_id, \WP_Post $post ): void { + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + if ( 'odw_dataset' !== $post->post_type ) { + return; + } + + // Update without triggering an infinite loop. + remove_action( 'save_post_odw_dataset', array( self::class, 'set_modified_date' ), 10 ); + + update_post_meta( $post_id, '_odw_modified', current_time( 'Y-m-d' ) ); + + add_action( 'save_post_odw_dataset', array( 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 array( + array( + 'meta_key' => '_odw_description', + 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ), + ), + array( + 'meta_key' => '_odw_publisher', + 'label' => __( 'Herausgebende Organisation (dct:publisher)', 'open-data-wizard' ), + ), + array( + 'meta_key' => '_odw_license', + 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ), + ), + ); + } + + // ------------------------------------------------------------------------- + // Controlled vocabulary options + // ------------------------------------------------------------------------- + + /** + * Lizenzen als URI → Label Map für Select-Felder und den `odw_license_options`-Filter. + * + * @return array Erweiterbar via `add_filter('odw_license_options', ...)`. + */ + public static function get_license_options(): array { + $options = array( + '' => __( '— 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. + * + * @param string $uri License URI. + * @return string Human-readable label, or the URI itself if not found. + */ + public static function get_license_label( string $uri ): string { + $options = self::get_license_options(); + return $options[ $uri ] ?? $uri; + } + + /** + * Themen-Vokabular als Label → Label Map für das DCAT-AP `dcat:theme`-Feld. + * + * @return array Erweiterbar via `add_filter('odw_theme_options', ...)`. + */ + public static function get_theme_options(): array { + $options = array( + '' => __( '— 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' ), + 'Wirtschaft' => __( 'Wirtschaft', '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 ); + } + + /** + * Aktualisierungsfrequenzen aus dem EU Publications Office Frequency Vocabulary. + * + * Basis-URI: http://publications.europa.eu/resource/authority/frequency/ + * Vollständige URI wird als Wert gespeichert und im JSON-LD als `@id` ausgegeben. + * + * @return array + */ + public static function get_periodicity_options(): array { + $base = 'http://publications.europa.eu/resource/authority/frequency/'; + return array( + '' => __( '— Bitte wählen —', 'open-data-wizard' ), + $base . 'DAILY' => __( 'Täglich', 'open-data-wizard' ), + $base . 'WEEKLY' => __( 'Wöchentlich', 'open-data-wizard' ), + $base . 'MONTHLY' => __( 'Monatlich', 'open-data-wizard' ), + $base . 'QUARTERLY' => __( 'Vierteljährlich', 'open-data-wizard' ), + $base . 'ANNUAL' => __( 'Jährlich', 'open-data-wizard' ), + $base . 'BIENNIAL' => __( 'Zweijährlich', 'open-data-wizard' ), + $base . 'IRREG' => __( 'Unregelmäßig', 'open-data-wizard' ), + $base . 'UNKNOWN' => __( 'Unbekannt', 'open-data-wizard' ), + ); + } + + /** + * Dateiformate als Kurzbezeichnung → Kurzbezeichnung Map für das Distribution-Feld. + * Die Kurzbezeichnung wird via `get_format_mime()` in den MIME-Typ für JSON-LD übersetzt. + * + * @return array + */ + public static function get_format_options(): array { + return array( + '' => __( '— Bitte wählen —', 'open-data-wizard' ), + 'CSV' => 'CSV', + 'JSON' => 'JSON', + 'XLSX' => 'XLSX', + 'PDF' => 'PDF', + 'GeoJSON' => 'GeoJSON', + 'XML' => 'XML', + 'Sonstiges' => __( 'Sonstiges', 'open-data-wizard' ), + ); + } + + /** + * Format MIME-type mapping for JSON-LD output. + * + * @param string $format Short format label (e.g. "CSV"). + * @return string MIME type, or the original format string if unknown. + */ + public static function get_format_mime( string $format ): string { + $map = array( + 'CSV' => 'text/csv', + 'JSON' => 'application/json', + 'XLSX' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'PDF' => 'application/pdf', + 'GeoJSON' => 'application/geo+json', + 'XML' => 'application/xml', + ); + + return $map[ $format ] ?? $format; + } + + /** + * Generates the HTML for the JSON-LD preview tab. + * + * @return string HTML output. + */ + private static function get_preview_html(): string { + ob_start(); + ?> +
+

+ + +

+
+ '; + echo esc_html( wp_json_encode( $json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); + echo ''; + } else { + echo '

' . esc_html__( 'Noch keine Daten vorhanden. Bitte erst Pflichtfelder befüllen und speichern.', 'open-data-wizard' ) . '

'; + } + } + ?> +
+ +

+ + + +

+ +
+ |null JSON-LD array, or null when the post is not a valid dataset. */ function odw_build_dataset_jsonld( int $post_id ): ?array { - $post = get_post( $post_id ); - - if ( ! $post || 'odw_dataset' !== $post->post_type ) { - return null; - } - - $title = $post->post_title; - $description = carbon_get_post_meta( $post_id, 'odw_description' ); - $publisher = carbon_get_post_meta( $post_id, 'odw_publisher' ); - $license = carbon_get_post_meta( $post_id, 'odw_license' ); - $language = carbon_get_post_meta( $post_id, 'odw_language' ); - $keywords = carbon_get_post_meta( $post_id, 'odw_keywords' ); - $theme = carbon_get_post_meta( $post_id, 'odw_theme' ); - $issued = carbon_get_post_meta( $post_id, 'odw_issued' ); - $modified = get_post_meta( $post_id, '_odw_modified', true ); - $distributions = carbon_get_post_meta( $post_id, 'odw_distributions' ); - - // Erweiterte DCAT-AP Felder (Tab 4) - $landing_page = (string) carbon_get_post_meta( $post_id, 'odw_landing_page' ); - $accrual_periodicity = (string) carbon_get_post_meta( $post_id, 'odw_accrual_periodicity' ); - $spatial = (string) carbon_get_post_meta( $post_id, 'odw_spatial' ); - $temporal_start = (string) carbon_get_post_meta( $post_id, 'odw_temporal_start' ); - $temporal_end = (string) carbon_get_post_meta( $post_id, 'odw_temporal_end' ); - $contact_name = (string) carbon_get_post_meta( $post_id, 'odw_contact_name' ); - $contact_email = (string) carbon_get_post_meta( $post_id, 'odw_contact_email' ); - $contact_url = (string) carbon_get_post_meta( $post_id, 'odw_contact_url' ); - - $dataset = [ - '@type' => 'dcat:Dataset', - '@id' => rest_url( 'datenatlas/v1/datasets/' . $post_id ), - 'dct:title' => $title, - 'dct:description' => $description, - 'dct:publisher' => [ - '@type' => 'foaf:Organization', - 'foaf:name' => $publisher, - ], - 'dct:license' => $license, - ]; - - if ( ! empty( $language ) ) { - $dataset['dct:language'] = $language; - } - - if ( ! empty( $keywords ) && is_string( $keywords ) ) { - $keyword_list = array_values( array_filter( array_map( 'trim', explode( "\n", $keywords ) ) ) ); - if ( ! empty( $keyword_list ) ) { - $dataset['dcat:keyword'] = $keyword_list; - } - } - - if ( ! empty( $theme ) ) { - $dataset['dcat:theme'] = $theme; - } - - if ( ! empty( $issued ) ) { - $dataset['dct:issued'] = [ - '@type' => 'xsd:date', - '@value' => $issued, - ]; - } - - if ( ! empty( $modified ) ) { - $dataset['dct:modified'] = [ - '@type' => 'xsd:date', - '@value' => $modified, - ]; - } - - if ( ! empty( $distributions ) && is_array( $distributions ) ) { - $dist_list = []; - foreach ( $distributions as $dist ) { - if ( empty( $dist['access_url'] ) ) { - continue; - } - - $dist_item = [ - '@type' => 'dcat:Distribution', - 'dcat:accessURL' => $dist['access_url'], - ]; - - if ( ! empty( $dist['format'] ) ) { - $dist_item['dct:format'] = ODW_Fields::get_format_mime( $dist['format'] ); - } - - 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']; - } - - $dist_list[] = $dist_item; - } - - if ( ! empty( $dist_list ) ) { - $dataset['dcat:distribution'] = $dist_list; - } - } - - // --- Erweiterte Felder --- - - if ( ! empty( $landing_page ) ) { - $dataset['dcat:landingPage'] = [ '@id' => $landing_page ]; - } - - if ( ! empty( $accrual_periodicity ) ) { - $dataset['dct:accrualPeriodicity'] = [ '@id' => $accrual_periodicity ]; - } - - if ( ! empty( $spatial ) ) { - $dataset['dct:spatial'] = [ - '@type' => 'dct:Location', - 'skos:prefLabel' => $spatial, - ]; - } - - if ( ! empty( $temporal_start ) || ! empty( $temporal_end ) ) { - $period = [ '@type' => 'dct:PeriodOfTime' ]; - if ( ! empty( $temporal_start ) ) { - $period['dcat:startDate'] = [ '@type' => 'xsd:date', '@value' => $temporal_start ]; - } - if ( ! empty( $temporal_end ) ) { - $period['dcat:endDate'] = [ '@type' => 'xsd:date', '@value' => $temporal_end ]; - } - $dataset['dct:temporal'] = $period; - } - - if ( ! empty( $contact_name ) || ! empty( $contact_email ) ) { - $contact = [ '@type' => 'vcard:Organization' ]; - if ( ! empty( $contact_name ) ) { - $contact['vcard:fn'] = $contact_name; - } - if ( ! empty( $contact_email ) ) { - $contact['vcard:hasEmail'] = 'mailto:' . $contact_email; - } - if ( ! empty( $contact_url ) ) { - $contact['vcard:hasURL'] = [ '@id' => $contact_url ]; - } - $dataset['dcat:contactPoint'] = $contact; - } - - /** - * 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 ); + $post = get_post( $post_id ); + + if ( ! $post || 'odw_dataset' !== $post->post_type ) { + return null; + } + + $title = $post->post_title; + $description = carbon_get_post_meta( $post_id, 'odw_description' ); + $publisher = carbon_get_post_meta( $post_id, 'odw_publisher' ); + $license = carbon_get_post_meta( $post_id, 'odw_license' ); + $language = carbon_get_post_meta( $post_id, 'odw_language' ); + $keywords = carbon_get_post_meta( $post_id, 'odw_keywords' ); + $theme = carbon_get_post_meta( $post_id, 'odw_theme' ); + $issued = carbon_get_post_meta( $post_id, 'odw_issued' ); + $modified = get_post_meta( $post_id, '_odw_modified', true ); + $distributions = carbon_get_post_meta( $post_id, 'odw_distributions' ); + + // Extended DCAT-AP fields (Tab 4). + $landing_page = (string) carbon_get_post_meta( $post_id, 'odw_landing_page' ); + $accrual_periodicity = (string) carbon_get_post_meta( $post_id, 'odw_accrual_periodicity' ); + $spatial = (string) carbon_get_post_meta( $post_id, 'odw_spatial' ); + $temporal_start = (string) carbon_get_post_meta( $post_id, 'odw_temporal_start' ); + $temporal_end = (string) carbon_get_post_meta( $post_id, 'odw_temporal_end' ); + $contact_name = (string) carbon_get_post_meta( $post_id, 'odw_contact_name' ); + $contact_email = (string) carbon_get_post_meta( $post_id, 'odw_contact_email' ); + $contact_url = (string) carbon_get_post_meta( $post_id, 'odw_contact_url' ); + + $dataset = array( + '@type' => 'dcat:Dataset', + '@id' => rest_url( 'datenatlas/v1/datasets/' . $post_id ), + 'dct:title' => $title, + 'dct:description' => $description, + 'dct:publisher' => array( + '@type' => 'foaf:Organization', + 'foaf:name' => $publisher, + ), + 'dct:license' => $license, + ); + + if ( ! empty( $language ) ) { + $dataset['dct:language'] = $language; + } + + if ( ! empty( $keywords ) && is_string( $keywords ) ) { + $keyword_list = array_values( array_filter( array_map( 'trim', explode( "\n", $keywords ) ) ) ); + if ( ! empty( $keyword_list ) ) { + $dataset['dcat:keyword'] = $keyword_list; + } + } + + if ( ! empty( $theme ) ) { + $dataset['dcat:theme'] = $theme; + } + + if ( ! empty( $issued ) ) { + $dataset['dct:issued'] = array( + '@type' => 'xsd:date', + '@value' => $issued, + ); + } + + if ( ! empty( $modified ) ) { + $dataset['dct:modified'] = array( + '@type' => 'xsd:date', + '@value' => $modified, + ); + } + + if ( ! empty( $distributions ) && is_array( $distributions ) ) { + $dist_list = array(); + foreach ( $distributions as $dist ) { + // esc_url_raw() strips javascript:, data:, and other non-HTTP schemes. + $access_url = esc_url_raw( (string) ( $dist['access_url'] ?? '' ) ); + if ( empty( $access_url ) ) { + continue; + } + + $dist_item = array( + '@type' => 'dcat:Distribution', + 'dcat:accessURL' => $access_url, + ); + + if ( ! empty( $dist['format'] ) ) { + $dist_item['dct:format'] = ODW_Fields::get_format_mime( $dist['format'] ); + } + + 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']; + } + + $dist_list[] = $dist_item; + } + + if ( ! empty( $dist_list ) ) { + $dataset['dcat:distribution'] = $dist_list; + } + } + + // Extended DCAT-AP fields (Tab 4). + + if ( ! empty( $landing_page ) ) { + $dataset['dcat:landingPage'] = array( '@id' => $landing_page ); + } + + if ( ! empty( $accrual_periodicity ) ) { + $dataset['dct:accrualPeriodicity'] = array( '@id' => $accrual_periodicity ); + } + + if ( ! empty( $spatial ) ) { + $dataset['dct:spatial'] = array( + '@type' => 'dct:Location', + 'skos:prefLabel' => $spatial, + ); + } + + if ( ! empty( $temporal_start ) || ! empty( $temporal_end ) ) { + $period = array( '@type' => 'dct:PeriodOfTime' ); + if ( ! empty( $temporal_start ) ) { + $period['dcat:startDate'] = array( + '@type' => 'xsd:date', + '@value' => $temporal_start, + ); + } + if ( ! empty( $temporal_end ) ) { + $period['dcat:endDate'] = array( + '@type' => 'xsd:date', + '@value' => $temporal_end, + ); + } + $dataset['dct:temporal'] = $period; + } + + if ( ! empty( $contact_name ) || ! empty( $contact_email ) ) { + $contact = array( '@type' => 'vcard:Organization' ); + if ( ! empty( $contact_name ) ) { + $contact['vcard:fn'] = $contact_name; + } + if ( ! empty( $contact_email ) ) { + $contact['vcard:hasEmail'] = 'mailto:' . $contact_email; + } + if ( ! empty( $contact_url ) ) { + $contact['vcard:hasURL'] = array( '@id' => $contact_url ); + } + $dataset['dcat:contactPoint'] = $contact; + } + + /** + * 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-post-types.php b/includes/class-post-types.php index d6c1dda..7690d29 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -8,63 +8,103 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * Registers the odw_dataset custom post type. + * + * @package OpenDataWizard + */ class ODW_Post_Types { - public static function init(): void { - add_action( 'init', [ self::class, 'register' ] ); - } + /** + * Registers the init hook that triggers CPT registration. + */ + public static function init(): void { + add_action( 'init', array( self::class, 'register' ) ); + } + + /** + * Registriert den Custom Post Type `odw_dataset`. + * + * Der CPT ist nicht öffentlich (kein Frontend-Permalink), wird aber im + * Admin-Bereich angezeigt und über eigene REST-Endpoints ausgeliefert + * (`show_in_rest => false`, da wir `/datenatlas/v1/` verwenden). + * + * Alle schreibenden Operationen (erstellen, bearbeiten, löschen) erfordern + * die Capability `manage_open_data`, die Admins und Editoren bei Aktivierung + * zugewiesen wird. + */ + public static function register(): void { + $labels = array( + 'name' => _x( 'Datensätze', 'Post Type General Name', 'open-data-wizard' ), + 'singular_name' => _x( 'Datensatz', 'Post Type Singular Name', 'open-data-wizard' ), + 'menu_name' => __( 'Open Data Wizard', 'open-data-wizard' ), + 'name_admin_bar' => __( 'Datensatz', 'open-data-wizard' ), + 'add_new' => __( 'Neuen Datensatz anlegen', 'open-data-wizard' ), + 'add_new_item' => __( 'Neuen Datensatz anlegen', 'open-data-wizard' ), + 'new_item' => __( 'Neuer Datensatz', 'open-data-wizard' ), + 'edit_item' => __( 'Datensatz bearbeiten', 'open-data-wizard' ), + 'view_item' => __( 'Datensatz ansehen', 'open-data-wizard' ), + 'all_items' => __( 'Alle Datensätze', 'open-data-wizard' ), + 'search_items' => __( 'Datensätze suchen', 'open-data-wizard' ), + 'not_found' => __( 'Keine Datensätze gefunden.', 'open-data-wizard' ), + 'not_found_in_trash' => __( 'Keine Datensätze im Papierkorb.', 'open-data-wizard' ), + 'featured_image' => __( 'Vorschaubild', 'open-data-wizard' ), + 'set_featured_image' => __( 'Vorschaubild festlegen', 'open-data-wizard' ), + 'remove_featured_image' => __( 'Vorschaubild entfernen', 'open-data-wizard' ), + 'use_featured_image' => __( 'Als Vorschaubild verwenden', 'open-data-wizard' ), + 'archives' => __( 'Datensatz-Archiv', 'open-data-wizard' ), + 'insert_into_item' => __( 'In Datensatz einfügen', 'open-data-wizard' ), + 'uploaded_to_this_item' => __( 'Zu diesem Datensatz hochgeladen', 'open-data-wizard' ), + 'items_list' => __( 'Datensatzliste', 'open-data-wizard' ), + 'items_list_navigation' => __( 'Datensatzliste Navigation', 'open-data-wizard' ), + 'filter_items_list' => __( 'Datensatzliste filtern', 'open-data-wizard' ), + ); - public static function register(): void { - $labels = [ - 'name' => _x( 'Datensätze', 'Post Type General Name', 'open-data-wizard' ), - 'singular_name' => _x( 'Datensatz', 'Post Type Singular Name', 'open-data-wizard' ), - 'menu_name' => __( 'Open Data Wizard', 'open-data-wizard' ), - 'name_admin_bar' => __( 'Datensatz', 'open-data-wizard' ), - 'add_new' => __( 'Neuen Datensatz anlegen', 'open-data-wizard' ), - 'add_new_item' => __( 'Neuen Datensatz anlegen', 'open-data-wizard' ), - 'new_item' => __( 'Neuer Datensatz', 'open-data-wizard' ), - 'edit_item' => __( 'Datensatz bearbeiten', 'open-data-wizard' ), - 'view_item' => __( 'Datensatz ansehen', 'open-data-wizard' ), - 'all_items' => __( 'Alle Datensätze', 'open-data-wizard' ), - 'search_items' => __( 'Datensätze suchen', 'open-data-wizard' ), - 'not_found' => __( 'Keine Datensätze gefunden.', 'open-data-wizard' ), - 'not_found_in_trash' => __( 'Keine Datensätze im Papierkorb.', 'open-data-wizard' ), - 'featured_image' => __( 'Vorschaubild', 'open-data-wizard' ), - 'set_featured_image' => __( 'Vorschaubild festlegen', 'open-data-wizard' ), - 'remove_featured_image' => __( 'Vorschaubild entfernen', 'open-data-wizard' ), - 'use_featured_image' => __( 'Als Vorschaubild verwenden', 'open-data-wizard' ), - 'archives' => __( 'Datensatz-Archiv', 'open-data-wizard' ), - 'insert_into_item' => __( 'In Datensatz einfügen', 'open-data-wizard' ), - 'uploaded_to_this_item' => __( 'Zu diesem Datensatz hochgeladen', 'open-data-wizard' ), - 'items_list' => __( 'Datensatzliste', 'open-data-wizard' ), - 'items_list_navigation' => __( 'Datensatzliste Navigation', 'open-data-wizard' ), - 'filter_items_list' => __( 'Datensatzliste filtern', 'open-data-wizard' ), - ]; + // Map all CPT capabilities to manage_open_data so only admins/editors + // (who receive the cap on activation) can create or modify datasets. + $capabilities = array( + 'edit_post' => 'manage_open_data', + 'read_post' => 'read', + 'delete_post' => 'manage_open_data', + 'edit_posts' => 'manage_open_data', + 'edit_others_posts' => 'manage_open_data', + 'publish_posts' => 'manage_open_data', + 'read_private_posts' => 'manage_open_data', + 'delete_posts' => 'manage_open_data', + 'delete_private_posts' => 'manage_open_data', + 'delete_published_posts' => 'manage_open_data', + 'delete_others_posts' => 'manage_open_data', + 'edit_private_posts' => 'manage_open_data', + 'edit_published_posts' => 'manage_open_data', + 'create_posts' => 'manage_open_data', + ); - $args = [ - 'label' => __( 'Datensatz', 'open-data-wizard' ), - 'labels' => $labels, - 'description' => __( 'DCAT-AP 3.0 konforme Datensatz-Metadaten', 'open-data-wizard' ), - 'public' => false, - 'publicly_queryable' => false, - 'show_ui' => true, - 'show_in_menu' => true, - 'show_in_nav_menus' => false, - 'show_in_rest' => false, // REST handled by custom endpoints - 'query_var' => false, - 'rewrite' => false, - 'capability_type' => 'post', - 'has_archive' => false, - 'hierarchical' => false, - 'menu_position' => 20, - 'menu_icon' => 'dashicons-database', - 'supports' => [ 'title', 'revisions' ], - 'taxonomies' => [], - ]; + $args = array( + 'label' => __( 'Datensatz', 'open-data-wizard' ), + 'labels' => $labels, + 'description' => __( 'DCAT-AP 3.0 konforme Datensatz-Metadaten', 'open-data-wizard' ), + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => true, + 'show_in_menu' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => false, // REST is handled by custom endpoints in class-rest-api.php. + 'query_var' => false, + 'rewrite' => false, + 'capability_type' => 'odw_dataset', + 'map_meta_cap' => true, + 'capabilities' => $capabilities, + 'has_archive' => false, + 'hierarchical' => false, + 'menu_position' => 20, + 'menu_icon' => 'dashicons-database', + 'supports' => array( 'title', 'revisions' ), + 'taxonomies' => array(), + ); - register_post_type( 'odw_dataset', $args ); - } + register_post_type( 'odw_dataset', $args ); + } } diff --git a/includes/class-quality.php b/includes/class-quality.php index a3e5dad..c7dc9a2 100644 --- a/includes/class-quality.php +++ b/includes/class-quality.php @@ -16,389 +16,478 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * Quality indicator and traffic-light logic for odw_dataset posts. + * + * @package OpenDataWizard + */ class ODW_Quality { - public const LEVEL_HIGH = 'high'; - public const LEVEL_MEDIUM = 'medium'; - public const LEVEL_LOW = 'low'; - - 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', [ self::class, 'recalculate_on_save' ], 30 ); - - // Meta-Box auf dem Edit-Screen registrieren. - add_action( 'add_meta_boxes', [ self::class, 'register_meta_box' ] ); - - // Qualitätsdaten in JSON-LD einbetten (REST API + Vorschau). - add_filter( 'odw_dataset_jsonld', [ self::class, 'append_to_jsonld' ], 10, 2 ); - } - - // ------------------------------------------------------------------------- - // Indikator-Definitionen — Single Source of Truth - // ------------------------------------------------------------------------- - - /** - * Gibt alle Qualitätsindikatoren mit Punktewertung zurück. - * - * @return array - */ - 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' => '' ]; - } + public const LEVEL_HIGH = 'high'; + public const LEVEL_MEDIUM = 'medium'; + public const LEVEL_LOW = 'low'; + + /** + * 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 ); + + // Meta-Box auf dem Edit-Screen registrieren. + add_action( 'add_meta_boxes', array( self::class, 'register_meta_box' ) ); + + // Qualitätsdaten in JSON-LD einbetten (REST API + Vorschau). + add_filter( 'odw_dataset_jsonld', array( self::class, 'append_to_jsonld' ), 10, 2 ); + } + + // ------------------------------------------------------------------------- + // Indikator-Definitionen — Single Source of Truth + // ------------------------------------------------------------------------- + + /** + * Gibt alle Qualitätsindikatoren mit Punktewertung zurück. + * + * @return array + */ + public static function get_indicators(): array { + return array( + // Pflichtfelder (DCAT-AP 3.0 mandatory) — 55 Punkte. + array( + 'key' => 'title', + 'label' => __( 'Titel (dct:title)', 'open-data-wizard' ), + 'points' => 10, + 'required' => true, + ), + array( + 'key' => 'description', + 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ), + 'points' => 10, + 'required' => true, + ), + array( + 'key' => 'publisher', + 'label' => __( 'Herausgeber (dct:publisher)', 'open-data-wizard' ), + 'points' => 10, + 'required' => true, + ), + array( + 'key' => 'license', + 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ), + 'points' => 10, + 'required' => true, + ), + array( + 'key' => 'distribution', + 'label' => __( 'Distribution mit URL (dcat:accessURL)', 'open-data-wizard' ), + 'points' => 15, + 'required' => true, + ), + + // Empfohlene Felder (DCAT-AP 3.0 recommended) — 40 Punkte. + array( + 'key' => 'language', + 'label' => __( 'Sprache (dct:language)', 'open-data-wizard' ), + 'points' => 10, + 'required' => false, + ), + array( + 'key' => 'keywords', + 'label' => __( 'Schlagworte (dcat:keyword)', 'open-data-wizard' ), + 'points' => 10, + 'required' => false, + ), + array( + 'key' => 'theme', + 'label' => __( 'Thema (dcat:theme)', 'open-data-wizard' ), + 'points' => 10, + 'required' => false, + ), + array( + 'key' => 'issued', + 'label' => __( 'Veröffentlichungsdatum (dct:issued)', 'open-data-wizard' ), + 'points' => 10, + 'required' => false, + ), + + // Optionale Angaben — 5 Punkte. + array( + '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. + * + * @param int $post_id Dataset post ID. + * @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 = array(); + + foreach ( self::get_indicators() as $indicator ) { + $passed = self::check_indicator( $indicator['key'], $post ); + $earned = $passed ? $indicator['points'] : 0; + $total += $earned; + + $breakdown[ $indicator['key'] ] = array( + 'label' => $indicator['label'], + 'points' => $indicator['points'], + 'earned' => $earned, + 'passed' => $passed, + 'required' => $indicator['required'], + ); + } + + return array( + '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. + * + * @param string $key Indicator key (e.g. 'title', 'license'). + * @param \WP_Post $post Dataset post object. + * @return bool True when the indicator passes. + */ + 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. + * + * @param int $score Numeric score 0–100. + * @return string One of LEVEL_HIGH, LEVEL_MEDIUM, LEVEL_LOW. + */ + 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. + * + * @param int $post_id Dataset post ID. + * @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 array( + 'score' => (int) get_post_meta( $post_id, '_odw_quality_score', true ), + 'level' => $level, + 'indicators' => is_array( $indicators ) ? $indicators : array(), + 'calculated_at' => (string) get_post_meta( $post_id, '_odw_quality_calculated_at', true ), + ); + } + + /** + * Speichert Qualitätsdaten in Post-Meta. + * + * @param int $post_id Dataset post ID. + * @param array $result Result array from calculate(). + */ + 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. + * + * @param int $post_id Dataset post ID. + */ + 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'] = array( + '@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' ), + array( self::class, 'render_meta_box' ), + 'odw_dataset', + 'normal', + 'default' + ); + } + + /** + * Rendert den Inhalt der Qualitäts-Meta-Box. + * + * @param \WP_Post $post Current post object. + */ + 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 + // ------------------------------------------------------------------------- + + /** + * Returns a zeroed-out quality result used when no data has been stored yet. + * + * @return array{score: int, level: string, indicators: array, calculated_at: string} + */ + private static function empty_result(): array { + return array( + 'score' => 0, + 'level' => '', + 'indicators' => array(), + 'calculated_at' => '', + ); + } } diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 0171853..a36f17d 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -11,333 +11,573 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * REST API endpoints for the Open Data Wizard plugin. + * + * @package OpenDataWizard + */ 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 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#', - 'vcard' => 'http://www.w3.org/2006/vcard/ns#', - 'skos' => 'http://www.w3.org/2004/02/skos/core#', - 'odw' => 'https://github.com/daimpad/OpenDataWizard/ns#', - ]; - - 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 { - $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', - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ self::class, 'get_catalog' ], - 'permission_callback' => '__return_true', - 'args' => [ - 'page' => [ - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1, - ], - 'per_page' => [ - 'default' => 20, - 'sanitize_callback' => 'absint', - 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100, - ], - 'theme' => [ - 'default' => '', - 'sanitize_callback' => 'sanitize_text_field', - ], - 'license' => [ - 'default' => '', - 'sanitize_callback' => 'sanitize_text_field', - ], - 'format' => $format_arg, - ], - ] - ); - - register_rest_route( - self::NAMESPACE, - '/datasets/(?P\d+)', - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ self::class, 'get_dataset' ], - 'permission_callback' => '__return_true', - 'args' => [ - 'id' => [ - 'required' => true, - 'sanitize_callback' => 'absint', - 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v > 0, - ], - 'format' => $format_arg, - ], - ] - ); - } - - /** - * GET /catalog - */ - public static function get_catalog( WP_REST_Request $request ): WP_REST_Response|WP_Error { - $page = (int) $request->get_param( 'page' ); - $per_page = (int) $request->get_param( 'per_page' ); - $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 ) ) { - $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' ); - return $response; - } - - $query_args = [ - 'post_type' => 'odw_dataset', - 'post_status' => 'publish', - 'posts_per_page' => $per_page, - 'paged' => $page, - 'orderby' => 'modified', - 'order' => 'DESC', - 'no_found_rows' => false, - ]; - - $meta_query = []; - - if ( ! empty( $theme ) ) { - $meta_query[] = [ - 'key' => '_odw_theme', - 'value' => $theme, - ]; - } - - if ( ! empty( $license ) ) { - $license_map = self::get_license_alias_map(); - $license_url = $license_map[ strtolower( $license ) ] ?? $license; - - $meta_query[] = [ - 'key' => '_odw_license', - 'value' => $license_url, - ]; - } - - if ( ! empty( $meta_query ) ) { - $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; - - $datasets = []; - foreach ( $posts as $post ) { - $jsonld = odw_build_dataset_jsonld( (int) $post->ID ); - if ( $jsonld ) { - $datasets[] = $jsonld; - } - } - - /** - * 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' => $catalog_title, - 'dct:publisher' => [ - '@type' => 'foaf:Organization', - 'foaf:name' => get_bloginfo( 'name' ), - ], - 'dcat:dataset' => $datasets, - ]; - - set_transient( $cache_key, [ - 'body' => $catalog, - 'total' => $total, - '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', $content_type ); - $response->header( 'X-WP-Total', (string) $total ); - $response->header( 'X-WP-TotalPages', (string) $pages ); - $response->header( 'X-ODW-Cache', 'MISS' ); - - return $response; - } - - /** - * GET /datasets/ - */ - public static function get_dataset( WP_REST_Request $request ): WP_REST_Response|WP_Error { - $post_id = (int) $request->get_param( 'id' ); - $post = get_post( $post_id ); - - if ( ! $post || 'odw_dataset' !== $post->post_type ) { - return new WP_Error( - 'odw_not_found', - __( 'Datensatz nicht gefunden.', 'open-data-wizard' ), - [ 'status' => 404 ] - ); - } - - if ( 'publish' !== $post->post_status ) { - return new WP_Error( - 'odw_not_published', - __( 'Dieser Datensatz ist nicht veröffentlicht.', 'open-data-wizard' ), - [ 'status' => 403 ] - ); - } - - $cache_key = 'odw_dataset_' . $post_id; - $cached = get_transient( $cache_key ); - - if ( false !== $cached && is_array( $cached ) ) { - $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; - } - - $dataset = odw_build_dataset_jsonld( $post_id ); - - if ( ! $dataset ) { - return new WP_Error( - 'odw_build_failed', - __( 'Datensatz konnte nicht gebaut werden.', 'open-data-wizard' ), - [ 'status' => 500 ] - ); - } - - $body = array_merge( - [ '@context' => self::JSONLD_CONTEXT ], - $dataset - ); - - 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', $content_type ); - $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). - * Public alias used by ODW_Settings when cache TTL changes. - */ - public static function delete_catalog_transients_public(): void { - self::delete_catalog_transients(); - } - - 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_%' - ) - ); - } - - /** - * 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. - */ - 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', - ]; - } + private const NAMESPACE = 'datenatlas/v1'; + + /** Cache-TTL in Sekunden (5 Minuten). */ + private const CACHE_TTL = 300; + + /** + * DCAT-AP 3.0 JSON-LD @context inkl. Plugin-eigenem odw:-Namespace für Qualitätsdaten. + */ + private const JSONLD_CONTEXT = array( + '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#', + 'vcard' => 'http://www.w3.org/2006/vcard/ns#', + 'skos' => 'http://www.w3.org/2004/02/skos/core#', + 'odw' => 'https://github.com/daimpad/OpenDataWizard/ns#', + ); + + /** + * Registers WordPress hooks. + */ + public static function init(): void { + add_action( 'rest_api_init', array( self::class, 'register_routes' ) ); + + // Cache invalidieren wenn ein Datensatz gespeichert oder gelöscht wird. + add_action( 'save_post_odw_dataset', array( self::class, 'invalidate_cache' ) ); + add_action( 'trashed_post', array( self::class, 'invalidate_cache_on_trash' ) ); + } + + /** + * Registers the /catalog, /datasets/, and /delta REST routes. + */ + public static function register_routes(): void { + $format_arg = array( + 'default' => 'jsonld', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => fn( $v ) => in_array( $v, array( 'json', 'jsonld' ), true ), + ); + + $pagination_args = array( + 'page' => array( + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1, + ), + 'per_page' => array( + 'default' => 20, + 'sanitize_callback' => 'absint', + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100, + ), + ); + + register_rest_route( + self::NAMESPACE, + '/catalog', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'get_catalog' ), + 'permission_callback' => '__return_true', + 'args' => array_merge( + $pagination_args, + array( + 'theme' => array( + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'license' => array( + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'format' => $format_arg, + ) + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/datasets/(?P\d+)', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'get_dataset' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v > 0, + ), + 'format' => $format_arg, + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/delta', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'get_delta' ), + 'permission_callback' => '__return_true', + 'args' => array_merge( + $pagination_args, + array( + 'since' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => array( self::class, 'validate_since_param' ), + 'description' => 'ISO 8601 datetime — return only datasets modified after this point.', + ), + 'format' => $format_arg, + ) + ), + ) + ); + } + + /** + * GET /catalog — returns a pageable dcat:Catalog JSON-LD response. + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure. + */ + public static function get_catalog( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $page = (int) $request->get_param( 'page' ); + $per_page = (int) $request->get_param( 'per_page' ); + $theme = (string) $request->get_param( 'theme' ); + $license = (string) $request->get_param( 'license' ); + + $cache_key = 'odw_catalog_' . md5( serialize( array( $page, $per_page, $theme, $license ) ) ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached && is_array( $cached ) ) { + $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' ); + return $response; + } + + $query_args = array( + 'post_type' => 'odw_dataset', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => false, + ); + + $meta_query = array(); + + if ( ! empty( $theme ) ) { + $meta_query[] = array( + 'key' => '_odw_theme', + 'value' => $theme, + ); + } + + if ( ! empty( $license ) ) { + $license_map = self::get_license_alias_map(); + $license_url = $license_map[ strtolower( $license ) ] ?? $license; + + $meta_query[] = array( + 'key' => '_odw_license', + 'value' => $license_url, + ); + } + + if ( ! empty( $meta_query ) ) { + $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; + + $datasets = array(); + foreach ( $posts as $post ) { + $jsonld = odw_build_dataset_jsonld( (int) $post->ID ); + if ( $jsonld ) { + $datasets[] = $jsonld; + } + } + + /** + * 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 = array( + '@context' => self::JSONLD_CONTEXT, + '@type' => 'dcat:Catalog', + 'dct:title' => $catalog_title, + 'dct:publisher' => array( + '@type' => 'foaf:Organization', + 'foaf:name' => get_bloginfo( 'name' ), + ), + 'dcat:dataset' => $datasets, + ); + + set_transient( + $cache_key, + array( + 'body' => $catalog, + 'total' => $total, + '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', $content_type ); + $response->header( 'X-WP-Total', (string) $total ); + $response->header( 'X-WP-TotalPages', (string) $pages ); + $response->header( 'X-ODW-Cache', 'MISS' ); + + return $response; + } + + /** + * GET /datasets/ — returns a single dcat:Dataset JSON-LD response. + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure. + */ + public static function get_dataset( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $post_id = (int) $request->get_param( 'id' ); + $post = get_post( $post_id ); + + if ( ! $post || 'odw_dataset' !== $post->post_type ) { + return new WP_Error( + 'odw_not_found', + __( 'Datensatz nicht gefunden.', 'open-data-wizard' ), + array( 'status' => 404 ) + ); + } + + if ( 'publish' !== $post->post_status ) { + return new WP_Error( + 'odw_not_published', + __( 'Dieser Datensatz ist nicht veröffentlicht.', 'open-data-wizard' ), + array( 'status' => 403 ) + ); + } + + $cache_key = 'odw_dataset_' . $post_id; + $cached = get_transient( $cache_key ); + + if ( false !== $cached && is_array( $cached ) ) { + $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; + } + + $dataset = odw_build_dataset_jsonld( $post_id ); + + if ( ! $dataset ) { + return new WP_Error( + 'odw_build_failed', + __( 'Datensatz konnte nicht gebaut werden.', 'open-data-wizard' ), + array( 'status' => 500 ) + ); + } + + $body = array_merge( + array( '@context' => self::JSONLD_CONTEXT ), + $dataset + ); + + 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', $content_type ); + $response->header( 'X-ODW-Cache', 'MISS' ); + + return $response; + } + + /** + * GET /delta — returns datasets modified or removed since a given ISO 8601 timestamp. + * + * Response body keys: + * - dcat:dataset — array of full JSON-LD dataset objects modified after `since` + * - odw:removed — array of tombstone objects for datasets trashed after `since` + * + * Pagination (page/per_page) applies only to the modified set; all tombstones for the + * requested window are always included in full so harvesters don't miss deletions. + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response|WP_Error Response on success, WP_Error on invalid input. + */ + public static function get_delta( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $since = (string) $request->get_param( 'since' ); + $page = (int) $request->get_param( 'page' ); + $per_page = (int) $request->get_param( 'per_page' ); + + $since_dt = self::parse_iso8601( $since ); + if ( null === $since_dt ) { + return new WP_Error( + 'odw_invalid_since', + __( 'Ungültiges Datumsformat für "since". Bitte ISO 8601 verwenden (z. B. 2024-01-01T00:00:00Z).', 'open-data-wizard' ), + array( 'status' => 400 ) + ); + } + + $cache_key = 'odw_delta_' . md5( serialize( array( $since, $page, $per_page ) ) ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached && is_array( $cached ) ) { + $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-Delta-Since', $since ); + $response->header( 'X-ODW-Generated-At', $cached['generated_at'] ); + $response->header( 'X-ODW-Cache', 'HIT' ); + return $response; + } + + // Compare against UTC-stored post_modified_gmt to avoid timezone drift. + $after_gmt = $since_dt->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); + + $modified_query = new WP_Query( + array( + 'post_type' => 'odw_dataset', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => false, + 'date_query' => array( + array( + 'column' => 'post_modified_gmt', + 'after' => $after_gmt, + ), + ), + ) + ); + + // Tombstones: all trashed datasets in the window (never paginated so harvesters get them all). + $removed_query = new WP_Query( + array( + 'post_type' => 'odw_dataset', + 'post_status' => 'trash', + 'posts_per_page' => -1, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => true, + 'date_query' => array( + array( + 'column' => 'post_modified_gmt', + 'after' => $after_gmt, + ), + ), + ) + ); + + $datasets = array(); + foreach ( $modified_query->posts as $post ) { + $jsonld = odw_build_dataset_jsonld( (int) $post->ID ); + if ( $jsonld ) { + $datasets[] = $jsonld; + } + } + + $removed = array(); + foreach ( $removed_query->posts as $post ) { + $removed[] = array( + '@id' => rest_url( self::NAMESPACE . '/datasets/' . $post->ID ), + '@type' => 'dcat:Dataset', + 'odw:removedAt' => gmdate( 'c', strtotime( $post->post_modified_gmt ) ), + ); + } + + $total = (int) $modified_query->found_posts; + $pages = (int) $modified_query->max_num_pages; + $generated_at = gmdate( 'c' ); + + $body = array( + '@context' => self::JSONLD_CONTEXT, + '@type' => 'odw:DeltaCatalog', + 'dct:issued' => $generated_at, + 'odw:since' => $since, + 'odw:totalModified' => $total, + 'odw:totalRemoved' => count( $removed ), + 'dcat:dataset' => $datasets, + 'odw:removed' => $removed, + ); + + set_transient( + $cache_key, + array( + 'body' => $body, + 'total' => $total, + 'pages' => $pages, + 'generated_at' => $generated_at, + ), + 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', $content_type ); + $response->header( 'X-WP-Total', (string) $total ); + $response->header( 'X-WP-TotalPages', (string) $pages ); + $response->header( 'X-ODW-Delta-Since', $since ); + $response->header( 'X-ODW-Generated-At', $generated_at ); + $response->header( 'X-ODW-Cache', 'MISS' ); + + return $response; + } + + /** + * Validates the `since` query parameter as an ISO 8601 date or datetime string. + * + * Accepted formats: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, YYYY-MM-DDTHH:MM:SSZ, + * and YYYY-MM-DDTHH:MM:SS+HH:MM offset notation. + * + * @param mixed $value Raw parameter value from the request. + * @return bool True when the value is a recognised ISO 8601 string. + */ + public static function validate_since_param( mixed $value ): bool { + return null !== self::parse_iso8601( (string) $value ); + } + + /** + * Parses an ISO 8601 date/datetime string into a DateTimeImmutable (UTC). + * + * @param string $value ISO 8601 string to parse. + * @return DateTimeImmutable|null Parsed datetime in UTC, or null on failure. + */ + private static function parse_iso8601( string $value ): ?DateTimeImmutable { + $utc = new DateTimeZone( 'UTC' ); + + // Formats tried in descending specificity. + $formats = array( + 'Y-m-d\TH:i:sP', // Numeric timezone offset notation. + 'Y-m-d\TH:i:s\Z', // UTC Z suffix. + 'Y-m-d\TH:i:s', // No timezone (assumed UTC). + 'Y-m-d', // Date only, start of day UTC. + ); + + foreach ( $formats as $format ) { + $dt = DateTimeImmutable::createFromFormat( $format, $value, $utc ); + if ( false !== $dt ) { + return $dt->setTimezone( $utc ); + } + } + + return null; + } + + /** + * Invalidate all catalog caches when a dataset is saved. + * + * @param int $post_id Post ID of the saved dataset. + */ + 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. + * + * @param int $post_id Post ID of the trashed post. + */ + 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). + * Public alias used by ODW_Settings when cache TTL changes. + */ + public static function delete_catalog_transients_public(): void { + self::delete_catalog_transients(); + } + + /** + * Deletes all odw_catalog_* and odw_delta_* transients via a direct DB query. + * + * Pattern-based transient deletion has no WP API, so a direct query is required. + */ + 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 OR option_name LIKE %s OR option_name LIKE %s", + '_transient_odw_catalog_%', + '_transient_timeout_odw_catalog_%', + '_transient_odw_delta_%', + '_transient_timeout_odw_delta_%' + ) + ); + } + + /** + * 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. + * + * @param string $format Format parameter value ('json' or 'jsonld'). + * @return string Content-Type header value. + */ + 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. + */ + private static function get_license_alias_map(): array { + return array( + '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-settings.php b/includes/class-settings.php index 6660ecf..eefd439 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -19,341 +19,397 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * Settings page and WordPress Settings API registration for Open Data Wizard. + * + * @package OpenDataWizard + */ class ODW_Settings { - public const OPTION_KEY = 'odw_settings'; - - public static function init(): void { - add_action( 'admin_menu', [ self::class, 'add_submenu_page' ] ); - add_action( 'admin_init', [ self::class, 'register_settings' ] ); - add_action( 'admin_post_odw_recalculate_quality', [ self::class, 'handle_recalculate_quality' ] ); - add_filter( 'odw_catalog_title', [ self::class, 'filter_catalog_title' ] ); - } - - // ------------------------------------------------------------------------- - // Menü & Seite - // ------------------------------------------------------------------------- - - public static function add_submenu_page(): void { - add_submenu_page( - 'edit.php?post_type=odw_dataset', - __( 'Open Data Wizard — Einstellungen', 'open-data-wizard' ), - __( 'Einstellungen', 'open-data-wizard' ), - 'manage_options', - 'odw-settings', - [ self::class, 'render_page' ] - ); - } - - public static function render_page(): void { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - - $recalculated = isset( $_GET['odw_recalculated'] ) ? (int) sanitize_text_field( wp_unslash( $_GET['odw_recalculated'] ) ) : null; - ?> -
-

- - -
-

- -

-
- - -
- -
- -
- -

-

- -

-
- - - -
-
- [ self::class, 'sanitize' ], - 'default' => self::get_defaults(), - ] - ); - - // --- Katalog --- - add_settings_section( - 'odw_section_catalog', - __( 'Katalog', 'open-data-wizard' ), - static function (): void { - echo '

' . esc_html__( 'Metadaten des Datenkatalogs — erscheinen in der REST-API-Antwort von /wp-json/datenatlas/v1/catalog.', 'open-data-wizard' ) . '

'; - }, - 'odw-settings' - ); - - add_settings_field( 'catalog_title', __( 'Katalog-Titel', 'open-data-wizard' ), [ self::class, 'field_catalog_title' ], 'odw-settings', 'odw_section_catalog' ); - add_settings_field( 'default_publisher', __( 'Herausgebende Organisation', 'open-data-wizard' ), [ self::class, 'field_default_publisher' ], 'odw-settings', 'odw_section_catalog' ); - - // --- Standardwerte --- - add_settings_section( - 'odw_section_defaults', - __( 'Standardwerte für neue Datensätze', 'open-data-wizard' ), - static function (): void { - echo '

' . esc_html__( 'Diese Werte werden beim Anlegen eines neuen Datensatzes automatisch vorausgefüllt.', 'open-data-wizard' ) . '

'; - }, - 'odw-settings' - ); - - add_settings_field( 'default_license', __( 'Standard-Lizenz', 'open-data-wizard' ), [ self::class, 'field_default_license' ], 'odw-settings', 'odw_section_defaults' ); - add_settings_field( 'default_language', __( 'Standard-Sprache', 'open-data-wizard' ), [ self::class, 'field_default_language' ], 'odw-settings', 'odw_section_defaults' ); - - // --- API --- - add_settings_section( - 'odw_section_api', - __( 'REST API', 'open-data-wizard' ), - null, - 'odw-settings' - ); - - add_settings_field( 'cache_ttl', __( 'Cache-Laufzeit (Sekunden)', 'open-data-wizard' ), [ self::class, 'field_cache_ttl' ], 'odw-settings', 'odw_section_api' ); - - // --- Deinstallation --- - add_settings_section( - 'odw_section_uninstall', - __( 'Deinstallation', 'open-data-wizard' ), - null, - 'odw-settings' - ); - - add_settings_field( 'delete_on_uninstall', __( 'Daten löschen', 'open-data-wizard' ), [ self::class, 'field_delete_on_uninstall' ], 'odw-settings', 'odw_section_uninstall' ); - } - - // ------------------------------------------------------------------------- - // Feld-Callbacks - // ------------------------------------------------------------------------- - - public static function field_catalog_title(): void { - $value = self::get( 'catalog_title' ); - $placeholder = get_bloginfo( 'name' ) . ' — Datenkatalog'; - ?> - -

- - -

- - -

- __( '— Kein Standard —', 'open-data-wizard' ), - 'de' => __( 'Deutsch (DE)', 'open-data-wizard' ), - 'en' => __( 'Englisch (EN)', 'open-data-wizard' ), - ]; - ?> - - - -

- -

- - - 'odw_dataset', - 'post_status' => [ 'publish', 'draft' ], - 'posts_per_page' => -1, - 'fields' => 'ids', - ] ); - - $count = 0; - foreach ( $posts as $post_id ) { - ODW_Quality::store( (int) $post_id, ODW_Quality::calculate( (int) $post_id ) ); - $count++; - } - - wp_safe_redirect( - add_query_arg( - [ - 'page' => 'odw-settings', - 'odw_recalculated' => $count, - ], - admin_url( 'edit.php?post_type=odw_dataset' ) - ) - ); - exit; - } - - // ------------------------------------------------------------------------- - // Filter - // ------------------------------------------------------------------------- - - public static function filter_catalog_title( string $default ): string { - $custom = trim( self::get( 'catalog_title' ) ); - return '' !== $custom ? $custom : $default; - } - - // ------------------------------------------------------------------------- - // Datenzugriff - // ------------------------------------------------------------------------- - - /** - * Gibt alle Einstellungen oder einen einzelnen Wert zurück. - * - * @return mixed - */ - public static function get( string $key = '' ) { - $settings = (array) get_option( self::OPTION_KEY, [] ); - $settings = array_merge( self::get_defaults(), $settings ); - - if ( '' === $key ) { - return $settings; - } - - return $settings[ $key ] ?? null; - } - - private static function get_defaults(): array { - return [ - 'catalog_title' => '', - 'default_publisher' => '', - 'default_license' => '', - 'default_language' => '', - 'cache_ttl' => 300, - 'delete_on_uninstall' => '0', - ]; - } + public const OPTION_KEY = 'odw_settings'; + + /** + * Registers all WordPress hooks. + */ + public static function init(): void { + add_action( 'admin_menu', array( self::class, 'add_submenu_page' ) ); + add_action( 'admin_init', array( self::class, 'register_settings' ) ); + add_action( 'admin_post_odw_recalculate_quality', array( self::class, 'handle_recalculate_quality' ) ); + add_filter( 'odw_catalog_title', array( self::class, 'filter_catalog_title' ) ); + } + + // ------------------------------------------------------------------------- + // Menü & Seite + // ------------------------------------------------------------------------- + + /** + * Adds the Settings submenu page under the dataset list. + */ + public static function add_submenu_page(): void { + add_submenu_page( + 'edit.php?post_type=odw_dataset', + __( 'Open Data Wizard — Einstellungen', 'open-data-wizard' ), + __( 'Einstellungen', 'open-data-wizard' ), + 'manage_options', + 'odw-settings', + array( self::class, 'render_page' ) + ); + } + + /** + * Renders the settings page HTML. + */ + public static function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only redirect param set by handle_recalculate_quality() after nonce check. + $recalculated = isset( $_GET['odw_recalculated'] ) ? absint( wp_unslash( $_GET['odw_recalculated'] ) ) : null; + ?> +
+

+ + +
+

+ +

+
+ + +
+ +
+ +
+ +

+

+ +

+
+ + + +
+
+ array( self::class, 'sanitize' ), + 'default' => self::get_defaults(), + ) + ); + + // --- Katalog --- + add_settings_section( + 'odw_section_catalog', + __( 'Katalog', 'open-data-wizard' ), + static function (): void { + echo '

' . esc_html__( 'Metadaten des Datenkatalogs — erscheinen in der REST-API-Antwort von /wp-json/datenatlas/v1/catalog.', 'open-data-wizard' ) . '

'; + }, + 'odw-settings' + ); + + add_settings_field( 'catalog_title', __( 'Katalog-Titel', 'open-data-wizard' ), array( self::class, 'field_catalog_title' ), 'odw-settings', 'odw_section_catalog' ); + add_settings_field( 'default_publisher', __( 'Herausgebende Organisation', 'open-data-wizard' ), array( self::class, 'field_default_publisher' ), 'odw-settings', 'odw_section_catalog' ); + + // --- Standardwerte --- + add_settings_section( + 'odw_section_defaults', + __( 'Standardwerte für neue Datensätze', 'open-data-wizard' ), + static function (): void { + echo '

' . esc_html__( 'Diese Werte werden beim Anlegen eines neuen Datensatzes automatisch vorausgefüllt.', 'open-data-wizard' ) . '

'; + }, + 'odw-settings' + ); + + add_settings_field( 'default_license', __( 'Standard-Lizenz', 'open-data-wizard' ), array( self::class, 'field_default_license' ), 'odw-settings', 'odw_section_defaults' ); + add_settings_field( 'default_language', __( 'Standard-Sprache', 'open-data-wizard' ), array( self::class, 'field_default_language' ), 'odw-settings', 'odw_section_defaults' ); + + // --- API --- + add_settings_section( + 'odw_section_api', + __( 'REST API', 'open-data-wizard' ), + null, + 'odw-settings' + ); + + add_settings_field( 'cache_ttl', __( 'Cache-Laufzeit (Sekunden)', 'open-data-wizard' ), array( self::class, 'field_cache_ttl' ), 'odw-settings', 'odw_section_api' ); + + // --- Deinstallation --- + add_settings_section( + 'odw_section_uninstall', + __( 'Deinstallation', 'open-data-wizard' ), + null, + 'odw-settings' + ); + + add_settings_field( 'delete_on_uninstall', __( 'Daten löschen', 'open-data-wizard' ), array( self::class, 'field_delete_on_uninstall' ), 'odw-settings', 'odw_section_uninstall' ); + } + + // ------------------------------------------------------------------------- + // Feld-Callbacks + // ------------------------------------------------------------------------- + + /** + * Renders the catalog title settings field. + */ + public static function field_catalog_title(): void { + $value = self::get( 'catalog_title' ); + $placeholder = get_bloginfo( 'name' ) . ' — Datenkatalog'; + ?> + +

+ + +

+ + +

+ __( '— Kein Standard —', 'open-data-wizard' ), + 'de' => __( 'Deutsch (DE)', 'open-data-wizard' ), + 'en' => __( 'Englisch (EN)', 'open-data-wizard' ), + ); + ?> + + + +

+ +

+ + + $input Raw input array from the settings form. + * @return array Sanitized settings array. + */ + public static function sanitize( array $input ): array { + $defaults = self::get_defaults(); + + $output = array(); + $output['catalog_title'] = sanitize_text_field( $input['catalog_title'] ?? '' ); + $output['default_publisher'] = sanitize_text_field( $input['default_publisher'] ?? '' ); + $output['default_license'] = sanitize_text_field( $input['default_license'] ?? '' ); + $output['default_language'] = sanitize_text_field( $input['default_language'] ?? '' ); + $output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] ) ? '1' : '0'; + + $ttl = (int) ( $input['cache_ttl'] ?? $defaults['cache_ttl'] ); + $output['cache_ttl'] = max( 60, min( 86400, $ttl ) ); + + // Cache nach Einstellungsänderung invalidieren. + ODW_Rest_API::delete_catalog_transients_public(); + + return $output; + } + + // ------------------------------------------------------------------------- + // Aktionen + // ------------------------------------------------------------------------- + + /** + * Berechnet Qualitätsscores aller veröffentlichten Datensätze neu. + */ + public static function handle_recalculate_quality(): void { + check_admin_referer( 'odw_recalculate_quality' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Keine Berechtigung.', 'open-data-wizard' ) ); + } + + $posts = get_posts( + array( + 'post_type' => 'odw_dataset', + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + + $count = 0; + foreach ( $posts as $post_id ) { + ODW_Quality::store( (int) $post_id, ODW_Quality::calculate( (int) $post_id ) ); + ++$count; + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'odw-settings', + 'odw_recalculated' => $count, + ), + admin_url( 'edit.php?post_type=odw_dataset' ) + ) + ); + exit; + } + + // ------------------------------------------------------------------------- + // Filter + // ------------------------------------------------------------------------- + + /** + * Filter callback for `odw_catalog_title`: returns stored custom title or falls back to default. + * + * @param string $odw_default Default catalog title provided by the caller. + * @return string Custom title when set, default otherwise. + */ + public static function filter_catalog_title( string $odw_default ): string { + $custom = trim( self::get( 'catalog_title' ) ); + return '' !== $custom ? $custom : $odw_default; + } + + // ------------------------------------------------------------------------- + // Datenzugriff + // ------------------------------------------------------------------------- + + /** + * Returns all settings or a single value by key. + * + * @param string $key Setting key. Pass empty string to get all settings. + * @return mixed All settings array, or the individual value (or null if key unknown). + */ + public static function get( string $key = '' ) { + $settings = (array) get_option( self::OPTION_KEY, array() ); + $settings = array_merge( self::get_defaults(), $settings ); + + if ( '' === $key ) { + return $settings; + } + + return $settings[ $key ] ?? null; + } + + /** + * Returns the default settings values. + * + * @return array + */ + private static function get_defaults(): array { + return array( + 'catalog_title' => '', + 'default_publisher' => '', + 'default_license' => '', + 'default_language' => '', + 'cache_ttl' => 300, + 'delete_on_uninstall' => '0', + ); + } } diff --git a/includes/class-setup.php b/includes/class-setup.php index c168762..f7450a3 100644 --- a/includes/class-setup.php +++ b/includes/class-setup.php @@ -12,220 +12,240 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * Handles first-run setup: creates demo dataset and shows welcome notice. + * + * @package OpenDataWizard + */ class ODW_Setup { - private const DEMO_OPTION = 'odw_demo_post_id'; - private const WELCOME_OPTION = 'odw_show_welcome'; - - /** - * Wird direkt aus register_activation_hook aufgerufen — Carbon Fields - * ist zu diesem Zeitpunkt noch nicht geladen. - */ - public static function on_activation(): void { - update_option( self::WELCOME_OPTION, '1', false ); - } - - /** - * Wird aus odw_bootstrap() aufgerufen, nachdem Carbon Fields initialisiert ist. - */ - public static function init(): void { - add_action( 'admin_init', [ self::class, 'maybe_create_demo' ] ); - add_action( 'admin_init', [ self::class, 'handle_dismiss' ] ); - add_action( 'admin_notices', [ self::class, 'render_welcome_notice' ] ); - } - - // ------------------------------------------------------------------------- - // Demo-Datensatz - // ------------------------------------------------------------------------- - - /** - * Erstellt den Demo-Datensatz genau einmal nach der Aktivierung. - * Läuft auf admin_init — Carbon Fields ist hier vollständig initialisiert. - */ - public static function maybe_create_demo(): void { - if ( ! get_option( self::WELCOME_OPTION ) ) { - return; - } - - if ( get_option( self::DEMO_OPTION ) ) { - return; - } - - $post_id = self::create_demo_dataset(); - - if ( $post_id ) { - update_option( self::DEMO_OPTION, $post_id, false ); - } - } - - private static function create_demo_dataset(): int { - $post_id = wp_insert_post( [ - 'post_title' => __( 'Beispiel: Zivilgesellschaftliche Organisationen', 'open-data-wizard' ), - 'post_status' => 'publish', - 'post_type' => 'odw_dataset', - ] ); - - if ( is_wp_error( $post_id ) || ! $post_id ) { - return 0; - } - - // Einfache Felder direkt per update_post_meta setzen — - // Carbon Fields liest dieselben _odw_* Keys über carbon_get_post_meta(). - update_post_meta( $post_id, '_odw_description', __( - 'Dieser Demo-Datensatz enthält eine Beispielliste zivilgesellschaftlicher Organisationen. Er wurde automatisch bei der Plugin-Installation erstellt und kann als Vorlage oder zum Testen des [odw_dataset]-Shortcodes verwendet werden.', - 'open-data-wizard' - ) ); - update_post_meta( $post_id, '_odw_publisher', __( 'Datenatlas Zivilgesellschaft e.V.', 'open-data-wizard' ) ); - update_post_meta( $post_id, '_odw_license', 'https://creativecommons.org/publicdomain/zero/1.0/' ); - update_post_meta( $post_id, '_odw_language', 'de' ); - update_post_meta( $post_id, '_odw_keywords', "Zivilgesellschaft\nEngagement\nOrganisationen\nDemo" ); - update_post_meta( $post_id, '_odw_theme', 'Soziales' ); - update_post_meta( $post_id, '_odw_issued', current_time( 'Y-m-d' ) ); - update_post_meta( $post_id, '_odw_modified', current_time( 'Y-m-d' ) ); - - // Beispiel-CSV importieren und als Mediathek-Eintrag verknüpfen. - $file_id = self::import_sample_file(); - - if ( $file_id ) { - update_post_meta( $post_id, '_odw_file_id', $file_id ); - - // Distribution via Carbon Fields API setzen, damit JSON-LD und - // Qualitätsprüfung korrekte Daten lesen. - if ( function_exists( 'carbon_set_post_meta' ) ) { - $file_url = wp_get_attachment_url( $file_id ); - if ( $file_url ) { - carbon_set_post_meta( $post_id, 'odw_distributions', [ - [ - 'access_url' => $file_url, - 'format' => 'CSV', - 'byte_size' => '', - ], - ] ); - } - } - } - - // Qualitätsscore sofort berechnen und persistieren. - ODW_Quality::store( $post_id, ODW_Quality::calculate( $post_id ) ); - - return $post_id; - } - - /** - * Kopiert die gebündelte Beispiel-CSV in das Upload-Verzeichnis und - * legt einen Mediathek-Eintrag an. - */ - private static function import_sample_file(): int { - $source = ODW_PLUGIN_DIR . 'assets/sample/beispiel-datensatz.csv'; - - if ( ! file_exists( $source ) ) { - return 0; - } - - $upload = wp_upload_dir(); - $dest = trailingslashit( $upload['path'] ) . 'odw-beispiel-datensatz.csv'; - - if ( ! copy( $source, $dest ) ) { - return 0; - } - - $attachment_id = wp_insert_attachment( - [ - 'post_mime_type' => 'text/csv', - 'post_title' => __( 'Open Data Wizard — Demo-Datensatz (CSV)', 'open-data-wizard' ), - 'post_status' => 'inherit', - ], - $dest - ); - - if ( is_wp_error( $attachment_id ) || ! $attachment_id ) { - if ( file_exists( $dest ) ) { - unlink( $dest ); - } - return 0; - } - - return (int) $attachment_id; - } - - // ------------------------------------------------------------------------- - // Willkommens-Notice - // ------------------------------------------------------------------------- - - /** - * Verarbeitet den Dismiss-Link (GET-Parameter + Nonce). - */ - public static function handle_dismiss(): void { - if ( ! isset( $_GET['odw_dismiss_welcome'] ) ) { - return; - } - - $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; - - if ( ! wp_verify_nonce( $nonce, 'odw_dismiss_welcome' ) ) { - wp_die( esc_html__( 'Sicherheitsüberprüfung fehlgeschlagen.', 'open-data-wizard' ) ); - } - - delete_option( self::WELCOME_OPTION ); - - wp_safe_redirect( remove_query_arg( [ 'odw_dismiss_welcome', '_wpnonce' ] ) ); - exit; - } - - /** - * Gibt die einmalige Willkommens-Notice aus. - */ - public static function render_welcome_notice(): void { - if ( ! get_option( self::WELCOME_OPTION ) ) { - return; - } - - $demo_id = (int) get_option( self::DEMO_OPTION ); - $edit_url = $demo_id ? get_edit_post_link( $demo_id ) : null; - $list_url = admin_url( 'edit.php?post_type=odw_dataset' ); - $dismiss_url = wp_nonce_url( add_query_arg( 'odw_dismiss_welcome', '1' ), 'odw_dismiss_welcome' ); - $shortcode = $demo_id ? '[odw_dataset id="' . $demo_id . '"]' : ''; - ?> -
-

- -

- - -

- -

-

- -

- - -

- - - - -   - - - - -   - - - -

-
- __( 'Beispiel: Zivilgesellschaftliche Organisationen', 'open-data-wizard' ), + 'post_status' => 'publish', + 'post_type' => 'odw_dataset', + ) + ); + + if ( is_wp_error( $post_id ) || ! $post_id ) { + return 0; + } + + // Einfache Felder direkt per update_post_meta setzen — + // Carbon Fields liest dieselben _odw_* Keys über carbon_get_post_meta(). + update_post_meta( + $post_id, + '_odw_description', + __( + 'Dieser Demo-Datensatz enthält eine Beispielliste zivilgesellschaftlicher Organisationen. Er wurde automatisch bei der Plugin-Installation erstellt und kann als Vorlage oder zum Testen des [odw_dataset]-Shortcodes verwendet werden.', + 'open-data-wizard' + ) + ); + update_post_meta( $post_id, '_odw_publisher', __( 'Datenatlas Zivilgesellschaft e.V.', 'open-data-wizard' ) ); + update_post_meta( $post_id, '_odw_license', 'https://creativecommons.org/publicdomain/zero/1.0/' ); + update_post_meta( $post_id, '_odw_language', 'de' ); + update_post_meta( $post_id, '_odw_keywords', "Zivilgesellschaft\nEngagement\nOrganisationen\nDemo" ); + update_post_meta( $post_id, '_odw_theme', 'Soziales' ); + update_post_meta( $post_id, '_odw_issued', current_time( 'Y-m-d' ) ); + update_post_meta( $post_id, '_odw_modified', current_time( 'Y-m-d' ) ); + + // Beispiel-CSV importieren und als Mediathek-Eintrag verknüpfen. + $file_id = self::import_sample_file(); + + if ( $file_id ) { + update_post_meta( $post_id, '_odw_file_id', $file_id ); + + // Distribution via Carbon Fields API setzen, damit JSON-LD und + // Qualitätsprüfung korrekte Daten lesen. + if ( function_exists( 'carbon_set_post_meta' ) ) { + $file_url = wp_get_attachment_url( $file_id ); + if ( $file_url ) { + carbon_set_post_meta( + $post_id, + 'odw_distributions', + array( + array( + 'access_url' => $file_url, + 'format' => 'CSV', + 'byte_size' => '', + ), + ) + ); + } + } + } + + // Qualitätsscore sofort berechnen und persistieren. + ODW_Quality::store( $post_id, ODW_Quality::calculate( $post_id ) ); + + return $post_id; + } + + /** + * Kopiert die gebündelte Beispiel-CSV in das Upload-Verzeichnis und + * legt einen Mediathek-Eintrag an. + */ + private static function import_sample_file(): int { + $source = ODW_PLUGIN_DIR . 'assets/sample/beispiel-datensatz.csv'; + + if ( ! file_exists( $source ) ) { + return 0; + } + + $upload = wp_upload_dir(); + $dest = trailingslashit( $upload['path'] ) . 'odw-beispiel-datensatz.csv'; + + if ( ! copy( $source, $dest ) ) { + return 0; + } + + $attachment_id = wp_insert_attachment( + array( + 'post_mime_type' => 'text/csv', + 'post_title' => __( 'Open Data Wizard — Demo-Datensatz (CSV)', 'open-data-wizard' ), + 'post_status' => 'inherit', + ), + $dest + ); + + if ( is_wp_error( $attachment_id ) || ! $attachment_id ) { + if ( file_exists( $dest ) ) { + wp_delete_file( $dest ); + } + return 0; + } + + return (int) $attachment_id; + } + + // ------------------------------------------------------------------------- + // Willkommens-Notice + // ------------------------------------------------------------------------- + + /** + * Verarbeitet den Dismiss-Link (GET-Parameter + Nonce). + */ + public static function handle_dismiss(): void { + if ( ! isset( $_GET['odw_dismiss_welcome'] ) ) { + return; + } + + $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + + if ( ! wp_verify_nonce( $nonce, 'odw_dismiss_welcome' ) ) { + wp_die( esc_html__( 'Sicherheitsüberprüfung fehlgeschlagen.', 'open-data-wizard' ) ); + } + + delete_option( self::WELCOME_OPTION ); + + wp_safe_redirect( remove_query_arg( array( 'odw_dismiss_welcome', '_wpnonce' ) ) ); + exit; + } + + /** + * Gibt die einmalige Willkommens-Notice aus. + */ + public static function render_welcome_notice(): void { + if ( ! get_option( self::WELCOME_OPTION ) ) { + return; + } + + $demo_id = (int) get_option( self::DEMO_OPTION ); + $edit_url = $demo_id ? get_edit_post_link( $demo_id ) : null; + $list_url = admin_url( 'edit.php?post_type=odw_dataset' ); + $dismiss_url = wp_nonce_url( add_query_arg( 'odw_dismiss_welcome', '1' ), 'odw_dismiss_welcome' ); + $shortcode = $demo_id ? '[odw_dataset id="' . $demo_id . '"]' : ''; + ?> +
+

+ +

+ + +

+ +

+

+ +

+ + +

+ + + + +   + + + + +   + + + +

+
+ |string $atts - */ - public static function render( $atts ): string { - $atts = shortcode_atts( [ 'id' => '0' ], $atts, 'odw_dataset' ); - $post_id = absint( $atts['id'] ); - - if ( ! $post_id ) { - return ''; - } - - $post = get_post( $post_id ); - - if ( ! $post || 'odw_dataset' !== $post->post_type || 'publish' !== $post->post_status ) { - return ''; - } - - wp_enqueue_style( 'odw-frontend' ); - - // --- Metadaten --- - $title = get_the_title( $post ); - $theme = (string) get_post_meta( $post_id, '_odw_theme', true ); - $license_uri = (string) get_post_meta( $post_id, '_odw_license', true ); - $license_label = ODW_Fields::get_license_label( $license_uri ); - $quality_level = (string) get_post_meta( $post_id, '_odw_quality_level', true ); - $quality_score = (int) get_post_meta( $post_id, '_odw_quality_score', true ); - $file_id = (int) get_post_meta( $post_id, '_odw_file_id', true ); - - // --- Datei-Informationen aus der Mediathek --- - $file_url = ''; - $file_size = ''; - $file_format = ''; - - if ( $file_id > 0 ) { - $url = wp_get_attachment_url( $file_id ); - if ( $url ) { - $file_url = $url; - - // Use pre-computed meta (set on save) — fall back to runtime on old entries. - $stored_format = (string) get_post_meta( $post_id, '_odw_file_format', true ); - $file_format = $stored_format ?: strtoupper( (string) pathinfo( $url, PATHINFO_EXTENSION ) ); - - $stored_size = get_post_meta( $post_id, '_odw_file_size', true ); - if ( $stored_size !== '' && is_numeric( $stored_size ) ) { - $file_size = self::format_bytes( (int) $stored_size ); - } else { - $file_path = get_attached_file( $file_id ); - if ( $file_path && is_readable( $file_path ) ) { - $file_size = self::format_bytes( (int) filesize( $file_path ) ); - } - } - } - } - - // --- Qualitätslabel --- - $quality_label = ''; - if ( '' !== $quality_level ) { - $quality_label = ODW_Quality::get_level_label( $quality_level ); - if ( $quality_score > 0 ) { - $quality_label .= ' (' . $quality_score . '/100)'; - } - } - - // --- HTML aufbauen --- - ob_start(); - ?> -
- -
-

- - - -
- - -
- - -
-
-
-
- - - -
-
-
-
- - -
- - - - - - -
- = 1_073_741_824 ) { - return round( $bytes / 1_073_741_824, 1 ) . ' GB'; - } - if ( $bytes >= 1_048_576 ) { - return round( $bytes / 1_048_576, 1 ) . ' MB'; - } - if ( $bytes >= 1_024 ) { - return round( $bytes / 1_024, 1 ) . ' KB'; - } - return $bytes . ' B'; - } + /** + * Registers the shortcode and enqueue hook. + */ + public static function init(): void { + add_shortcode( 'odw_dataset', array( self::class, 'render' ) ); + add_action( 'wp_enqueue_scripts', array( self::class, 'register_assets' ) ); + } + + /** + * Nur registrieren — tatsächlich eingebunden wird erst beim Rendern, + * damit CSS nur auf Seiten geladen wird, die den Shortcode verwenden. + */ + public static function register_assets(): void { + wp_register_style( + 'odw-frontend', + ODW_PLUGIN_URL . 'assets/css/frontend.css', + array(), + ODW_VERSION + ); + } + + /** + * Shortcode-Handler — gibt HTML zurück, kein direktes echo. + * + * @param array|string $atts Shortcode attributes. Expects `id` with the post ID. + * @return string Rendered HTML or empty string when the post is not found/published. + */ + public static function render( $atts ): string { + $atts = shortcode_atts( array( 'id' => '0' ), $atts, 'odw_dataset' ); + $post_id = absint( $atts['id'] ); + + if ( ! $post_id ) { + return ''; + } + + $post = get_post( $post_id ); + + if ( ! $post || 'odw_dataset' !== $post->post_type || 'publish' !== $post->post_status ) { + return ''; + } + + wp_enqueue_style( 'odw-frontend' ); + + // --- Metadaten --- + $title = get_the_title( $post ); + $theme = (string) get_post_meta( $post_id, '_odw_theme', true ); + $license_uri = (string) get_post_meta( $post_id, '_odw_license', true ); + $license_label = ODW_Fields::get_license_label( $license_uri ); + $quality_level = (string) get_post_meta( $post_id, '_odw_quality_level', true ); + $quality_score = (int) get_post_meta( $post_id, '_odw_quality_score', true ); + $file_id = (int) get_post_meta( $post_id, '_odw_file_id', true ); + + // --- Datei-Informationen aus der Mediathek --- + $file_url = ''; + $file_size = ''; + $file_format = ''; + + if ( $file_id > 0 ) { + $url = wp_get_attachment_url( $file_id ); + if ( $url ) { + $file_url = $url; + + // Use pre-computed meta (set on save) — fall back to runtime on old entries. + $stored_format = (string) get_post_meta( $post_id, '_odw_file_format', true ); + $file_format = $stored_format ? $stored_format : strtoupper( (string) pathinfo( $url, PATHINFO_EXTENSION ) ); + + $stored_size = get_post_meta( $post_id, '_odw_file_size', true ); + if ( '' !== $stored_size && is_numeric( $stored_size ) ) { + $file_size = self::format_bytes( (int) $stored_size ); + } else { + $file_path = get_attached_file( $file_id ); + if ( $file_path && is_readable( $file_path ) ) { + $file_size = self::format_bytes( (int) filesize( $file_path ) ); + } + } + } + } + + // --- Qualitätslabel --- + $quality_label = ''; + if ( '' !== $quality_level ) { + $quality_label = ODW_Quality::get_level_label( $quality_level ); + if ( $quality_score > 0 ) { + $quality_label .= ' (' . $quality_score . '/100)'; + } + } + + // --- HTML aufbauen --- + ob_start(); + ?> +
+ +
+

+ + + +
+ + +
+ + +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + + +
+ = 1_073_741_824 ) { + return round( $bytes / 1_073_741_824, 1 ) . ' GB'; + } + if ( $bytes >= 1_048_576 ) { + return round( $bytes / 1_048_576, 1 ) . ' MB'; + } + if ( $bytes >= 1_024 ) { + return round( $bytes / 1_024, 1 ) . ' KB'; + } + return $bytes . ' B'; + } } diff --git a/includes/class-validation.php b/includes/class-validation.php index 1f153b3..c3233b9 100644 --- a/includes/class-validation.php +++ b/includes/class-validation.php @@ -11,223 +11,234 @@ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } +/** + * Blocks publishing of odw_dataset posts that fail required-field validation. + * + * @package OpenDataWizard + */ class ODW_Validation { - /** Transient-Prefix für Validierungsfehler (per Post-ID). */ - private const TRANSIENT_PREFIX = 'odw_validation_errors_'; - - public static function init(): void { - add_filter( 'wp_insert_post_data', [ self::class, 'intercept_publish' ], 10, 2 ); - add_action( 'admin_notices', [ self::class, 'show_validation_notice' ] ); - } - - /** - * Intercept the post save and prevent publishing if required fields are missing. - * Runs before post is written to DB. - * - * @param array $data Sanitised post data to be inserted. - * @param array $postarr Raw $_POST data. - * @return array - */ - public static function intercept_publish( array $data, array $postarr ): array { - // Only act on odw_dataset posts being set to publish. - if ( 'odw_dataset' !== $data['post_type'] ) { - return $data; - } - - if ( 'publish' !== $data['post_status'] ) { - return $data; - } - - $post_id = (int) ( $postarr['ID'] ?? 0 ); - - if ( ! $post_id ) { - return $data; - } - - // Skip if prior status was already publish (re-saving a published post is OK - // as long as fields aren't removed — validated below). - $errors = self::validate( $post_id, $postarr ); - - if ( empty( $errors ) ) { - return $data; - } - - // Revert status to draft. - $data['post_status'] = 'draft'; - - // Store errors so the admin notice can display them. - set_transient( - self::TRANSIENT_PREFIX . $post_id, - $errors, - 300 // 5 Minuten - ); - - return $data; - } - - /** - * Validate required fields. - * - * Carbon Fields saves to post_meta directly before/during save_post. - * At wp_insert_post_data time, the CF values may not yet be in the DB, - * so we additionally look at $_POST['carbon_fields_compact_input']. - * - * @param int $post_id Post ID. - * @param array $postarr Raw $_POST data. - * @return string[] Array of human-readable error messages (empty = valid). - */ - private static function validate( int $post_id, array $postarr ): array { - $errors = []; - - // Carbon Fields stores compact input in a JSON blob during save. - $cf_input = self::get_carbon_input( $postarr ); - - // --- Titel (WP-native, nicht in Carbon Fields) --- - $title = trim( (string) ( $postarr['post_title'] ?? '' ) ); - if ( '' === $title ) { - $errors[] = __( 'Titel (dct:title)', '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 --- - $has_distribution = self::has_valid_distribution( $post_id, $cf_input ); - if ( ! $has_distribution ) { - $errors[] = __( 'Mindestens eine Distribution mit Zugriffs-URL (dcat:accessURL)', 'open-data-wizard' ); - } - - return $errors; - } - - /** - * 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 (underscore-prefixed, e.g. _odw_publisher). - * @return mixed - */ - 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 ]; - } - - return get_post_meta( $post_id, $meta_key, true ); - } - - /** - * Check whether the post has at least one distribution with a non-empty access_url. - * - * @param int $post_id Post ID. - * @param array $cf_input Decoded Carbon Fields compact input. - */ - private static function has_valid_distribution( int $post_id, array $cf_input ): bool { - // Check CF compact input for new distributions. - 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 ) && self::is_valid_url( (string) $value ) ) { - return true; - } - } - } - - // Fall back to existing meta. - $distributions = carbon_get_post_meta( $post_id, 'odw_distributions' ); - - if ( ! is_array( $distributions ) ) { - return false; - } - - foreach ( $distributions as $dist ) { - if ( ! empty( $dist['access_url'] ) && self::is_valid_url( (string) $dist['access_url'] ) ) { - return true; - } - } - - 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. - * - * @param array $postarr - * @return array - */ - private static function get_carbon_input( array $postarr ): array { - $raw = $postarr['carbon_fields_compact_input'] ?? ''; - - if ( empty( $raw ) ) { - return []; - } - - if ( is_array( $raw ) ) { - return $raw; - } - - $decoded = json_decode( (string) $raw, true ); - return is_array( $decoded ) ? $decoded : []; - } - - /** - * Display Admin Notice if validation errors are stored for the current post. - */ - public static function show_validation_notice(): void { - $screen = get_current_screen(); - - if ( ! $screen || ! in_array( $screen->base, [ 'post', 'post-new' ], true ) ) { - return; - } - - // 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; - } - - $errors = get_transient( self::TRANSIENT_PREFIX . $post_id ); - - if ( ! is_array( $errors ) || empty( $errors ) ) { - return; - } - - delete_transient( self::TRANSIENT_PREFIX . $post_id ); - - echo '
'; - echo '

' . esc_html__( 'Open Data Wizard: Veröffentlichung blockiert', 'open-data-wizard' ) . '

'; - echo '

' . esc_html__( 'Folgende Pflichtfelder fehlen oder sind leer:', 'open-data-wizard' ) . '

'; - echo '
    '; - - foreach ( $errors as $field_label ) { - echo '
  • ' . esc_html( $field_label ) . '
  • '; - } - - echo '
'; - echo '

' . esc_html__( 'Der Datensatz wurde als Entwurf gespeichert. Bitte alle Pflichtfelder befüllen und erneut veröffentlichen.', 'open-data-wizard' ) . '

'; - echo '
'; - } + /** Transient-Prefix für Validierungsfehler (per Post-ID). */ + private const TRANSIENT_PREFIX = 'odw_validation_errors_'; + + /** + * Registers WordPress hooks. + */ + public static function init(): void { + add_filter( 'wp_insert_post_data', array( self::class, 'intercept_publish' ), 10, 2 ); + add_action( 'admin_notices', array( self::class, 'show_validation_notice' ) ); + } + + /** + * Intercept the post save and prevent publishing if required fields are missing. + * Runs before post is written to DB. + * + * @param array $data Sanitised post data to be inserted. + * @param array $postarr Raw $_POST data. + * @return array + */ + public static function intercept_publish( array $data, array $postarr ): array { + // Only act on odw_dataset posts being set to publish. + if ( 'odw_dataset' !== $data['post_type'] ) { + return $data; + } + + if ( 'publish' !== $data['post_status'] ) { + return $data; + } + + $post_id = (int) ( $postarr['ID'] ?? 0 ); + + if ( ! $post_id ) { + return $data; + } + + // Skip if prior status was already publish (re-saving a published post is OK + // as long as fields aren't removed — validated below). + $errors = self::validate( $post_id, $postarr ); + + if ( empty( $errors ) ) { + return $data; + } + + // Revert status to draft. + $data['post_status'] = 'draft'; + + // Store errors so the admin notice can display them. + set_transient( + self::TRANSIENT_PREFIX . $post_id, + $errors, + 300 // 5 Minuten + ); + + return $data; + } + + /** + * Validate required fields. + * + * Carbon Fields saves to post_meta directly before/during save_post. + * At wp_insert_post_data time, the CF values may not yet be in the DB, + * so we additionally look at $_POST['carbon_fields_compact_input']. + * + * @param int $post_id Post ID. + * @param array $postarr Raw $_POST data. + * @return string[] Array of human-readable error messages (empty = valid). + */ + private static function validate( int $post_id, array $postarr ): array { + $errors = array(); + + // Carbon Fields stores compact input in a JSON blob during save. + $cf_input = self::get_carbon_input( $postarr ); + + // --- Titel (WP-native, nicht in Carbon Fields) --- + $title = trim( (string) ( $postarr['post_title'] ?? '' ) ); + if ( '' === $title ) { + $errors[] = __( 'Titel (dct:title)', '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 --- + $has_distribution = self::has_valid_distribution( $post_id, $cf_input ); + if ( ! $has_distribution ) { + $errors[] = __( 'Mindestens eine Distribution mit Zugriffs-URL (dcat:accessURL)', 'open-data-wizard' ); + } + + return $errors; + } + + /** + * 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 (underscore-prefixed, e.g. _odw_publisher). + * @return mixed + */ + 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 ]; + } + + return get_post_meta( $post_id, $meta_key, true ); + } + + /** + * Check whether the post has at least one distribution with a non-empty access_url. + * + * @param int $post_id Post ID. + * @param array $cf_input Decoded Carbon Fields compact input. + */ + private static function has_valid_distribution( int $post_id, array $cf_input ): bool { + // Check CF compact input for new distributions. + 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 ) && self::is_valid_url( (string) $value ) ) { + return true; + } + } + } + + // Fall back to existing meta. + $distributions = carbon_get_post_meta( $post_id, 'odw_distributions' ); + + if ( ! is_array( $distributions ) ) { + return false; + } + + foreach ( $distributions as $dist ) { + if ( ! empty( $dist['access_url'] ) && self::is_valid_url( (string) $dist['access_url'] ) ) { + return true; + } + } + + return false; + } + + /** + * Validate that a string is a safe HTTP(S) URL. + * Blocks javascript:, data:, and other non-HTTP schemes. + * + * @param string $url URL to validate. + * @return bool True when scheme is http, https, ftp, or ftps. + */ + 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, array( 'http', 'https', 'ftp', 'ftps' ), true ); + } + + /** + * Parse the Carbon Fields compact JSON input from $_POST. + * + * @param array $postarr Raw $_POST data passed to wp_insert_post_data. + * @return array Decoded field map, empty array on failure. + */ + private static function get_carbon_input( array $postarr ): array { + $raw = $postarr['carbon_fields_compact_input'] ?? ''; + + if ( empty( $raw ) ) { + return array(); + } + + if ( is_array( $raw ) ) { + return $raw; + } + + $decoded = json_decode( (string) $raw, true ); + return is_array( $decoded ) ? $decoded : array(); + } + + /** + * Display Admin Notice if validation errors are stored for the current post. + */ + public static function show_validation_notice(): void { + $screen = get_current_screen(); + + if ( ! $screen || ! in_array( $screen->base, array( 'post', 'post-new' ), true ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- read-only: used only to look up stored transient errors. + $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : ( isset( $_POST['post_ID'] ) ? absint( $_POST['post_ID'] ) : 0 ); + + if ( ! $post_id ) { + return; + } + + $errors = get_transient( self::TRANSIENT_PREFIX . $post_id ); + + if ( ! is_array( $errors ) || empty( $errors ) ) { + return; + } + + delete_transient( self::TRANSIENT_PREFIX . $post_id ); + + echo '
'; + echo '

' . esc_html__( 'Open Data Wizard: Veröffentlichung blockiert', 'open-data-wizard' ) . '

'; + echo '

' . esc_html__( 'Folgende Pflichtfelder fehlen oder sind leer:', 'open-data-wizard' ) . '

'; + echo '
    '; + + foreach ( $errors as $field_label ) { + echo '
  • ' . esc_html( $field_label ) . '
  • '; + } + + echo '
'; + echo '

' . esc_html__( 'Der Datensatz wurde als Entwurf gespeichert. Bitte alle Pflichtfelder befüllen und erneut veröffentlichen.', 'open-data-wizard' ) . '

'; + echo '
'; + } } diff --git a/open-data-wizard.php b/open-data-wizard.php index 85be778..4d5b003 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.7.0 + * Version: 1.8.0 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: Datenatlas Zivilgesellschaft @@ -11,15 +11,17 @@ * License URI: https://www.gnu.org/licenses/gpl-2.0.html * Text Domain: open-data-wizard * Domain Path: /languages + * + * @package OpenDataWizard */ declare(strict_types=1); if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } -define( 'ODW_VERSION', '1.7.0' ); +define( 'ODW_VERSION', '1.8.0' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); @@ -35,15 +37,21 @@ register_activation_hook( __FILE__, 'odw_activate' ); register_deactivation_hook( __FILE__, 'odw_deactivate' ); +/** + * Runs on plugin activation: registers CPT, flushes rewrite rules, grants capabilities. + */ function odw_activate(): void { - odw_register_cpt_static(); - flush_rewrite_rules(); - odw_add_capabilities(); - ODW_Setup::on_activation(); + odw_register_cpt_static(); + flush_rewrite_rules(); + odw_add_capabilities(); + ODW_Setup::on_activation(); } +/** + * Runs on plugin deactivation: flushes rewrite rules. + */ function odw_deactivate(): void { - flush_rewrite_rules(); + flush_rewrite_rules(); } /** @@ -51,23 +59,26 @@ function odw_deactivate(): void { * 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' ); - } - } + $roles = array( 'administrator', 'editor' ); + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->add_cap( 'manage_open_data' ); + } + } } /** * 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' ], - ] ); + register_post_type( + 'odw_dataset', + array( + 'public' => false, + 'supports' => array( 'title', 'revisions' ), + ) + ); } // --------------------------------------------------------------------------- @@ -78,55 +89,61 @@ function odw_register_cpt_static(): void { * Bootstrap Carbon Fields and all plugin modules. */ function odw_bootstrap(): void { - $autoloader = ODW_PLUGIN_DIR . 'vendor/autoload.php'; - - if ( ! file_exists( $autoloader ) ) { - add_action( 'admin_notices', function (): void { - echo '

'; - esc_html_e( - 'Open Data Wizard: Vendor-Abhängigkeiten fehlen. Bitte composer install im Plugin-Verzeichnis ausführen.', - 'open-data-wizard' - ); - echo '

'; - } ); - return; - } - - require_once $autoloader; - - 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-settings.php'; - require_once ODW_PLUGIN_DIR . 'includes/class-post-types.php'; - 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'; - require_once ODW_PLUGIN_DIR . 'includes/class-shortcode.php'; - - ODW_Settings::init(); - ODW_Post_Types::init(); - ODW_Fields::init(); - ODW_Rest_API::init(); - ODW_Validation::init(); - ODW_Quality::init(); - ODW_Admin::init(); - ODW_Shortcode::init(); - ODW_Setup::init(); + $autoloader = ODW_PLUGIN_DIR . 'vendor/autoload.php'; + + if ( ! file_exists( $autoloader ) ) { + add_action( + 'admin_notices', + function (): void { + echo '

'; + esc_html_e( + 'Open Data Wizard: Vendor-Abhängigkeiten fehlen. Bitte composer install im Plugin-Verzeichnis ausführen.', + 'open-data-wizard' + ); + echo '

'; + } + ); + return; + } + + require_once $autoloader; + + 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-settings.php'; + require_once ODW_PLUGIN_DIR . 'includes/class-post-types.php'; + 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'; + require_once ODW_PLUGIN_DIR . 'includes/class-shortcode.php'; + + ODW_Settings::init(); + ODW_Post_Types::init(); + ODW_Fields::init(); + ODW_Rest_API::init(); + ODW_Validation::init(); + ODW_Quality::init(); + ODW_Admin::init(); + ODW_Shortcode::init(); + ODW_Setup::init(); } add_action( 'after_setup_theme', 'odw_bootstrap' ); @@ -134,10 +151,10 @@ function odw_bootstrap(): void { * Load plugin textdomain. */ function odw_load_textdomain(): void { - load_plugin_textdomain( - 'open-data-wizard', - false, - dirname( plugin_basename( __FILE__ ) ) . '/languages' - ); + load_plugin_textdomain( + 'open-data-wizard', + false, + dirname( plugin_basename( __FILE__ ) ) . '/languages' + ); } add_action( 'init', 'odw_load_textdomain' ); diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..64bdbf0 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,37 @@ + + + WordPress Coding Standards for Open Data Wizard plugin. + + . + vendor/* + + + + + + + + + includes/* + + + + + includes/* + + + + + includes/class-rest-api.php + + + + + tests/* + + + + + tests/* + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7d353a4..e4cdab6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,17 +15,17 @@ // Plugin-Konstanten definieren damit Includes geladen werden können. if ( ! defined( 'ABSPATH' ) ) { - define( 'ABSPATH', '/tmp/wordpress/' ); + define( 'ABSPATH', '/tmp/wordpress/' ); } if ( ! defined( 'ODW_VERSION' ) ) { - define( 'ODW_VERSION', '1.2.0' ); + define( 'ODW_VERSION', '1.2.0' ); } if ( ! defined( 'ODW_PLUGIN_DIR' ) ) { - define( 'ODW_PLUGIN_DIR', dirname( __DIR__ ) . '/' ); + define( 'ODW_PLUGIN_DIR', dirname( __DIR__ ) . '/' ); } if ( ! defined( 'ODW_PLUGIN_URL' ) ) { - define( 'ODW_PLUGIN_URL', 'http://localhost/wp-content/plugins/open-data-wizard/' ); + define( 'ODW_PLUGIN_URL', 'http://localhost/wp-content/plugins/open-data-wizard/' ); } if ( ! defined( 'ODW_PLUGIN_FILE' ) ) { - define( 'ODW_PLUGIN_FILE', dirname( __DIR__ ) . '/open-data-wizard.php' ); + define( 'ODW_PLUGIN_FILE', dirname( __DIR__ ) . '/open-data-wizard.php' ); } diff --git a/tests/test-fields-extended.php b/tests/test-fields-extended.php index dbb643f..badae40 100644 --- a/tests/test-fields-extended.php +++ b/tests/test-fields-extended.php @@ -9,289 +9,401 @@ use PHPUnit\Framework\TestCase; +/** + * Extended unit tests for ODW_Fields methods and odw_build_dataset_jsonld(). + * + * @package OpenDataWizard + */ class Test_ODW_Fields_Extended extends TestCase { - protected function setUp(): void { - \WP_Mock::setUp(); - } - - protected function tearDown(): void { - \WP_Mock::tearDown(); - } - - private function load_fields(): void { - if ( ! class_exists( 'ODW_Fields' ) ) { - \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; - } - } - - // ------------------------------------------------------------------------- - // get_periodicity_options() - // ------------------------------------------------------------------------- - - public function test_get_periodicity_options_has_empty_key_default(): void { - $this->load_fields(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $options = ODW_Fields::get_periodicity_options(); - $this->assertArrayHasKey( '', $options ); - } - - public function test_get_periodicity_options_contains_daily_uri(): void { - $this->load_fields(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $options = ODW_Fields::get_periodicity_options(); - $this->assertArrayHasKey( - 'http://publications.europa.eu/resource/authority/frequency/DAILY', - $options - ); - } - - public function test_get_periodicity_options_contains_annual_uri(): void { - $this->load_fields(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $options = ODW_Fields::get_periodicity_options(); - $this->assertArrayHasKey( - 'http://publications.europa.eu/resource/authority/frequency/ANNUAL', - $options - ); - } - - public function test_get_periodicity_options_all_uris_use_correct_base(): void { - $this->load_fields(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $base = 'http://publications.europa.eu/resource/authority/frequency/'; - $options = ODW_Fields::get_periodicity_options(); - - foreach ( array_keys( $options ) as $key ) { - if ( '' === $key ) { - continue; - } - $this->assertStringStartsWith( $base, $key, "Key '$key' does not use the EU Publications Office base URI." ); - } - } - - // ------------------------------------------------------------------------- - // odw_build_dataset_jsonld() — helper to build mock post & CF meta - // ------------------------------------------------------------------------- - - /** - * @param array $cf_meta CF field key → value - * @param array $post_meta WP meta key → value - */ - private function setup_jsonld_mocks( - int $post_id, - string $post_type, - array $cf_meta = [], - array $post_meta = [] - ): void { - $post = new \stdClass(); - $post->ID = $post_id; - $post->post_type = $post_type; - $post->post_title = 'Test Dataset'; - $post->post_status = 'publish'; - - \WP_Mock::userFunction( 'get_post' ) - ->with( $post_id ) - ->andReturn( $post ); - - // Default CF meta returns empty string unless overridden. - \WP_Mock::userFunction( 'carbon_get_post_meta' ) - ->andReturnUsing( function ( $id, $key ) use ( $cf_meta ) { - return $cf_meta[ $key ] ?? ''; - } ); - - // get_post_meta for _odw_modified and similar keys. - \WP_Mock::userFunction( 'get_post_meta' ) - ->andReturnUsing( function ( $id, $key, $single ) use ( $post_meta ) { - return $post_meta[ $key ] ?? ''; - } ); - - \WP_Mock::userFunction( 'rest_url' ) - ->andReturnUsing( function ( $path ) { - return 'http://localhost/wp-json/' . $path; - } ); - - \WP_Mock::userFunction( 'apply_filters' ) - ->andReturnArg( 1 ); - } - - public function test_build_returns_null_for_non_dataset_post_type(): void { - $this->load_fields(); - - $post = new \stdClass(); - $post->ID = 1; - $post->post_type = 'post'; - - \WP_Mock::userFunction( 'get_post' ) - ->with( 1 ) - ->andReturn( $post ); - - $this->assertNull( odw_build_dataset_jsonld( 1 ) ); - } - - public function test_build_returns_null_when_post_not_found(): void { - $this->load_fields(); - - \WP_Mock::userFunction( 'get_post' ) - ->with( 999 ) - ->andReturn( null ); - - $this->assertNull( odw_build_dataset_jsonld( 999 ) ); - } - - public function test_build_includes_landing_page_when_set(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 10, 'odw_dataset', [ - 'odw_landing_page' => 'https://example.com/project', - ] ); - - $result = odw_build_dataset_jsonld( 10 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dcat:landingPage', $result ); - $this->assertSame( [ '@id' => 'https://example.com/project' ], $result['dcat:landingPage'] ); - } - - public function test_build_omits_landing_page_when_empty(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 10, 'odw_dataset' ); - - $result = odw_build_dataset_jsonld( 10 ); - - $this->assertIsArray( $result ); - $this->assertArrayNotHasKey( 'dcat:landingPage', $result ); - } - - public function test_build_includes_accrual_periodicity_when_set(): void { - $this->load_fields(); - - $uri = 'http://publications.europa.eu/resource/authority/frequency/MONTHLY'; - $this->setup_jsonld_mocks( 11, 'odw_dataset', [ - 'odw_accrual_periodicity' => $uri, - ] ); - - $result = odw_build_dataset_jsonld( 11 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dct:accrualPeriodicity', $result ); - $this->assertSame( [ '@id' => $uri ], $result['dct:accrualPeriodicity'] ); - } - - public function test_build_includes_spatial_with_correct_type(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 12, 'odw_dataset', [ - 'odw_spatial' => 'Berlin', - ] ); - - $result = odw_build_dataset_jsonld( 12 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dct:spatial', $result ); - $this->assertSame( 'dct:Location', $result['dct:spatial']['@type'] ); - $this->assertSame( 'Berlin', $result['dct:spatial']['skos:prefLabel'] ); - } - - public function test_build_includes_temporal_with_start_and_end(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 13, 'odw_dataset', [ - 'odw_temporal_start' => '2024-01-01', - 'odw_temporal_end' => '2024-12-31', - ] ); - - $result = odw_build_dataset_jsonld( 13 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dct:temporal', $result ); - - $temporal = $result['dct:temporal']; - $this->assertSame( 'dct:PeriodOfTime', $temporal['@type'] ); - $this->assertSame( [ '@type' => 'xsd:date', '@value' => '2024-01-01' ], $temporal['dcat:startDate'] ); - $this->assertSame( [ '@type' => 'xsd:date', '@value' => '2024-12-31' ], $temporal['dcat:endDate'] ); - } - - public function test_build_includes_temporal_with_start_only(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 14, 'odw_dataset', [ - 'odw_temporal_start' => '2025-01-01', - ] ); - - $result = odw_build_dataset_jsonld( 14 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dct:temporal', $result ); - $this->assertArrayHasKey( 'dcat:startDate', $result['dct:temporal'] ); - $this->assertArrayNotHasKey( 'dcat:endDate', $result['dct:temporal'] ); - } - - public function test_build_omits_temporal_when_both_empty(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 15, 'odw_dataset' ); - - $result = odw_build_dataset_jsonld( 15 ); - - $this->assertIsArray( $result ); - $this->assertArrayNotHasKey( 'dct:temporal', $result ); - } - - public function test_build_includes_contact_point_with_mailto_prefix(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 16, 'odw_dataset', [ - 'odw_contact_name' => 'Max Mustermann', - 'odw_contact_email' => 'max@example.org', - ] ); - - $result = odw_build_dataset_jsonld( 16 ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'dcat:contactPoint', $result ); - - $contact = $result['dcat:contactPoint']; - $this->assertSame( 'vcard:Organization', $contact['@type'] ); - $this->assertSame( 'Max Mustermann', $contact['vcard:fn'] ); - $this->assertSame( 'mailto:max@example.org', $contact['vcard:hasEmail'] ); - } - - public function test_build_contact_point_includes_url_as_id(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 17, 'odw_dataset', [ - 'odw_contact_name' => 'Org', - 'odw_contact_email' => 'info@org.de', - 'odw_contact_url' => 'https://org.de', - ] ); - - $result = odw_build_dataset_jsonld( 17 ); - - $this->assertIsArray( $result ); - $contact = $result['dcat:contactPoint']; - $this->assertSame( [ '@id' => 'https://org.de' ], $contact['vcard:hasURL'] ); - } - - public function test_build_omits_contact_point_when_empty(): void { - $this->load_fields(); - - $this->setup_jsonld_mocks( 18, 'odw_dataset' ); - - $result = odw_build_dataset_jsonld( 18 ); - - $this->assertIsArray( $result ); - $this->assertArrayNotHasKey( 'dcat:contactPoint', $result ); - } + /** + * Set up WP_Mock before each test. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + } + + /** + * Tear down WP_Mock after each test. + */ + protected function tearDown(): void { + \WP_Mock::tearDown(); + } + + /** + * Loads ODW_Fields (and its companion function) once per test run. + */ + private function load_fields(): void { + if ( ! class_exists( 'ODW_Fields' ) ) { + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + } + } + + // ------------------------------------------------------------------------- + // get_periodicity_options() + // ------------------------------------------------------------------------- + + /** + * Includes an empty-string key as placeholder in periodicity options. + */ + public function test_get_periodicity_options_has_empty_key_default(): void { + $this->load_fields(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $options = ODW_Fields::get_periodicity_options(); + $this->assertArrayHasKey( '', $options ); + } + + /** + * The DAILY frequency URI from the EU Publications Office is present. + */ + public function test_get_periodicity_options_contains_daily_uri(): void { + $this->load_fields(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $options = ODW_Fields::get_periodicity_options(); + $this->assertArrayHasKey( + 'http://publications.europa.eu/resource/authority/frequency/DAILY', + $options + ); + } + + /** + * The ANNUAL frequency URI from the EU Publications Office is present. + */ + public function test_get_periodicity_options_contains_annual_uri(): void { + $this->load_fields(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $options = ODW_Fields::get_periodicity_options(); + $this->assertArrayHasKey( + 'http://publications.europa.eu/resource/authority/frequency/ANNUAL', + $options + ); + } + + /** + * All non-empty periodicity option keys start with the correct EU Publications Office base URI. + */ + public function test_get_periodicity_options_all_uris_use_correct_base(): void { + $this->load_fields(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $base = 'http://publications.europa.eu/resource/authority/frequency/'; + $options = ODW_Fields::get_periodicity_options(); + + foreach ( array_keys( $options ) as $key ) { + if ( '' === $key ) { + continue; + } + $this->assertStringStartsWith( $base, $key, "Key '$key' does not use the EU Publications Office base URI." ); + } + } + + // ------------------------------------------------------------------------- + // odw_build_dataset_jsonld() — helper to build mock post & CF meta + // ------------------------------------------------------------------------- + + /** + * Sets up WP_Mock stubs needed by odw_build_dataset_jsonld() tests. + * + * @param int $post_id Post ID to mock. + * @param string $post_type Post type for the mock post object. + * @param array $cf_meta Carbon Fields key-value map (field slug → value). + * @param array $post_meta WP meta key-value map (meta key → value). + */ + private function setup_jsonld_mocks( + int $post_id, + string $post_type, + array $cf_meta = array(), + array $post_meta = array() + ): void { + $post = new \stdClass(); + $post->ID = $post_id; + $post->post_type = $post_type; + $post->post_title = 'Test Dataset'; + $post->post_status = 'publish'; + + \WP_Mock::userFunction( 'get_post' ) + ->with( $post_id ) + ->andReturn( $post ); + + // Default CF meta returns empty string unless overridden. + \WP_Mock::userFunction( 'carbon_get_post_meta' ) + ->andReturnUsing( + function ( $id, $key ) use ( $cf_meta ) { + return $cf_meta[ $key ] ?? ''; + } + ); + + // get_post_meta for _odw_modified and similar keys. + \WP_Mock::userFunction( 'get_post_meta' ) + ->andReturnUsing( + function ( $id, $key, $_single ) use ( $post_meta ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $post_meta[ $key ] ?? ''; + } + ); + + \WP_Mock::userFunction( 'rest_url' ) + ->andReturnUsing( + function ( $path ) { + return 'http://localhost/wp-json/' . $path; + } + ); + + \WP_Mock::userFunction( 'apply_filters' ) + ->andReturnArg( 1 ); + } + + /** + * Returns null when the post type is not odw_dataset. + */ + public function test_build_returns_null_for_non_dataset_post_type(): void { + $this->load_fields(); + + $post = new \stdClass(); + $post->ID = 1; + $post->post_type = 'post'; + + \WP_Mock::userFunction( 'get_post' ) + ->with( 1 ) + ->andReturn( $post ); + + $this->assertNull( odw_build_dataset_jsonld( 1 ) ); + } + + /** + * Returns null when no post is found for the given ID. + */ + public function test_build_returns_null_when_post_not_found(): void { + $this->load_fields(); + + \WP_Mock::userFunction( 'get_post' ) + ->with( 999 ) + ->andReturn( null ); + + $this->assertNull( odw_build_dataset_jsonld( 999 ) ); + } + + /** + * The dcat:landingPage key is present in JSON-LD when odw_landing_page is set. + */ + public function test_build_includes_landing_page_when_set(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 10, + 'odw_dataset', + array( + 'odw_landing_page' => 'https://example.com/project', + ) + ); + + $result = odw_build_dataset_jsonld( 10 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dcat:landingPage', $result ); + $this->assertSame( array( '@id' => 'https://example.com/project' ), $result['dcat:landingPage'] ); + } + + /** + * The dcat:landingPage key is absent when odw_landing_page is empty. + */ + public function test_build_omits_landing_page_when_empty(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( 10, 'odw_dataset' ); + + $result = odw_build_dataset_jsonld( 10 ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'dcat:landingPage', $result ); + } + + /** + * The dct:accrualPeriodicity is included as an @id object when the field is set. + */ + public function test_build_includes_accrual_periodicity_when_set(): void { + $this->load_fields(); + + $uri = 'http://publications.europa.eu/resource/authority/frequency/MONTHLY'; + $this->setup_jsonld_mocks( + 11, + 'odw_dataset', + array( + 'odw_accrual_periodicity' => $uri, + ) + ); + + $result = odw_build_dataset_jsonld( 11 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dct:accrualPeriodicity', $result ); + $this->assertSame( array( '@id' => $uri ), $result['dct:accrualPeriodicity'] ); + } + + /** + * The dct:spatial field uses @type dct:Location and skos:prefLabel for a text value. + */ + public function test_build_includes_spatial_with_correct_type(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 12, + 'odw_dataset', + array( + 'odw_spatial' => 'Berlin', + ) + ); + + $result = odw_build_dataset_jsonld( 12 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dct:spatial', $result ); + $this->assertSame( 'dct:Location', $result['dct:spatial']['@type'] ); + $this->assertSame( 'Berlin', $result['dct:spatial']['skos:prefLabel'] ); + } + + /** + * The dct:temporal field includes both dcat:startDate and dcat:endDate as xsd:date typed values. + */ + public function test_build_includes_temporal_with_start_and_end(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 13, + 'odw_dataset', + array( + 'odw_temporal_start' => '2024-01-01', + 'odw_temporal_end' => '2024-12-31', + ) + ); + + $result = odw_build_dataset_jsonld( 13 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dct:temporal', $result ); + + $temporal = $result['dct:temporal']; + $this->assertSame( 'dct:PeriodOfTime', $temporal['@type'] ); + $this->assertSame( + array( + '@type' => 'xsd:date', + '@value' => '2024-01-01', + ), + $temporal['dcat:startDate'] + ); + $this->assertSame( + array( + '@type' => 'xsd:date', + '@value' => '2024-12-31', + ), + $temporal['dcat:endDate'] + ); + } + + /** + * The dct:temporal field contains only dcat:startDate when end date is empty. + */ + public function test_build_includes_temporal_with_start_only(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 14, + 'odw_dataset', + array( + 'odw_temporal_start' => '2025-01-01', + ) + ); + + $result = odw_build_dataset_jsonld( 14 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dct:temporal', $result ); + $this->assertArrayHasKey( 'dcat:startDate', $result['dct:temporal'] ); + $this->assertArrayNotHasKey( 'dcat:endDate', $result['dct:temporal'] ); + } + + /** + * The dct:temporal field is absent when both start and end are empty. + */ + public function test_build_omits_temporal_when_both_empty(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( 15, 'odw_dataset' ); + + $result = odw_build_dataset_jsonld( 15 ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'dct:temporal', $result ); + } + + /** + * The dcat:contactPoint includes vcard:hasEmail with the mailto: prefix. + */ + public function test_build_includes_contact_point_with_mailto_prefix(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 16, + 'odw_dataset', + array( + 'odw_contact_name' => 'Max Mustermann', + 'odw_contact_email' => 'max@example.org', + ) + ); + + $result = odw_build_dataset_jsonld( 16 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'dcat:contactPoint', $result ); + + $contact = $result['dcat:contactPoint']; + $this->assertSame( 'vcard:Organization', $contact['@type'] ); + $this->assertSame( 'Max Mustermann', $contact['vcard:fn'] ); + $this->assertSame( 'mailto:max@example.org', $contact['vcard:hasEmail'] ); + } + + /** + * The vcard:hasURL is emitted as an @id object when odw_contact_url is set. + */ + public function test_build_contact_point_includes_url_as_id(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( + 17, + 'odw_dataset', + array( + 'odw_contact_name' => 'Org', + 'odw_contact_email' => 'info@org.de', + 'odw_contact_url' => 'https://org.de', + ) + ); + + $result = odw_build_dataset_jsonld( 17 ); + + $this->assertIsArray( $result ); + $contact = $result['dcat:contactPoint']; + $this->assertSame( array( '@id' => 'https://org.de' ), $contact['vcard:hasURL'] ); + } + + /** + * The dcat:contactPoint is absent when both contact name and email are empty. + */ + public function test_build_omits_contact_point_when_empty(): void { + $this->load_fields(); + + $this->setup_jsonld_mocks( 18, 'odw_dataset' ); + + $result = odw_build_dataset_jsonld( 18 ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'dcat:contactPoint', $result ); + } } diff --git a/tests/test-fields.php b/tests/test-fields.php index 5af3f5d..9dfe9bb 100644 --- a/tests/test-fields.php +++ b/tests/test-fields.php @@ -9,61 +9,84 @@ use PHPUnit\Framework\TestCase; +/** + * Unit tests for ODW_Fields static helper methods. + * + * @package OpenDataWizard + */ class Test_ODW_Fields extends TestCase { - public function test_get_required_fields_returns_expected_keys(): void { - require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; - - $fields = ODW_Fields::get_required_fields(); - - $this->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' ) ); - } + /** + * Returns a non-empty array with the three required meta keys. + */ + public function test_get_required_fields_returns_expected_keys(): void { + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + $fields = ODW_Fields::get_required_fields(); + + $this->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 ); + } + + /** + * The license options array includes an empty-string key as the placeholder option. + */ + 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 ); + } + + /** + * Returns the human-readable label for a known license URI. + */ + 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 ); + } + + /** + * Returns the URI itself when the URI is not in the license map. + */ + 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 ) ); + } + + /** + * Maps "CSV" to the correct MIME type. + */ + 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' ) ); + } + + /** + * Returns the input string unchanged for unknown formats. + */ + 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' ) ); + } } diff --git a/tests/test-quality.php b/tests/test-quality.php index 3a2d64c..329cf11 100644 --- a/tests/test-quality.php +++ b/tests/test-quality.php @@ -9,237 +9,302 @@ use PHPUnit\Framework\TestCase; +/** + * Unit tests for ODW_Quality. + * + * @package OpenDataWizard + */ class Test_ODW_Quality extends TestCase { - protected function setUp(): void { - \WP_Mock::setUp(); - } - - protected function tearDown(): void { - \WP_Mock::tearDown(); - } - - private function load_class(): void { - if ( ! class_exists( 'ODW_Quality' ) ) { - require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; - } - } - - // ------------------------------------------------------------------------- - // get_level() - // ------------------------------------------------------------------------- - - public function test_get_level_returns_high_at_80(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_HIGH, ODW_Quality::get_level( 80 ) ); - } - - public function test_get_level_returns_high_at_100(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_HIGH, ODW_Quality::get_level( 100 ) ); - } - - public function test_get_level_returns_medium_at_50(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_MEDIUM, ODW_Quality::get_level( 50 ) ); - } - - public function test_get_level_returns_medium_at_79(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_MEDIUM, ODW_Quality::get_level( 79 ) ); - } - - public function test_get_level_returns_low_at_0(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_LOW, ODW_Quality::get_level( 0 ) ); - } - - public function test_get_level_returns_low_at_49(): void { - $this->load_class(); - $this->assertSame( ODW_Quality::LEVEL_LOW, ODW_Quality::get_level( 49 ) ); - } - - // ------------------------------------------------------------------------- - // get_level_label() - // ------------------------------------------------------------------------- - - public function test_get_level_label_high(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $this->assertSame( 'Gut', ODW_Quality::get_level_label( ODW_Quality::LEVEL_HIGH ) ); - } - - public function test_get_level_label_medium(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $this->assertSame( 'Mittel', ODW_Quality::get_level_label( ODW_Quality::LEVEL_MEDIUM ) ); - } - - public function test_get_level_label_low(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $this->assertSame( 'Verbesserungsbedarf', ODW_Quality::get_level_label( ODW_Quality::LEVEL_LOW ) ); - } - - public function test_get_level_label_unknown_returns_fallback(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $this->assertSame( 'Unbekannt', ODW_Quality::get_level_label( 'invalid' ) ); - } - - // ------------------------------------------------------------------------- - // get_indicators() - // ------------------------------------------------------------------------- - - public function test_get_indicators_sums_to_100(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $total = array_sum( array_column( ODW_Quality::get_indicators(), 'points' ) ); - $this->assertSame( 100, $total ); - } - - public function test_get_indicators_has_title_key(): void { - $this->load_class(); - - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - - $keys = array_column( ODW_Quality::get_indicators(), 'key' ); - $this->assertContains( 'title', $keys ); - } - - // ------------------------------------------------------------------------- - // get() — liest aus Post-Meta - // ------------------------------------------------------------------------- - - public function test_get_returns_empty_result_when_no_level_stored(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_level', true ) - ->andReturn( '' ); - - $result = ODW_Quality::get( 42 ); - - $this->assertSame( 0, $result['score'] ); - $this->assertSame( '', $result['level'] ); - $this->assertSame( [], $result['indicators'] ); - $this->assertSame( '', $result['calculated_at'] ); - } - - public function test_get_returns_stored_values(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_level', true ) - ->andReturn( 'high' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_score', true ) - ->andReturn( '85' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_indicators', true ) - ->andReturn( [ 'title' => [ 'passed' => true ] ] ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_calculated_at', true ) - ->andReturn( '2026-04-21 10:00:00' ); - - $result = ODW_Quality::get( 42 ); - - $this->assertSame( 85, $result['score'] ); - $this->assertSame( 'high', $result['level'] ); - $this->assertSame( '2026-04-21 10:00:00', $result['calculated_at'] ); - } - - // ------------------------------------------------------------------------- - // store() - // ------------------------------------------------------------------------- - - public function test_store_calls_update_post_meta_for_all_keys(): void { - $this->load_class(); - - $result = [ - 'score' => 75, - 'level' => 'medium', - 'indicators' => [], - 'calculated_at' => '2026-04-21 12:00:00', - ]; - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_score', 75 ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_level', 'medium' ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_indicators', [] ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_calculated_at', '2026-04-21 12:00:00' ) - ->once(); - - ODW_Quality::store( 7, $result ); - - // WP_Mock ->once() expectations verified in tearDown; count them explicitly. - $this->addToAssertionCount( 4 ); - } - - // ------------------------------------------------------------------------- - // append_to_jsonld() - // ------------------------------------------------------------------------- - - public function test_append_to_jsonld_adds_quality_data(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_level', true ) - ->andReturn( 'high' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_score', true ) - ->andReturn( '90' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_indicators', true ) - ->andReturn( [] ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_calculated_at', true ) - ->andReturn( '2026-04-21 09:00:00' ); - - $dataset = [ '@type' => 'dcat:Dataset' ]; - $result = ODW_Quality::append_to_jsonld( $dataset, 5 ); - - $this->assertArrayHasKey( 'odw:qualityScore', $result ); - $this->assertSame( 90, $result['odw:qualityScore']['odw:score'] ); - $this->assertSame( 'high', $result['odw:qualityScore']['odw:level'] ); - $this->assertSame( 100, $result['odw:qualityScore']['odw:maxScore'] ); - } - - public function test_append_to_jsonld_skips_when_no_level(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_level', true ) - ->andReturn( '' ); - - $dataset = [ '@type' => 'dcat:Dataset' ]; - $result = ODW_Quality::append_to_jsonld( $dataset, 5 ); - - $this->assertArrayNotHasKey( 'odw:qualityScore', $result ); - } + /** + * Set up WP_Mock before each test. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + } + + /** + * Tear down WP_Mock after each test. + */ + protected function tearDown(): void { + \WP_Mock::tearDown(); + } + + /** + * Loads ODW_Quality once per test run. + */ + private function load_class(): void { + if ( ! class_exists( 'ODW_Quality' ) ) { + require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; + } + } + + // ------------------------------------------------------------------------- + // get_level() + // ------------------------------------------------------------------------- + + /** + * Score 80 maps to LEVEL_HIGH. + */ + public function test_get_level_returns_high_at_80(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_HIGH, ODW_Quality::get_level( 80 ) ); + } + + /** + * Score 100 maps to LEVEL_HIGH. + */ + public function test_get_level_returns_high_at_100(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_HIGH, ODW_Quality::get_level( 100 ) ); + } + + /** + * Score 50 maps to LEVEL_MEDIUM. + */ + public function test_get_level_returns_medium_at_50(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_MEDIUM, ODW_Quality::get_level( 50 ) ); + } + + /** + * Score 79 is still LEVEL_MEDIUM (boundary check). + */ + public function test_get_level_returns_medium_at_79(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_MEDIUM, ODW_Quality::get_level( 79 ) ); + } + + /** + * Score 0 maps to LEVEL_LOW. + */ + public function test_get_level_returns_low_at_0(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_LOW, ODW_Quality::get_level( 0 ) ); + } + + /** + * Score 49 is LEVEL_LOW (boundary check). + */ + public function test_get_level_returns_low_at_49(): void { + $this->load_class(); + $this->assertSame( ODW_Quality::LEVEL_LOW, ODW_Quality::get_level( 49 ) ); + } + + // ------------------------------------------------------------------------- + // get_level_label() + // ------------------------------------------------------------------------- + + /** + * LEVEL_HIGH maps to label "Gut". + */ + public function test_get_level_label_high(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( 'Gut', ODW_Quality::get_level_label( ODW_Quality::LEVEL_HIGH ) ); + } + + /** + * LEVEL_MEDIUM maps to label "Mittel". + */ + public function test_get_level_label_medium(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( 'Mittel', ODW_Quality::get_level_label( ODW_Quality::LEVEL_MEDIUM ) ); + } + + /** + * LEVEL_LOW maps to label "Verbesserungsbedarf". + */ + public function test_get_level_label_low(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( 'Verbesserungsbedarf', ODW_Quality::get_level_label( ODW_Quality::LEVEL_LOW ) ); + } + + /** + * An unknown level string returns the fallback label "Unbekannt". + */ + public function test_get_level_label_unknown_returns_fallback(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( 'Unbekannt', ODW_Quality::get_level_label( 'invalid' ) ); + } + + // ------------------------------------------------------------------------- + // get_indicators() + // ------------------------------------------------------------------------- + + /** + * The sum of all indicator points equals 100. + */ + public function test_get_indicators_sums_to_100(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $total = array_sum( array_column( ODW_Quality::get_indicators(), 'points' ) ); + $this->assertSame( 100, $total ); + } + + /** + * The 'title' indicator key is present in the indicators list. + */ + public function test_get_indicators_has_title_key(): void { + $this->load_class(); + + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $keys = array_column( ODW_Quality::get_indicators(), 'key' ); + $this->assertContains( 'title', $keys ); + } + + // ------------------------------------------------------------------------- + // get() — liest aus Post-Meta + // ------------------------------------------------------------------------- + + /** + * Returns a zeroed result when no quality level is stored in post meta. + */ + public function test_get_returns_empty_result_when_no_level_stored(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 42, '_odw_quality_level', true ) + ->andReturn( '' ); + + $result = ODW_Quality::get( 42 ); + + $this->assertSame( 0, $result['score'] ); + $this->assertSame( '', $result['level'] ); + $this->assertSame( array(), $result['indicators'] ); + $this->assertSame( '', $result['calculated_at'] ); + } + + /** + * Returns score, level, and calculated_at from stored post meta. + */ + public function test_get_returns_stored_values(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 42, '_odw_quality_level', true ) + ->andReturn( 'high' ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 42, '_odw_quality_score', true ) + ->andReturn( '85' ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 42, '_odw_quality_indicators', true ) + ->andReturn( array( 'title' => array( 'passed' => true ) ) ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 42, '_odw_quality_calculated_at', true ) + ->andReturn( '2026-04-21 10:00:00' ); + + $result = ODW_Quality::get( 42 ); + + $this->assertSame( 85, $result['score'] ); + $this->assertSame( 'high', $result['level'] ); + $this->assertSame( '2026-04-21 10:00:00', $result['calculated_at'] ); + } + + // ------------------------------------------------------------------------- + // store() + // ------------------------------------------------------------------------- + + /** + * Calls update_post_meta for all four quality meta keys. + */ + public function test_store_calls_update_post_meta_for_all_keys(): void { + $this->load_class(); + + $result = array( + 'score' => 75, + 'level' => 'medium', + 'indicators' => array(), + 'calculated_at' => '2026-04-21 12:00:00', + ); + + \WP_Mock::userFunction( 'update_post_meta' ) + ->with( 7, '_odw_quality_score', 75 ) + ->once(); + + \WP_Mock::userFunction( 'update_post_meta' ) + ->with( 7, '_odw_quality_level', 'medium' ) + ->once(); + + \WP_Mock::userFunction( 'update_post_meta' ) + ->with( 7, '_odw_quality_indicators', array() ) + ->once(); + + \WP_Mock::userFunction( 'update_post_meta' ) + ->with( 7, '_odw_quality_calculated_at', '2026-04-21 12:00:00' ) + ->once(); + + ODW_Quality::store( 7, $result ); + + // WP_Mock ->once() expectations verified in tearDown; count them explicitly. + $this->addToAssertionCount( 4 ); + } + + // ------------------------------------------------------------------------- + // append_to_jsonld() + // ------------------------------------------------------------------------- + + /** + * Adds odw:qualityScore to the dataset array when a quality level is stored. + */ + public function test_append_to_jsonld_adds_quality_data(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 5, '_odw_quality_level', true ) + ->andReturn( 'high' ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 5, '_odw_quality_score', true ) + ->andReturn( '90' ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 5, '_odw_quality_indicators', true ) + ->andReturn( array() ); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 5, '_odw_quality_calculated_at', true ) + ->andReturn( '2026-04-21 09:00:00' ); + + $dataset = array( '@type' => 'dcat:Dataset' ); + $result = ODW_Quality::append_to_jsonld( $dataset, 5 ); + + $this->assertArrayHasKey( 'odw:qualityScore', $result ); + $this->assertSame( 90, $result['odw:qualityScore']['odw:score'] ); + $this->assertSame( 'high', $result['odw:qualityScore']['odw:level'] ); + $this->assertSame( 100, $result['odw:qualityScore']['odw:maxScore'] ); + } + + /** + * Leaves the dataset array unchanged when no quality level is stored. + */ + public function test_append_to_jsonld_skips_when_no_level(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_post_meta' ) + ->with( 5, '_odw_quality_level', true ) + ->andReturn( '' ); + + $dataset = array( '@type' => 'dcat:Dataset' ); + $result = ODW_Quality::append_to_jsonld( $dataset, 5 ); + + $this->assertArrayNotHasKey( 'odw:qualityScore', $result ); + } } diff --git a/tests/test-rest-delta.php b/tests/test-rest-delta.php new file mode 100644 index 0000000..dcc6029 --- /dev/null +++ b/tests/test-rest-delta.php @@ -0,0 +1,603 @@ + + */ + private array $params = array(); + + /** + * Sets a single parameter value. + * + * @param string $key Parameter name. + * @param mixed $value Parameter value. + */ + public function set_param( string $key, mixed $value ): void { + $this->params[ $key ] = $value; + } + + /** + * Returns the value for a single parameter. + * + * @param string $key Parameter name. + * @return mixed Parameter value, or null when not set. + */ + public function get_param( string $key ): mixed { + return $this->params[ $key ] ?? null; + } + } +} + +if ( ! class_exists( 'WP_REST_Response' ) ) { + /** + * Stub for WP_REST_Response. + */ + class WP_REST_Response { + /** + * Response body data. + * + * @var mixed + */ + public mixed $data; + /** + * HTTP status code. + * + * @var int + */ + public int $status; + /** + * Stored response headers. + * + * @var array + */ + public array $headers = array(); + + /** + * Creates a REST response stub. + * + * @param mixed $data Response body. + * @param int $status HTTP status code. + */ + public function __construct( mixed $data, int $status = 200 ) { + $this->data = $data; + $this->status = $status; + } + + /** + * Stores an HTTP response header. + * + * @param string $key Header name. + * @param string $value Header value. + */ + public function header( string $key, string $value ): void { + $this->headers[ $key ] = $value; + } + } +} + +if ( ! class_exists( 'WP_Error' ) ) { + /** + * Stub for WP_Error. + */ + class WP_Error { + /** + * Error code string. + * + * @var string + */ + public string $code; + /** + * Human-readable error message. + * + * @var string + */ + public string $message; + /** + * Additional error data. + * + * @var array + */ + public array $data; + + /** + * Creates a WP_Error stub. + * + * @param string $code Error code. + * @param string $message Human-readable message. + * @param array $data Additional data (e.g. HTTP status). + */ + public function __construct( string $code, string $message = '', array $data = array() ) { + $this->code = $code; + $this->message = $message; + $this->data = $data; + } + } +} + +if ( ! class_exists( 'WP_Query' ) ) { + /** + * Configurable stub for WP_Query used in delta endpoint tests. + * + * Tests push result sets onto $mock_queue; each instantiation shifts one off. + */ + class WP_Query { + /** + * Preset result sets consumed by each instantiation. + * + * @var array> + */ + public static array $mock_queue = array(); + + /** + * Queried post objects. + * + * @var array + */ + public array $posts = array(); + /** + * Total number of found posts. + * + * @var int + */ + public int $found_posts = 0; + /** + * Total number of result pages. + * + * @var int + */ + public int $max_num_pages = 0; + + /** + * Creates a WP_Query stub, consuming the next item from the mock queue. + * + * @param array $args Query arguments (not used by stub). + */ + public function __construct( array $args = array() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- stub intentionally ignores query args. + $result = array_shift( self::$mock_queue ) ?? array(); + $this->posts = $result['posts'] ?? array(); + $this->found_posts = $result['found_posts'] ?? 0; + $this->max_num_pages = $result['max_num_pages'] ?? 0; + } + } +} + +if ( ! class_exists( 'DateTimeZone' ) ) { + /** + * Stub for DateTimeZone — only needed if PHP is missing it (should not happen). + */ + class DateTimeZone {} // phpcs:ignore Generic.Files.OneClassPerFile.MultipleFound -- stubs file. +} + +/** + * Unit tests for the ODW_Rest_API delta harvesting endpoint. + * + * @package OpenDataWizard + */ +class Test_ODW_Rest_Delta extends TestCase { + + /** + * Set up WP_Mock before each test. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + WP_Query::$mock_queue = array(); + } + + /** + * Tear down WP_Mock after each test. + */ + protected function tearDown(): void { + \WP_Mock::tearDown(); + } + + /** + * Loads ODW_Rest_API once per test run. + */ + private function load_class(): void { + if ( ! class_exists( 'ODW_Rest_API' ) ) { + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + require_once ODW_PLUGIN_DIR . 'includes/class-rest-api.php'; + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + } + } + + /** + * Builds a WP_REST_Request stub with the given parameters pre-set. + * + * @param array $params Request parameters. + * @return WP_REST_Request Populated request stub. + */ + private function make_request( array $params ): WP_REST_Request { + $request = new WP_REST_Request(); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + return $request; + } + + // ------------------------------------------------------------------------- + // validate_since_param() + // ------------------------------------------------------------------------- + + /** + * Accepts a plain date string in YYYY-MM-DD format. + */ + public function test_validate_since_accepts_date_only(): void { + $this->load_class(); + $this->assertTrue( ODW_Rest_API::validate_since_param( '2024-01-01' ) ); + } + + /** + * Accepts a full datetime with Z (UTC) suffix. + */ + public function test_validate_since_accepts_datetime_utc_z(): void { + $this->load_class(); + $this->assertTrue( ODW_Rest_API::validate_since_param( '2024-06-15T12:30:00Z' ) ); + } + + /** + * Accepts a full datetime with numeric timezone offset. + */ + public function test_validate_since_accepts_datetime_with_offset(): void { + $this->load_class(); + $this->assertTrue( ODW_Rest_API::validate_since_param( '2024-06-15T12:30:00+02:00' ) ); + } + + /** + * Accepts a datetime without timezone suffix (assumed UTC). + */ + public function test_validate_since_accepts_datetime_no_tz(): void { + $this->load_class(); + $this->assertTrue( ODW_Rest_API::validate_since_param( '2024-06-15T12:30:00' ) ); + } + + /** + * Rejects a free-text string that is not a date. + */ + public function test_validate_since_rejects_free_text(): void { + $this->load_class(); + $this->assertFalse( ODW_Rest_API::validate_since_param( 'yesterday' ) ); + } + + /** + * Rejects an empty string. + */ + public function test_validate_since_rejects_empty_string(): void { + $this->load_class(); + $this->assertFalse( ODW_Rest_API::validate_since_param( '' ) ); + } + + /** + * Rejects a partial date that cannot be parsed (YYYY-MM only). + */ + public function test_validate_since_rejects_partial_date(): void { + $this->load_class(); + $this->assertFalse( ODW_Rest_API::validate_since_param( '2024-06' ) ); + } + + // ------------------------------------------------------------------------- + // get_delta() — cache hit + // ------------------------------------------------------------------------- + + /** + * Returns cached body and correct headers on a transient cache hit. + */ + public function test_get_delta_returns_cached_response_on_hit(): void { + $this->load_class(); + + $cached_body = array( + '@type' => 'odw:DeltaCatalog', + 'odw:since' => '2024-01-01', + 'odw:totalModified' => 1, + 'odw:totalRemoved' => 0, + 'dcat:dataset' => array( array( '@type' => 'dcat:Dataset' ) ), + 'odw:removed' => array(), + ); + + \WP_Mock::userFunction( 'get_transient' ) + ->andReturn( + array( + 'body' => $cached_body, + 'total' => 1, + 'pages' => 1, + 'generated_at' => '2026-04-22T10:00:00+00:00', + ) + ); + + $request = $this->make_request( + array( + 'since' => '2024-01-01', + 'page' => 1, + 'per_page' => 20, + 'format' => 'jsonld', + ) + ); + + $response = ODW_Rest_API::get_delta( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->status ); + $this->assertSame( $cached_body, $response->data ); + $this->assertSame( 'HIT', $response->headers['X-ODW-Cache'] ); + $this->assertSame( '2024-01-01', $response->headers['X-ODW-Delta-Since'] ); + } + + // ------------------------------------------------------------------------- + // get_delta() — cache miss, no results + // ------------------------------------------------------------------------- + + /** + * Returns an empty delta body with zero counts when no datasets match the window. + */ + public function test_get_delta_returns_empty_body_when_nothing_modified(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'set_transient' )->andReturn( true ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + // First WP_Query: published (empty); second: trashed (empty). + WP_Query::$mock_queue = array( + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + ); + + $request = $this->make_request( + array( + 'since' => '2025-01-01', + 'page' => 1, + 'per_page' => 20, + 'format' => 'jsonld', + ) + ); + + $response = ODW_Rest_API::get_delta( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->status ); + + $body = $response->data; + $this->assertSame( 'odw:DeltaCatalog', $body['@type'] ); + $this->assertSame( '2025-01-01', $body['odw:since'] ); + $this->assertSame( 0, $body['odw:totalModified'] ); + $this->assertSame( 0, $body['odw:totalRemoved'] ); + $this->assertSame( array(), $body['dcat:dataset'] ); + $this->assertSame( array(), $body['odw:removed'] ); + $this->assertSame( 'MISS', $response->headers['X-ODW-Cache'] ); + } + + // ------------------------------------------------------------------------- + // get_delta() — cache miss, with results + // ------------------------------------------------------------------------- + + /** + * Includes full JSON-LD for each modified dataset in dcat:dataset. + */ + public function test_get_delta_includes_modified_datasets(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'set_transient' )->andReturn( true ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $post = new \stdClass(); + $post->ID = 42; + $post->post_type = 'odw_dataset'; + $post->post_status = 'publish'; + $post->post_title = 'My Dataset'; + + WP_Query::$mock_queue = array( + array( + 'posts' => array( $post ), + 'found_posts' => 1, + 'max_num_pages' => 1, + ), + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + ); + + // Stub functions needed by odw_build_dataset_jsonld(). + \WP_Mock::userFunction( 'get_post' )->with( 42 )->andReturn( $post ); + \WP_Mock::userFunction( 'carbon_get_post_meta' )->andReturn( '' ); + \WP_Mock::userFunction( 'get_post_meta' )->andReturn( '' ); + \WP_Mock::userFunction( 'rest_url' )->andReturnUsing( fn( $p ) => 'http://localhost/wp-json/' . $p ); + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + + $request = $this->make_request( + array( + 'since' => '2025-01-01', + 'page' => 1, + 'per_page' => 20, + 'format' => 'jsonld', + ) + ); + + $response = ODW_Rest_API::get_delta( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $body = $response->data; + $this->assertSame( 1, $body['odw:totalModified'] ); + $this->assertCount( 1, $body['dcat:dataset'] ); + $this->assertSame( 'dcat:Dataset', $body['dcat:dataset'][0]['@type'] ); + } + + /** + * Includes tombstone entries in odw:removed for trashed datasets. + */ + public function test_get_delta_includes_removed_tombstones(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'set_transient' )->andReturn( true ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + + $trashed = new \stdClass(); + $trashed->ID = 99; + $trashed->post_type = 'odw_dataset'; + $trashed->post_status = 'trash'; + $trashed->post_modified_gmt = '2025-06-01 08:00:00'; + + WP_Query::$mock_queue = array( + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + array( + 'posts' => array( $trashed ), + 'found_posts' => 1, + 'max_num_pages' => 1, + ), + ); + + \WP_Mock::userFunction( 'rest_url' )->andReturnUsing( fn( $p ) => 'http://localhost/wp-json/' . $p ); + + $request = $this->make_request( + array( + 'since' => '2025-01-01', + 'page' => 1, + 'per_page' => 20, + 'format' => 'jsonld', + ) + ); + + $response = ODW_Rest_API::get_delta( $request ); + $body = $response->data; + + $this->assertSame( 1, $body['odw:totalRemoved'] ); + $this->assertCount( 1, $body['odw:removed'] ); + + $tombstone = $body['odw:removed'][0]; + $this->assertSame( 'dcat:Dataset', $tombstone['@type'] ); + $this->assertStringContainsString( '99', $tombstone['@id'] ); + $this->assertArrayHasKey( 'odw:removedAt', $tombstone ); + } + + // ------------------------------------------------------------------------- + // get_delta() — response headers + // ------------------------------------------------------------------------- + + /** + * Sets the correct pagination and delta-specific response headers. + */ + public function test_get_delta_sets_expected_response_headers(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'set_transient' )->andReturn( true ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( 'rest_url' )->andReturnArg( 0 ); + + WP_Query::$mock_queue = array( + array( + 'posts' => array(), + 'found_posts' => 3, + 'max_num_pages' => 2, + ), + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + ); + + $request = $this->make_request( + array( + 'since' => '2024-03-01T00:00:00Z', + 'page' => 1, + 'per_page' => 2, + 'format' => 'jsonld', + ) + ); + + $response = ODW_Rest_API::get_delta( $request ); + + $this->assertSame( '3', $response->headers['X-WP-Total'] ); + $this->assertSame( '2', $response->headers['X-WP-TotalPages'] ); + $this->assertSame( '2024-03-01T00:00:00Z', $response->headers['X-ODW-Delta-Since'] ); + $this->assertArrayHasKey( 'X-ODW-Generated-At', $response->headers ); + $this->assertSame( 'MISS', $response->headers['X-ODW-Cache'] ); + } + + // ------------------------------------------------------------------------- + // get_delta() — response body structure + // ------------------------------------------------------------------------- + + /** + * The response body contains all required top-level JSON-LD keys. + */ + public function test_get_delta_body_has_required_keys(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'set_transient' )->andReturn( true ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( 'rest_url' )->andReturnArg( 0 ); + + WP_Query::$mock_queue = array( + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + array( + 'posts' => array(), + 'found_posts' => 0, + 'max_num_pages' => 0, + ), + ); + + $request = $this->make_request( + array( + 'since' => '2024-01-01', + 'page' => 1, + 'per_page' => 20, + 'format' => 'jsonld', + ) + ); + + $body = ODW_Rest_API::get_delta( $request )->data; + + $this->assertArrayHasKey( '@context', $body ); + $this->assertArrayHasKey( '@type', $body ); + $this->assertArrayHasKey( 'dct:issued', $body ); + $this->assertArrayHasKey( 'odw:since', $body ); + $this->assertArrayHasKey( 'odw:totalModified', $body ); + $this->assertArrayHasKey( 'odw:totalRemoved', $body ); + $this->assertArrayHasKey( 'dcat:dataset', $body ); + $this->assertArrayHasKey( 'odw:removed', $body ); + } +} diff --git a/tests/test-settings.php b/tests/test-settings.php index db37401..beb3755 100644 --- a/tests/test-settings.php +++ b/tests/test-settings.php @@ -9,107 +9,142 @@ use PHPUnit\Framework\TestCase; +/** + * Unit tests for ODW_Settings. + * + * @package OpenDataWizard + */ class Test_ODW_Settings extends TestCase { - protected function setUp(): void { - \WP_Mock::setUp(); - } - - protected function tearDown(): void { - \WP_Mock::tearDown(); - } - - private function load_class(): void { - if ( ! class_exists( 'ODW_Settings' ) ) { - require_once ODW_PLUGIN_DIR . 'includes/class-settings.php'; - } - } - - public function test_get_returns_defaults_when_option_empty(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [] ); - - $settings = ODW_Settings::get(); - - $this->assertIsArray( $settings ); - $this->assertSame( '', $settings['catalog_title'] ); - $this->assertSame( '', $settings['default_license'] ); - $this->assertSame( '', $settings['default_language'] ); - $this->assertSame( 300, $settings['cache_ttl'] ); - $this->assertSame( '0', $settings['delete_on_uninstall'] ); - } - - public function test_get_single_key_returns_stored_value(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [ 'catalog_title' => 'Mein Datenkatalog' ] ); - - $this->assertSame( 'Mein Datenkatalog', ODW_Settings::get( 'catalog_title' ) ); - } - - public function test_get_single_key_returns_null_for_unknown_key(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [] ); - - $this->assertNull( ODW_Settings::get( 'non_existent_key' ) ); - } - - public function test_get_merges_stored_with_defaults(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [ 'cache_ttl' => 600 ] ); - - $settings = ODW_Settings::get(); - - // Gespeicherter Wert überschreibt Default. - $this->assertSame( 600, $settings['cache_ttl'] ); - // Andere Defaults bleiben. - $this->assertSame( '', $settings['catalog_title'] ); - } - - public function test_filter_catalog_title_returns_custom_when_set(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [ 'catalog_title' => 'Mein Katalog' ] ); - - $result = ODW_Settings::filter_catalog_title( 'Standard-Katalog' ); - - $this->assertSame( 'Mein Katalog', $result ); - } - - public function test_filter_catalog_title_returns_default_when_empty(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [] ); - - $result = ODW_Settings::filter_catalog_title( 'Standard-Katalog' ); - - $this->assertSame( 'Standard-Katalog', $result ); - } - - public function test_filter_catalog_title_ignores_whitespace_only(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'get_option' ) - ->with( ODW_Settings::OPTION_KEY, [] ) - ->andReturn( [ 'catalog_title' => ' ' ] ); - - $result = ODW_Settings::filter_catalog_title( 'Fallback' ); + /** + * Set up WP_Mock before each test. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + } + + /** + * Tear down WP_Mock after each test. + */ + protected function tearDown(): void { + \WP_Mock::tearDown(); + } + + /** + * Loads ODW_Settings once per test run. + */ + private function load_class(): void { + if ( ! class_exists( 'ODW_Settings' ) ) { + require_once ODW_PLUGIN_DIR . 'includes/class-settings.php'; + } + } + + /** + * When the option is empty, get() returns the default values. + */ + public function test_get_returns_defaults_when_option_empty(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array() ); + + $settings = ODW_Settings::get(); + + $this->assertIsArray( $settings ); + $this->assertSame( '', $settings['catalog_title'] ); + $this->assertSame( '', $settings['default_license'] ); + $this->assertSame( '', $settings['default_language'] ); + $this->assertSame( 300, $settings['cache_ttl'] ); + $this->assertSame( '0', $settings['delete_on_uninstall'] ); + } + + /** + * A stored value is returned when get() is called with a specific key. + */ + public function test_get_single_key_returns_stored_value(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array( 'catalog_title' => 'Mein Datenkatalog' ) ); + + $this->assertSame( 'Mein Datenkatalog', ODW_Settings::get( 'catalog_title' ) ); + } + + /** + * An unknown key returns null from get(). + */ + public function test_get_single_key_returns_null_for_unknown_key(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array() ); + + $this->assertNull( ODW_Settings::get( 'non_existent_key' ) ); + } + + /** + * Stored values are merged with defaults, overriding them where set. + */ + public function test_get_merges_stored_with_defaults(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array( 'cache_ttl' => 600 ) ); + + $settings = ODW_Settings::get(); + + // Gespeicherter Wert überschreibt Default. + $this->assertSame( 600, $settings['cache_ttl'] ); + // Andere Defaults bleiben. + $this->assertSame( '', $settings['catalog_title'] ); + } + + /** + * Returns the custom title when one is stored in settings. + */ + public function test_filter_catalog_title_returns_custom_when_set(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array( 'catalog_title' => 'Mein Katalog' ) ); + + $result = ODW_Settings::filter_catalog_title( 'Standard-Katalog' ); + + $this->assertSame( 'Mein Katalog', $result ); + } + + /** + * Returns the default title when no custom title is stored. + */ + public function test_filter_catalog_title_returns_default_when_empty(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array() ); + + $result = ODW_Settings::filter_catalog_title( 'Standard-Katalog' ); + + $this->assertSame( 'Standard-Katalog', $result ); + } + + /** + * A whitespace-only catalog title is treated as empty, returning the default. + */ + public function test_filter_catalog_title_ignores_whitespace_only(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_option' ) + ->with( ODW_Settings::OPTION_KEY, array() ) + ->andReturn( array( 'catalog_title' => ' ' ) ); + + $result = ODW_Settings::filter_catalog_title( 'Fallback' ); - $this->assertSame( 'Fallback', $result ); - } + $this->assertSame( 'Fallback', $result ); + } } diff --git a/tests/test-shortcode.php b/tests/test-shortcode.php index 16744e5..3dfc086 100644 --- a/tests/test-shortcode.php +++ b/tests/test-shortcode.php @@ -9,159 +9,206 @@ use PHPUnit\Framework\TestCase; +/** + * Unit tests for ODW_Shortcode. + * + * @package OpenDataWizard + */ class Test_ODW_Shortcode extends TestCase { - protected function setUp(): void { - \WP_Mock::setUp(); - } - - protected function tearDown(): void { - \WP_Mock::tearDown(); - } - - private function load_class(): void { - if ( ! class_exists( 'ODW_Fields' ) ) { - \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); - \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); - require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; - } - - if ( ! class_exists( 'ODW_Quality' ) ) { - require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; - } - - if ( ! class_exists( 'ODW_Shortcode' ) ) { - require_once ODW_PLUGIN_DIR . 'includes/class-shortcode.php'; - } - } - - // ------------------------------------------------------------------------- - // format_bytes() — via Reflection (private method) - // ------------------------------------------------------------------------- - - private function call_format_bytes( int $bytes ): string { - $ref = new \ReflectionClass( 'ODW_Shortcode' ); - $method = $ref->getMethod( 'format_bytes' ); - $method->setAccessible( true ); - return (string) $method->invoke( null, $bytes ); - } - - public function test_format_bytes_below_1kb(): void { - $this->load_class(); - $this->assertSame( '512 B', $this->call_format_bytes( 512 ) ); - } - - public function test_format_bytes_zero(): void { - $this->load_class(); - $this->assertSame( '0 B', $this->call_format_bytes( 0 ) ); - } - - public function test_format_bytes_exactly_1kb(): void { - $this->load_class(); - $this->assertSame( '1 KB', $this->call_format_bytes( 1024 ) ); - } - - public function test_format_bytes_kb_range(): void { - $this->load_class(); - // 2048 bytes = 2.0 KB - $this->assertSame( '2 KB', $this->call_format_bytes( 2048 ) ); - } - - public function test_format_bytes_mb_range(): void { - $this->load_class(); - // 1.5 MB = 1573888 bytes - $this->assertSame( '1.5 MB', $this->call_format_bytes( 1_048_576 + 524_288 ) ); - } - - public function test_format_bytes_gb_range(): void { - $this->load_class(); - // 2 GB - $this->assertSame( '2 GB', $this->call_format_bytes( 2 * 1_073_741_824 ) ); - } - - // ------------------------------------------------------------------------- - // render() — edge cases without valid post - // ------------------------------------------------------------------------- - - public function test_render_returns_empty_when_id_is_zero(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'shortcode_atts' ) - ->with( [ 'id' => '0' ], [], 'odw_dataset' ) - ->andReturn( [ 'id' => '0' ] ); - - \WP_Mock::userFunction( 'absint' ) - ->with( '0' ) - ->andReturn( 0 ); - - $result = ODW_Shortcode::render( [] ); - $this->assertSame( '', $result ); - } - - public function test_render_returns_empty_when_post_not_found(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'shortcode_atts' ) - ->with( [ 'id' => '0' ], [ 'id' => '99' ], 'odw_dataset' ) - ->andReturn( [ 'id' => '99' ] ); - - \WP_Mock::userFunction( 'absint' ) - ->with( '99' ) - ->andReturn( 99 ); - - \WP_Mock::userFunction( 'get_post' ) - ->with( 99 ) - ->andReturn( null ); - - $result = ODW_Shortcode::render( [ 'id' => '99' ] ); - $this->assertSame( '', $result ); - } - - public function test_render_returns_empty_when_wrong_post_type(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'shortcode_atts' ) - ->with( [ 'id' => '0' ], [ 'id' => '5' ], 'odw_dataset' ) - ->andReturn( [ 'id' => '5' ] ); - - \WP_Mock::userFunction( 'absint' ) - ->with( '5' ) - ->andReturn( 5 ); - - $post = new \stdClass(); - $post->ID = 5; - $post->post_type = 'post'; - $post->post_status = 'publish'; - - \WP_Mock::userFunction( 'get_post' ) - ->with( 5 ) - ->andReturn( $post ); - - $result = ODW_Shortcode::render( [ 'id' => '5' ] ); - $this->assertSame( '', $result ); - } - - public function test_render_returns_empty_when_post_not_published(): void { - $this->load_class(); - - \WP_Mock::userFunction( 'shortcode_atts' ) - ->with( [ 'id' => '0' ], [ 'id' => '6' ], 'odw_dataset' ) - ->andReturn( [ 'id' => '6' ] ); - - \WP_Mock::userFunction( 'absint' ) - ->with( '6' ) - ->andReturn( 6 ); - - $post = new \stdClass(); - $post->ID = 6; - $post->post_type = 'odw_dataset'; - $post->post_status = 'draft'; - - \WP_Mock::userFunction( 'get_post' ) - ->with( 6 ) - ->andReturn( $post ); - - $result = ODW_Shortcode::render( [ 'id' => '6' ] ); - $this->assertSame( '', $result ); - } + /** + * Set up WP_Mock before each test. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + } + + /** + * Tear down WP_Mock after each test. + */ + protected function tearDown(): void { + \WP_Mock::tearDown(); + } + + /** + * Loads ODW_Shortcode and its dependencies once per test run. + */ + private function load_class(): void { + if ( ! class_exists( 'ODW_Fields' ) ) { + \WP_Mock::userFunction( 'apply_filters' )->andReturnArg( 1 ); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + } + + if ( ! class_exists( 'ODW_Quality' ) ) { + require_once ODW_PLUGIN_DIR . 'includes/class-quality.php'; + } + + if ( ! class_exists( 'ODW_Shortcode' ) ) { + require_once ODW_PLUGIN_DIR . 'includes/class-shortcode.php'; + } + } + + // ------------------------------------------------------------------------- + // format_bytes() — via Reflection (private method) + // ------------------------------------------------------------------------- + + /** + * Invokes the private format_bytes() method via ReflectionClass. + * + * @param int $bytes Byte count. + * @return string Formatted size string. + */ + private function call_format_bytes( int $bytes ): string { + $ref = new \ReflectionClass( 'ODW_Shortcode' ); + $method = $ref->getMethod( 'format_bytes' ); + $method->setAccessible( true ); + return (string) $method->invoke( null, $bytes ); + } + + /** + * Values below 1 KB are shown as bytes. + */ + public function test_format_bytes_below_1kb(): void { + $this->load_class(); + $this->assertSame( '512 B', $this->call_format_bytes( 512 ) ); + } + + /** + * Zero bytes produces "0 B". + */ + public function test_format_bytes_zero(): void { + $this->load_class(); + $this->assertSame( '0 B', $this->call_format_bytes( 0 ) ); + } + + /** + * Exactly 1024 bytes formats to "1 KB". + */ + public function test_format_bytes_exactly_1kb(): void { + $this->load_class(); + $this->assertSame( '1 KB', $this->call_format_bytes( 1024 ) ); + } + + /** + * 2048 bytes formats to "2 KB". + */ + public function test_format_bytes_kb_range(): void { + $this->load_class(); + $this->assertSame( '2 KB', $this->call_format_bytes( 2048 ) ); + } + + /** + * 1.5 MB formats correctly. + */ + public function test_format_bytes_mb_range(): void { + $this->load_class(); + $this->assertSame( '1.5 MB', $this->call_format_bytes( 1_048_576 + 524_288 ) ); + } + + /** + * 2 GB formats correctly. + */ + public function test_format_bytes_gb_range(): void { + $this->load_class(); + $this->assertSame( '2 GB', $this->call_format_bytes( 2 * 1_073_741_824 ) ); + } + + // ------------------------------------------------------------------------- + // render() — edge cases without valid post + // ------------------------------------------------------------------------- + + /** + * A zero ID causes render() to return empty string immediately. + */ + public function test_render_returns_empty_when_id_is_zero(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'shortcode_atts' ) + ->with( array( 'id' => '0' ), array(), 'odw_dataset' ) + ->andReturn( array( 'id' => '0' ) ); + + \WP_Mock::userFunction( 'absint' ) + ->with( '0' ) + ->andReturn( 0 ); + + $result = ODW_Shortcode::render( array() ); + $this->assertSame( '', $result ); + } + + /** + * A non-existent post causes render() to return empty string. + */ + public function test_render_returns_empty_when_post_not_found(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'shortcode_atts' ) + ->with( array( 'id' => '0' ), array( 'id' => '99' ), 'odw_dataset' ) + ->andReturn( array( 'id' => '99' ) ); + + \WP_Mock::userFunction( 'absint' ) + ->with( '99' ) + ->andReturn( 99 ); + + \WP_Mock::userFunction( 'get_post' ) + ->with( 99 ) + ->andReturn( null ); + + $result = ODW_Shortcode::render( array( 'id' => '99' ) ); + $this->assertSame( '', $result ); + } + + /** + * A post with a different post_type causes render() to return empty string. + */ + public function test_render_returns_empty_when_wrong_post_type(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'shortcode_atts' ) + ->with( array( 'id' => '0' ), array( 'id' => '5' ), 'odw_dataset' ) + ->andReturn( array( 'id' => '5' ) ); + + \WP_Mock::userFunction( 'absint' ) + ->with( '5' ) + ->andReturn( 5 ); + + $post = new \stdClass(); + $post->ID = 5; + $post->post_type = 'post'; + $post->post_status = 'publish'; + + \WP_Mock::userFunction( 'get_post' ) + ->with( 5 ) + ->andReturn( $post ); + + $result = ODW_Shortcode::render( array( 'id' => '5' ) ); + $this->assertSame( '', $result ); + } + + /** + * A draft post causes render() to return empty string. + */ + public function test_render_returns_empty_when_post_not_published(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'shortcode_atts' ) + ->with( array( 'id' => '0' ), array( 'id' => '6' ), 'odw_dataset' ) + ->andReturn( array( 'id' => '6' ) ); + + \WP_Mock::userFunction( 'absint' ) + ->with( '6' ) + ->andReturn( 6 ); + + $post = new \stdClass(); + $post->ID = 6; + $post->post_type = 'odw_dataset'; + $post->post_status = 'draft'; + + \WP_Mock::userFunction( 'get_post' ) + ->with( 6 ) + ->andReturn( $post ); + + $result = ODW_Shortcode::render( array( 'id' => '6' ) ); + $this->assertSame( '', $result ); + } } diff --git a/uninstall.php b/uninstall.php index 9bfee42..e20be9f 100644 --- a/uninstall.php +++ b/uninstall.php @@ -9,34 +9,36 @@ */ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { - exit; + exit; } // Nur löschen wenn die Option gesetzt ist (Opt-in Datenlöschung). -$odw_settings = (array) get_option( 'odw_settings', [] ); +$odw_settings = (array) get_option( 'odw_settings', array() ); if ( empty( $odw_settings['delete_on_uninstall'] ) ) { - return; + return; } // Capabilities entfernen. -$roles = [ 'administrator', 'editor' ]; -foreach ( $roles as $role_name ) { - $role = get_role( $role_name ); - if ( $role ) { - $role->remove_cap( 'manage_open_data' ); - } +$odw_roles = array( 'administrator', 'editor' ); +foreach ( $odw_roles as $odw_role_name ) { + $odw_role = get_role( $odw_role_name ); + if ( $odw_role ) { + $odw_role->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', -] ); +$odw_post_ids = get_posts( + array( + '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 ); +foreach ( $odw_post_ids as $odw_pid ) { + wp_delete_post( (int) $odw_pid, true ); } // Plugin-Optionen löschen. @@ -48,15 +50,15 @@ 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_%' - ) + $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_%' - ) + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + '_transient_timeout_odw_%' + ) ); diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 1f6fdfd..f9263c0 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -209,32 +209,33 @@ }, { "name": "doctrine/instantiator", - "version": "2.1.0", - "version_normalized": "2.1.0.0", + "version": "2.0.0", + "version_normalized": "2.0.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, - "time": "2026-01-05T06:47:08+00:00", + "time": "2022-12-30T00:23:10+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -261,7 +262,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index aff3429..ac6f1b6 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'daimpad/open-data-wizard', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '37541a5f11b6105f39e324b87b496d58e8b0b919', + 'reference' => '57c2a6c6c71058ad2d982a84e035b3a58dd29c0e', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -37,7 +37,7 @@ 'daimpad/open-data-wizard' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '37541a5f11b6105f39e324b87b496d58e8b0b919', + 'reference' => '57c2a6c6c71058ad2d982a84e035b3a58dd29c0e', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -59,9 +59,9 @@ 'dev_requirement' => true, ), 'doctrine/instantiator' => array( - 'pretty_version' => '2.1.0', - 'version' => '2.1.0.0', - 'reference' => '23da848e1a2308728fe5fdddabf4be17ff9720c7', + 'pretty_version' => '2.0.0', + 'version' => '2.0.0.0', + 'reference' => 'c6222283fa3f4ac679f8b9ced9a4e23f163e80d0', 'type' => 'library', 'install_path' => __DIR__ . '/../doctrine/instantiator', 'aliases' => array(), diff --git a/vendor/doctrine/instantiator/.doctrine-project.json b/vendor/doctrine/instantiator/.doctrine-project.json new file mode 100644 index 0000000..24ae36e --- /dev/null +++ b/vendor/doctrine/instantiator/.doctrine-project.json @@ -0,0 +1,47 @@ +{ + "active": true, + "name": "Instantiator", + "slug": "instantiator", + "docsSlug": "doctrine-instantiator", + "codePath": "/src", + "versions": [ + { + "name": "1.5", + "branchName": "1.5.x", + "slug": "latest", + "upcoming": true + }, + { + "name": "1.4", + "branchName": "1.4.x", + "slug": "1.4", + "aliases": [ + "current", + "stable" + ], + "maintained": true, + "current": true + }, + { + "name": "1.3", + "branchName": "1.3.x", + "slug": "1.3", + "maintained": false + }, + { + "name": "1.2", + "branchName": "1.2.x", + "slug": "1.2" + }, + { + "name": "1.1", + "branchName": "1.1.x", + "slug": "1.1" + }, + { + "name": "1.0", + "branchName": "1.0.x", + "slug": "1.0" + } + ] +} diff --git a/vendor/doctrine/instantiator/CONTRIBUTING.md b/vendor/doctrine/instantiator/CONTRIBUTING.md new file mode 100644 index 0000000..c1a2c42 --- /dev/null +++ b/vendor/doctrine/instantiator/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + + * Follow the [Doctrine Coding Standard](https://github.com/doctrine/coding-standard) + * The project will follow strict [object calisthenics](http://www.slideshare.net/guilhermeblanco/object-calisthenics-applied-to-php) + * Any contribution must provide tests for additional introduced conditions + * Any un-confirmed issue needs a failing test case before being accepted + * Pull requests must be sent from a new hotfix/feature branch, not from `master`. + +## Installation + +To install the project and run the tests, you need to clone it first: + +```sh +$ git clone git://github.com/doctrine/instantiator.git +``` + +You will then need to run a composer installation: + +```sh +$ cd Instantiator +$ curl -s https://getcomposer.org/installer | php +$ php composer.phar update +``` + +## Testing + +The PHPUnit version to be used is the one installed as a dev- dependency via composer: + +```sh +$ ./vendor/bin/phpunit +``` + +Accepted coverage for new contributions is 80%. Any contribution not satisfying this requirement +won't be merged. + diff --git a/vendor/doctrine/instantiator/README.md b/vendor/doctrine/instantiator/README.md index d4bfaec..1fa9567 100644 --- a/vendor/doctrine/instantiator/README.md +++ b/vendor/doctrine/instantiator/README.md @@ -1,4 +1,4 @@ -# Doctrine Instantiator +# Instantiator This library provides a way of avoiding usage of constructors when instantiating PHP classes. diff --git a/vendor/doctrine/instantiator/composer.json b/vendor/doctrine/instantiator/composer.json index 2fc7724..179145e 100644 --- a/vendor/doctrine/instantiator/composer.json +++ b/vendor/doctrine/instantiator/composer.json @@ -16,16 +16,17 @@ } ], "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { "ext-phar": "*", "ext-pdo": "*", - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^11", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "autoload": { "psr-4": { diff --git a/vendor/doctrine/instantiator/docs/en/index.rst b/vendor/doctrine/instantiator/docs/en/index.rst new file mode 100644 index 0000000..0c85da0 --- /dev/null +++ b/vendor/doctrine/instantiator/docs/en/index.rst @@ -0,0 +1,68 @@ +Introduction +============ + +This library provides a way of avoiding usage of constructors when instantiating PHP classes. + +Installation +============ + +The suggested installation method is via `composer`_: + +.. code-block:: console + + $ composer require doctrine/instantiator + +Usage +===== + +The instantiator is able to create new instances of any class without +using the constructor or any API of the class itself: + +.. code-block:: php + + instantiate(User::class); + +Contributing +============ + +- Follow the `Doctrine Coding Standard`_ +- The project will follow strict `object calisthenics`_ +- Any contribution must provide tests for additional introduced + conditions +- Any un-confirmed issue needs a failing test case before being + accepted +- Pull requests must be sent from a new hotfix/feature branch, not from + ``master``. + +Testing +======= + +The PHPUnit version to be used is the one installed as a dev- dependency +via composer: + +.. code-block:: console + + $ ./vendor/bin/phpunit + +Accepted coverage for new contributions is 80%. Any contribution not +satisfying this requirement won’t be merged. + +Credits +======= + +This library was migrated from `ocramius/instantiator`_, which has been +donated to the doctrine organization, and which is now deprecated in +favour of this package. + +.. _composer: https://getcomposer.org/ +.. _CONTRIBUTING.md: CONTRIBUTING.md +.. _ocramius/instantiator: https://github.com/Ocramius/Instantiator +.. _Doctrine Coding Standard: https://github.com/doctrine/coding-standard +.. _object calisthenics: http://www.slideshare.net/guilhermeblanco/object-calisthenics-applied-to-php diff --git a/vendor/doctrine/instantiator/docs/en/sidebar.rst b/vendor/doctrine/instantiator/docs/en/sidebar.rst new file mode 100644 index 0000000..0c36479 --- /dev/null +++ b/vendor/doctrine/instantiator/docs/en/sidebar.rst @@ -0,0 +1,4 @@ +.. toctree:: + :depth: 3 + + index diff --git a/vendor/doctrine/instantiator/psalm.xml b/vendor/doctrine/instantiator/psalm.xml new file mode 100644 index 0000000..e9b622b --- /dev/null +++ b/vendor/doctrine/instantiator/psalm.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/vendor/doctrine/instantiator/src/Doctrine/Instantiator/Instantiator.php b/vendor/doctrine/instantiator/src/Doctrine/Instantiator/Instantiator.php index 0642d44..f803f89 100644 --- a/vendor/doctrine/instantiator/src/Doctrine/Instantiator/Instantiator.php +++ b/vendor/doctrine/instantiator/src/Doctrine/Instantiator/Instantiator.php @@ -28,14 +28,16 @@ final class Instantiator implements InstantiatorInterface * Markers used internally by PHP to define whether {@see \unserialize} should invoke * the method {@see \Serializable::unserialize()} when dealing with classes implementing * the {@see \Serializable} interface. + * + * @deprecated This constant will be private in 2.0 */ - private const string SERIALIZATION_FORMAT_USE_UNSERIALIZER = 'C'; - private const string SERIALIZATION_FORMAT_AVOID_UNSERIALIZER = 'O'; + private const SERIALIZATION_FORMAT_USE_UNSERIALIZER = 'C'; + private const SERIALIZATION_FORMAT_AVOID_UNSERIALIZER = 'O'; /** * Used to instantiate specific classes, indexed by class name. * - * @var array + * @var callable[] */ private static array $cachedInstantiators = [];