diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d8433..78ebf93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht --- +## [2.47.5] — 2026-11-06 + +### 🐛 Fixed +- **Carbon-Fields-Bedienelemente auf Deutsch.** Im Formular standen auch in einem deutschen + WordPress englische Texte von Carbon Fields: „There are no entries yet.“ und + „Add Distribution“ im Wiederholungsfeld „Weitere Distributionen“ (ebenso bei den + Übersetzungs-Wiederholungsfeldern in Tab 2), „Select Date“ am Datumsfeld, „No options.“, + „Collapse All“ u. a. Zwei Ursachen: + - Carbon Fields 3.6.11 bringt für seine Oberflächen-Domain `carbon-fields-ui` keine deutsche + Datei mit. Das Plugin liefert sie jetzt selbst aus + (`languages/carbon-fields-ui-de_DE.po`/`.mo`, Anrede „Sie“), nicht in `vendor/`. + - Carbon Fields gibt seine UI-Übersetzungen nicht über `wp_set_script_translations()` weiter, + sondern über `wp_localize_script()` als `cf.config.locale`, das die Oberfläche an + `setLocaleData()` übergibt. Seit WordPress 6.5 liefert + `get_translations_for_domain()->entries` dafür eine Liste statt einer nach msgid + geschlüsselten Tabelle; Carbon Fields übernimmt die Listenindizes als msgids, und keine + Übersetzung erreicht die Oberfläche — auch die mitgelieferten (z. B. fr_FR) nicht. Die neue + Klasse `ODW_Carbon_Fields_L10n` baut die Daten im Filter `carbon_fields_config` korrekt + geschlüsselt neu auf und lädt für alle deutschen Locales (de_DE, de_DE_formal, de_AT, de_CH …) + die gebündelte Datei. +- **Mehrfachauswahl (Themen, Engagementfelder):** Platzhalter „Select...“ und die Meldung + „No options“ im Aufklappmenü sind feste Vorgaben der Bibliothek react-select und laufen durch + keine Textdomain. `assets/js/odw-carbon-fields-l10n.js` ersetzt sie durch „Auswählen…“ und + „Keine passenden Optionen“ (über die Plugin-Domain; en_US erhält das englische Original). +- **„Datum wählen“ statt „Select Date“:** Der Button am Datumsfeld kommt jetzt regulär aus der + Übersetzung. Der bisherige Tausch in `odw-datepicker-de.js` suchte „Select Date“ als + Platzhalter des Eingabefelds — es ist aber die Beschriftung eines Buttons, der Tausch blieb + deshalb wirkungslos und ist entfallen. +- **Kalender auf englischen Installationen englisch.** Die Datumsfelder stellten flatpickr fest + auf `locale: 'de'`, und `odw-datepicker-de.js` lud die deutsche Sprachdatei für jede Locale — + auf en_US standen deutsche Monats- und Wochentagsnamen neben englischen Beschriftungen. Die + Kalendersprache folgt jetzt der Oberfläche (`ODW_Fields::date_picker_options()`: `de` bei + deutscher Locale, sonst `default`), und die Sprachdatei wird nur für deutsche Locales geladen. + +### 🧪 Tests +- `tests/test-carbon-fields-l10n.php`: Schlüsselbildung mit WP_Translations (nur `__get()`, + Liste) und älteren Übersetzungsobjekten, Laden nur bei deutscher Locale, gebündelte `.mo` + passend zur `.po`, Kalendersprache je Locale. +- E2E (`admin-workflow.spec.js`): Das deutsche Formular zeigt „Noch keine Einträge vorhanden.“, + „Distribution hinzufügen“, „Datum wählen“ und deutsche Wochentage im Kalender, aber keinen der + englischen Texte. + +--- + ## [2.47.4] — 2026-11-06 ### 🔧 Changed diff --git a/CLAUDE.md b/CLAUDE.md index 9f913f8..5b8a478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ open-data-wizard/ │ ├── class-samples.php # Beispieldaten laden/entfernen (_odw_sample), nie im Endpunkt │ ├── class-post-types.php # CPT: odw_dataset │ ├── class-fields.php # Carbon Fields Formular (5 Tabs) +│ ├── class-carbon-fields-l10n.php # Deutsche UI-Texte für Carbon Fields (Filter carbon_fields_config) │ ├── class-validation.php # Publishing-Validation │ ├── class-quality.php # Qualitäts-Scoring (0-100) │ ├── class-admin.php # Admin UI, Spalten, Intro-Seite @@ -113,7 +114,8 @@ open-data-wizard/ │ │ ├── odw-catalog.js # Katalog: Schlagwort-Aufklappfeld, Einklappen auf dem Handy │ │ ├── wizard-tabs.js # Tab-Navigation mit SessionStorage │ │ ├── odw-admin-fields.js # Field-Initialization, Autosuggest -│ │ └── odw-file-upload.js # File-Upload Handler (wp.media) +│ │ ├── odw-file-upload.js # File-Upload Handler (wp.media) +│ │ └── odw-carbon-fields-l10n.js # Feste react-select-Texte der Mehrfachauswahl („Select...") │ └── images/ # Icons, Logos ├── config/ │ ├── phpcs.xml # WordPress Coding Standards @@ -145,6 +147,7 @@ open-data-wizard/ │ ├── test-rest-delta.php # REST: Delta, Content Negotiation, Ausschlüsse (Beispiele, Passwort) │ ├── test-batch-import.php # Batch-Import: Parsing, Spalten, Vokabulare, Beispieldateien │ ├── test-samples.php # Beispieldaten: Laden/Entfernen/Übernehmen, Demo-Migration +│ ├── test-carbon-fields-l10n.php # Carbon-Fields-UI auf Deutsch: Locale-Daten, gebündelte .mo │ ├── test-cli.php # CLI Commands Tests │ ├── test-validation.php, test-rdf.php, test-vocabulary.php, test-registry-schema.php, │ │ test-field-catalog.php, test-shacl-shapes.php @@ -155,7 +158,8 @@ open-data-wizard/ ├── languages/ │ ├── open-data-wizard.pot # i18n Translation Template │ ├── open-data-wizard-en_US.po # Englische Übersetzung (Quelle) -│ └── open-data-wizard-en_US.mo # Englische Übersetzung (kompiliert) +│ ├── open-data-wizard-en_US.mo # Englische Übersetzung (kompiliert) +│ └── carbon-fields-ui-de_DE.po/.mo # Deutsche Texte der Carbon-Fields-Oberfläche ├── README.md # User-facing Dokumentation (DE) + Doku-Navigation ├── DOCUMENTATION.md # Technische Dokumentation (Architektur, REST-API, CLI …) ├── TECHNICAL-SPEC.md # Technische Spezifikationen + DCAT-AP-Roadmap/Umsetzungsstand @@ -194,6 +198,7 @@ Externe Harvester rufen /catalog, /datasets/, oder /delta ab | **ODW_Samples** | Beispieldaten aus `samples/import-example.json` laden/entfernen/übernehmen; Kennzeichen `_odw_sample` hält sie aus `/catalog`, `/datasets`, `/delta` heraus und im Frontend-Katalog nur für die Redaktion sichtbar (oder `show_demo="1"`) | `load()`, `remove()`, `adopt()`, `is_sample()`, `exclude_clause()` | | **ODW_Post_Types** | CPT Registrierung mit Capabilities | `register()` | | **ODW_Fields** | Carbon Fields Formular (5 Tabs) + JSON-LD Builder | `register_required_fields()`, `odw_build_dataset_jsonld()` | +| **ODW_Carbon_Fields_L10n** | Deutsche Texte der Carbon-Fields-Oberfläche (Domain `carbon-fields-ui`): lädt `languages/carbon-fields-ui-de_DE.mo` für alle `de*`-Locales und baut `cf.config.locale` nach msgid geschlüsselt neu auf (Carbon Fields scheitert daran seit WP 6.5) | `filter_config()`, `locale_data()`, `is_german_locale()` | | **ODW_Validation** | Publish-Blocking bei fehlenden Pflichtfeldern | `intercept_publish()`, `validate()` | | **ODW_Quality** | Qualitäts-Scoring & Caching | `calculate()`, `get_level()` | | **ODW_Admin** | Admin UI (Spalten, Intro-Seite, Datei-Upload-Widget in Tab 3) | `register_introduction_page()`, `render_column()`, `file_upload_html()`, `save_file_attachment()` | @@ -454,6 +459,17 @@ esc_attr__( 'Attribute Text', 'open-data-wizard' ) - **English**: `languages/open-data-wizard-en_US.po` / `.mo` — overrides the German source strings on `en_US` installs (rund 750 Einträge) - Loaded via `load_plugin_textdomain()` on `init` hook + +**Carbon-Fields-Oberfläche (`carbon-fields-ui`).** Bedienelemente wie „Add %s", „There are no +entries yet.", „Select Date" stammen aus Carbon Fields, nicht aus dem Plugin. Carbon Fields +bringt dafür kein Deutsch mit und reicht seine Übersetzungen nicht über +`wp_set_script_translations()`, sondern über den Filter `carbon_fields_config` +(`cf.config.locale` → `setLocaleData()`) an die Oberfläche. `ODW_Carbon_Fields_L10n` setzt dort +an; die Übersetzung liegt in `languages/carbon-fields-ui-de_DE.po` (nach Änderungen mit +`bin/compile-mo.py` neu kompilieren), **nie in `vendor/`**. Platzhalter „Select..." und +„No options" der Mehrfachauswahl sind feste react-select-Texte und werden von +`assets/js/odw-carbon-fields-l10n.js` getauscht. Nach einem Carbon-Fields-Update prüfen, ob neue +`__( '…', 'carbon-fields-ui' )`-Aufrufe dazugekommen sind. - The `.mo` is generated without gettext tooling; if strings change, regenerate the `.po`/`.mo` (see the generator approach in the v2.3.0 PR) @@ -828,7 +844,7 @@ Update **both** locations: - MINOR: New features (backward-compatible) - PATCH: Bug fixes only -Current: **v2.47.4** +Current: **v2.47.5** --- @@ -882,6 +898,8 @@ Neue technische Festlegungen gehören dorthin, nicht in README oder CLAUDE.md. - **Beispieldaten statt Demo-Datensatz (v2.47.0):** leerer Start, Laden/Entfernen per Klick oder WP-CLI, nie in einem Harvest-Endpunkt, im Frontend nur für die Redaktion. - **Nur öffentliche Datensätze in den Endpunkten, CI mit Leserechten (v2.47.1).** +- **Carbon-Fields-Bedienelemente auf Deutsch (v2.47.5):** gebündelte `carbon-fields-ui`-Übersetzung + und korrekt geschlüsselte Locale-Daten (siehe Internationalisierung). ### ☐ Noch offen / geplant @@ -981,6 +999,6 @@ Neue technische Festlegungen gehören dorthin, nicht in README oder CLAUDE.md. --- -**Zuletzt aktualisiert**: Version 2.47.4 (November 2026) +**Zuletzt aktualisiert**: Version 2.47.5 (November 2026) **Autor**: Open Data Wizard Team (nozilla) **License**: GPL-2.0-or-later diff --git a/README.md b/README.md index dea70ee..b6aa65b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Lizenz - Version + Version DCAT-AP 3.0 PHP >= 8.1 WordPress 6.4+ diff --git a/assets/js/odw-carbon-fields-l10n.js b/assets/js/odw-carbon-fields-l10n.js new file mode 100644 index 0000000..3f4703e --- /dev/null +++ b/assets/js/odw-carbon-fields-l10n.js @@ -0,0 +1,59 @@ +/* global odwCarbonFieldsL10n */ +/** + * Open Data Wizard — Texte der Mehrfachauswahl, die keine Übersetzung erreicht. + * + * Die Mehrfachauswahl von Carbon Fields (Themen, Engagementfelder) baut auf + * react-select auf. Dessen Platzhalter „Select..." und die Meldung + * „No options" im Aufklappmenü sind Vorgaben der Bibliothek; Carbon Fields + * reicht dafür keine Eigenschaft durch, und sie laufen durch keine Textdomain. + * Alle übrigen Carbon-Fields-Texte kommen regulär über die Übersetzung + * (siehe includes/class-carbon-fields-l10n.php) und werden hier nicht angefasst. + * + * Getauscht wird nur Text, der exakt dem englischen Original entspricht, und + * nur in den Elementen mit dem Klassenpräfix `cf-multiselect`, das Carbon + * Fields an react-select übergibt. React schreibt diesen Text nicht erneut, + * solange er sich nicht ändert; erscheint das Element neu (Menü öffnen, + * Auswahl leeren), fängt der MutationObserver es wieder ab. + */ +( function () { + 'use strict'; + + var map = ( typeof odwCarbonFieldsL10n !== 'undefined' && odwCarbonFieldsL10n.reactSelect ) || {}; + + var SELECTOR = '.cf-multiselect__placeholder, .cf-multiselect__menu-notice'; + + function localize( el ) { + var text = el.textContent; + if ( Object.prototype.hasOwnProperty.call( map, text ) && map[ text ] && map[ text ] !== text ) { + el.textContent = map[ text ]; + } + } + + function scan( root ) { + if ( ! root || root.nodeType !== 1 ) { + return; + } + if ( root.matches( SELECTOR ) ) { + localize( root ); + } + root.querySelectorAll( SELECTOR ).forEach( localize ); + } + + function start() { + if ( Object.keys( map ).length === 0 ) { + return; + } + scan( document.body ); + new MutationObserver( function ( mutations ) { + mutations.forEach( function ( mutation ) { + mutation.addedNodes.forEach( scan ); + } ); + } ).observe( document.body, { childList: true, subtree: true } ); + } + + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', start ); + } else { + start(); + } +} )(); diff --git a/assets/js/odw-datepicker-de.js b/assets/js/odw-datepicker-de.js index 6350993..e83d137 100644 --- a/assets/js/odw-datepicker-de.js +++ b/assets/js/odw-datepicker-de.js @@ -2,8 +2,14 @@ * Open Data Wizard — German localization for the Carbon Fields date picker (flatpickr). * * Registers the German locale on the global flatpickr instance and re-localizes - * any pickers that were already initialised, so month names, weekday names and - * the "Select Date" placeholder appear in German. + * any pickers that were already initialised, so month and weekday names appear + * in German. Enqueued for German locales only (ODW_Admin::enqueue_assets()); + * the date fields pick their calendar locale to match + * (ODW_Fields::date_picker_options()). + * + * The "Select Date" button is not handled here: it is a regular Carbon Fields + * UI string and comes from languages/carbon-fields-ui-de_DE.mo via + * ODW_Carbon_Fields_L10n. */ ( function () { 'use strict'; @@ -57,9 +63,6 @@ if ( input._flatpickr ) { input._flatpickr.set( 'locale', German ); } - if ( input.placeholder === 'Select Date' || input.placeholder === 'Select date' ) { - input.placeholder = 'Datum wählen…'; - } } ); } diff --git a/includes/class-admin.php b/includes/class-admin.php index cebac6a..8dfef8c 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -453,13 +453,33 @@ public static function enqueue_assets( string $hook ): void { ) ); + // Deutsche Monats- und Wochentagsnamen für den Kalender nur bei + // deutscher Oberfläche; die Datumsfelder wählen ihre Kalendersprache + // passend dazu (ODW_Fields::date_picker_options()). + if ( ODW_Carbon_Fields_L10n::is_german_locale() ) { + wp_enqueue_script( + 'odw-datepicker-de', + ODW_PLUGIN_URL . 'assets/js/odw-datepicker-de.js', + array(), + ODW_VERSION, + true + ); + } + + // Platzhalter und Leermeldung der Mehrfachauswahl (react-select). + // Die übrigen Carbon-Fields-Texte kommen über ODW_Carbon_Fields_L10n. wp_enqueue_script( - 'odw-datepicker-de', - ODW_PLUGIN_URL . 'assets/js/odw-datepicker-de.js', + 'odw-carbon-fields-l10n', + ODW_PLUGIN_URL . 'assets/js/odw-carbon-fields-l10n.js', array(), ODW_VERSION, true ); + wp_localize_script( + 'odw-carbon-fields-l10n', + 'odwCarbonFieldsL10n', + array( 'reactSelect' => ODW_Carbon_Fields_L10n::react_select_strings() ) + ); global $post; $file_id = $post ? (int) get_post_meta( $post->ID, '_odw_file_id', true ) : 0; diff --git a/includes/class-carbon-fields-l10n.php b/includes/class-carbon-fields-l10n.php new file mode 100644 index 0000000..3663159 --- /dev/null +++ b/includes/class-carbon-fields-l10n.php @@ -0,0 +1,241 @@ +entries aus und gibt sie per + * wp_localize_script() als `cf.config.locale` an die Oberfläche, die damit + * `setLocaleData()` aus @wordpress/i18n aufruft. Seit WordPress 6.5 liefert + * `->entries` aber eine Liste (WP_Translations::__get()), keine nach msgid + * geschlüsselte Tabelle. Carbon Fields übernimmt die Listenindizes 0, 1, 2 … + * als msgids — die Übersetzungen kommen in der Oberfläche nie an, auch die + * mitgelieferten nicht. + * + * Diese Klasse behebt beides an der Stelle, die Carbon Fields dafür vorsieht, + * dem Filter `carbon_fields_config`: Für deutsche Locales lädt sie die + * gebündelte Übersetzung `languages/carbon-fields-ui-de_DE.mo` in die Domain + * `carbon-fields-ui` und baut `cf.config.locale` danach neu auf, geschlüsselt + * nach msgid (samt Kontext). Nichts davon liegt in `vendor/`, das nicht + * eingecheckt ist und bei jedem `composer install` neu entsteht. + * + * Die Mehrfachauswahl zeigt zusätzlich zwei Texte, die keine Übersetzung + * erreichen kann: „Select..." und „No options" setzt die Bibliothek + * react-select als feste Vorgabe, Carbon Fields reicht dafür nichts durch. + * Diese beiden ersetzt assets/js/odw-carbon-fields-l10n.js im DOM; die + * Zielwörter kommen aus react_select_strings() und laufen damit durch die + * Textdomain des Plugins (en_US erhält wieder das englische Original). + * + * @package OpenDataWizard + */ + +declare(strict_types=1); + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Supplies German UI strings to the Carbon Fields JavaScript interface. + * + * @package OpenDataWizard + */ +class ODW_Carbon_Fields_L10n { + + /** Textdomain der Carbon-Fields-Oberfläche. */ + public const UI_DOMAIN = 'carbon-fields-ui'; + + /** + * Ob die deutsche Datei in diesem Request bereits geladen wurde. + * + * @var bool + */ + private static bool $german_loaded = false; + + /** + * Registers hooks. + */ + public static function init(): void { + add_filter( 'carbon_fields_config', array( self::class, 'filter_config' ) ); + } + + /** + * Ob eine Locale deutsch ist (de_DE, de_DE_formal, de_AT, de_CH, …). + * + * Alle deutschen Varianten erhalten dieselbe Datei: Das Plugin spricht + * durchgehend mit „Sie" an, eine eigene du-Fassung gibt es nicht. + * + * @param string|null $locale Locale; ohne Angabe die des aktuellen Requests. + */ + public static function is_german_locale( ?string $locale = null ): bool { + $locale = $locale ?? determine_locale(); + + return 'de' === $locale || 0 === strpos( $locale, 'de_' ); + } + + /** + * Absoluter Pfad der gebündelten deutschen Übersetzung. + */ + public static function german_mo_path(): string { + return ODW_PLUGIN_DIR . 'languages/carbon-fields-ui-de_DE.mo'; + } + + /** + * Filter `carbon_fields_config`: lädt bei deutscher Locale die gebündelte + * Übersetzung und baut `config.locale` nach msgid geschlüsselt neu auf. + * + * Carbon Fields ruft den Filter in Loader::enqueue_assets() auf + * (`admin_print_footer_scripts`), also nur auf Seiten mit seinen Feldern + * und erst, wenn die Locale feststeht. + * + * @param mixed $config Konfiguration, die Carbon Fields als `window.cf` ausgibt. + * @return mixed + */ + public static function filter_config( $config ) { + if ( ! is_array( $config ) || ! isset( $config['config'] ) || ! is_array( $config['config'] ) ) { + return $config; + } + + if ( self::is_german_locale() ) { + self::load_german(); + } + + $header = array(); + if ( isset( $config['config']['locale'][''] ) && is_array( $config['config']['locale'][''] ) ) { + $header = $config['config']['locale']['']; + } + + $config['config']['locale'] = self::locale_data( get_translations_for_domain( self::UI_DOMAIN ), $header ); + + return $config; + } + + /** + * Lädt die deutsche Übersetzung in die Domain `carbon-fields-ui`. + * + * Ergänzt eine bereits geladene Datei, statt sie zu ersetzen — sollte + * Carbon Fields künftig selbst eine deutsche Datei mitbringen, bleiben + * deren Einträge erhalten, wo diese Datei keine hat. + */ + public static function load_german(): bool { + if ( self::$german_loaded ) { + return true; + } + + $path = self::german_mo_path(); + if ( ! is_readable( $path ) ) { + return false; + } + + self::$german_loaded = (bool) load_textdomain( self::UI_DOMAIN, $path ); + + return self::$german_loaded; + } + + /** + * Baut die Locale-Daten für `setLocaleData()` (@wordpress/i18n). + * + * Format: `'' => [ domain, lang, plural_forms ]`, danach je Eintrag + * `msgid => [ Übersetzung, … ]`; mit Kontext lautet der Schlüssel + * `Kontext\u0004msgid`. Der Schlüssel wird aus `singular`/`context` des + * Eintrags gebildet und nicht aus dem Array-Index — das ist der Punkt, an + * dem Carbon Fields seit WordPress 6.5 scheitert. So funktioniert es mit + * der Liste aus WP_Translations ebenso wie mit der nach Schlüssel + * indizierten Tabelle älterer Versionen. + * + * @param object|null $translations Ergebnis von get_translations_for_domain(). + * @param array $header Kopfeintrag, den Carbon Fields gebildet hat. + * @return array + */ + public static function locale_data( $translations, array $header = array() ): array { + $header = array_merge( + array( + 'domain' => self::UI_DOMAIN, + 'lang' => determine_locale(), + ), + $header + ); + + $headers = self::read_array( $translations, 'headers' ); + if ( empty( $header['plural_forms'] ) && ! empty( $headers['Plural-Forms'] ) ) { + $header['plural_forms'] = (string) $headers['Plural-Forms']; + } + + $data = array( '' => $header ); + + $entries = self::read_array( $translations, 'entries' ); + + foreach ( $entries as $entry ) { + if ( ! is_object( $entry ) || ! isset( $entry->singular ) || '' === (string) $entry->singular ) { + continue; + } + + $values = isset( $entry->translations ) && is_array( $entry->translations ) + ? array_map( 'strval', array_values( $entry->translations ) ) + : array(); + if ( '' === implode( '', $values ) ) { + continue; + } + + $key = (string) $entry->singular; + if ( isset( $entry->context ) && '' !== (string) $entry->context ) { + $key = $entry->context . "\4" . $key; + } + + $data[ $key ] = $values; + } + + return $data; + } + + /** + * Liest eine Array-Eigenschaft eines Übersetzungsobjekts. + * + * Nicht über isset() oder `??`: WP_Translations (ab WordPress 6.5) stellt + * `entries` und `headers` nur über __get() bereit und kennt kein + * __isset() — isset() wäre dort immer false, die Übersetzungen fielen + * stillschweigend weg. NOOP_Translations und MO haben echte Eigenschaften. + * + * @param mixed $source Übersetzungsobjekt. + * @param string $name Eigenschaft. + * @return array + */ + private static function read_array( $source, string $name ): array { + if ( ! is_object( $source ) ) { + return array(); + } + if ( ! property_exists( $source, $name ) && ! method_exists( $source, '__get' ) ) { + return array(); + } + + $value = $source->$name; + + return is_array( $value ) ? $value : array(); + } + + /** + * Texte für die Mehrfachauswahl, die react-select fest vorgibt. + * + * Schlüssel: englischer Originaltext im DOM; Wert: Ersatz. Auf en_US + * übersetzt die Plugin-Domain zurück ins Original — dann ist der Tausch + * ein No-op. + * + * @return array + */ + public static function react_select_strings(): array { + return array( + 'Select...' => __( 'Auswählen…', 'open-data-wizard' ), + 'No options' => __( 'Keine passenden Optionen', 'open-data-wizard' ), + ); + } +} diff --git a/includes/class-fields.php b/includes/class-fields.php index a0fc0c8..406af0f 100644 --- a/includes/class-fields.php +++ b/includes/class-fields.php @@ -390,12 +390,7 @@ static function (): string { // beisammen — unter „Inhaltliche Angaben" war das Datum fehl am Platz. Field::make( 'date', 'odw_issued', __( 'Wann wurden diese Daten zum ersten Mal veröffentlicht?', 'open-data-wizard' ) ) ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( - array( - 'dateFormat' => 'Y-m-d', - 'locale' => 'de', - ) - ) + ->set_picker_options( self::date_picker_options() ) ->set_help_text( __( 'VERÖFFENTLICHUNGSDATUM (dct:issued)', 'open-data-wizard' ) ), // Das Änderungsdatum (dct:modified) hat kein Eingabefeld mehr: @@ -439,22 +434,12 @@ static function (): string { Field::make( 'date', 'odw_temporal_start', __( 'Ab wann reicht der Zeitraum, den die Daten abdecken?', 'open-data-wizard' ) ) ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( - array( - 'dateFormat' => 'Y-m-d', - 'locale' => 'de', - ) - ) + ->set_picker_options( self::date_picker_options() ) ->set_help_text( __( 'ZEITLICHER BEZUG — START (dct:temporal)', 'open-data-wizard' ) ), Field::make( 'date', 'odw_temporal_end', __( 'Bis wann reicht der Zeitraum, den die Daten abdecken?', 'open-data-wizard' ) ) ->set_storage_format( 'Y-m-d' ) - ->set_picker_options( - array( - 'dateFormat' => 'Y-m-d', - 'locale' => 'de', - ) - ) + ->set_picker_options( self::date_picker_options() ) ->set_help_text( __( 'ZEITLICHER BEZUG — ENDE (dct:temporal)', 'open-data-wizard' ) ), Field::make( 'html', 'odw_ext_hint_contact' ) @@ -1016,6 +1001,24 @@ public static function get_license_descriptions(): array { return (array) apply_filters( 'odw_license_descriptions', $descriptions ); } + /** + * Optionen für den Kalender (flatpickr) der Datumsfelder. + * + * Die Sprache folgt der Oberfläche: Deutsch nur bei deutscher Locale, sonst + * das englische Original („default"). Fest auf „de" gestellt zeigte der + * Kalender auch auf en_US deutsche Monate; ohne die Sprachdatei aus + * assets/js/odw-datepicker-de.js, die nur für deutsche Locales geladen + * wird, würde flatpickr bei „de" zudem „invalid locale" melden. + * + * @return array{dateFormat: string, locale: string} + */ + public static function date_picker_options(): array { + return array( + 'dateFormat' => 'Y-m-d', + 'locale' => ODW_Carbon_Fields_L10n::is_german_locale() ? 'de' : 'default', + ); + } + /** * Kuratierte Liste geografischer Regionen (Name → GeoNames-URI) für das * dct:spatial-Feld. Deckt Deutschland, die 16 Bundesländer und größere Städte ab. diff --git a/languages/carbon-fields-ui-de_DE.mo b/languages/carbon-fields-ui-de_DE.mo new file mode 100644 index 0000000..88a9bde Binary files /dev/null and b/languages/carbon-fields-ui-de_DE.mo differ diff --git a/languages/carbon-fields-ui-de_DE.po b/languages/carbon-fields-ui-de_DE.po new file mode 100644 index 0000000..2004ac7 --- /dev/null +++ b/languages/carbon-fields-ui-de_DE.po @@ -0,0 +1,190 @@ +# Open Data Wizard — deutsche Übersetzung der Carbon-Fields-Oberfläche. +# +# Textdomain: carbon-fields-ui (JavaScript-Oberfläche von Carbon Fields 3.6). +# Carbon Fields bringt für diese Domain keine deutsche Datei mit; ohne sie +# zeigt das Formular auch in einem deutschen WordPress „There are no entries +# yet.", „Add Distribution" oder „Select Date". Geladen wird die Datei von +# ODW_Carbon_Fields_L10n für alle deutschen Locales (de_DE, de_DE_formal, +# de_AT, de_CH …) — Anrede „Sie", wie im übrigen Plugin. +# +# Die msgids stammen aus dem Quelltext von Carbon Fields 3.6.11 +# (packages/**/*.js, Aufrufe von __( '…', 'carbon-fields-ui' )). Meldungen, +# die nur in der Browser-Konsole für Entwickler:innen erscheinen (Registry, +# Container-Bedingungen), bleiben bewusst englisch. +# +# Nach Änderungen neu kompilieren: +# python3 bin/compile-mo.py languages/carbon-fields-ui-de_DE.po languages/carbon-fields-ui-de_DE.mo +# +# This file is distributed under the same license as the plugin. +msgid "" +msgstr "" +"Project-Id-Version: Open Data Wizard 2.47.5\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-11-06 00:00+0000\n" +"Language: de_DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: carbon-fields-ui\n" + +# Wiederholungsfeld (complex) +msgid "There are no entries yet." +msgstr "Noch keine Einträge vorhanden." + +msgid "Add %s" +msgstr "%s hinzufügen" + +msgid "Expand All" +msgstr "Alle aufklappen" + +msgid "Collapse All" +msgstr "Alle zuklappen" + +msgid "Collapse" +msgstr "Zuklappen" + +msgid "Duplicate" +msgstr "Duplizieren" + +msgid "Remove" +msgstr "Entfernen" + +msgid "This field is required." +msgstr "Dieses Feld ist ein Pflichtfeld." + +msgid "Minimum number of rows not reached (%1$d %2$s)" +msgstr "Mindestanzahl an Einträgen nicht erreicht (%1$d %2$s)" + +# Datum und Uhrzeit +msgid "Select Date" +msgstr "Datum wählen" + +msgid "Select Time" +msgstr "Uhrzeit wählen" + +# Auswahlfelder (multiselect, set, select) +msgid "No options." +msgstr "Keine Optionen vorhanden." + +msgid "Show All Options" +msgstr "Alle Optionen anzeigen" + +msgid "Show Less Options" +msgstr "Weniger Optionen anzeigen" + +msgid "Search..." +msgstr "Suchen…" + +msgid "Please choose" +msgstr "Bitte wählen" + +# Verknüpfungen (association) +msgid "Add" +msgstr "Hinzufügen" + +msgid "Edit" +msgstr "Bearbeiten" + +msgid "Thumbnail" +msgstr "Vorschaubild" + +msgid "Showing %1$d of %2$d results" +msgstr "%1$d von %2$d Ergebnissen" + +msgid "Maximum number of items reached (%s items)" +msgstr "Höchstzahl an Elementen erreicht (%s Elemente)" + +msgid "Minimum number of items not reached (%s items)" +msgstr "Mindestanzahl an Elementen nicht erreicht (%s Elemente)" + +msgid "An error occurred while trying to fetch association options." +msgstr "Beim Laden der Auswahlmöglichkeiten ist ein Fehler aufgetreten." + +# Dateien und Medien +msgid "Select File" +msgstr "Datei auswählen" + +msgid "Use File" +msgstr "Datei verwenden" + +msgid "Select Image" +msgstr "Bild auswählen" + +msgid "Use Image" +msgstr "Bild verwenden" + +msgid "Select Attachments" +msgstr "Anhänge auswählen" + +msgid "Use Attachments" +msgstr "Anhänge verwenden" + +msgid "An error occurred while trying to fetch files data." +msgstr "Beim Laden der Dateiangaben ist ein Fehler aufgetreten." + +# Farbe +msgid "Select a color" +msgstr "Farbe wählen" + +msgid "Clear" +msgstr "Zurücksetzen" + +# Rich-Text +msgid "Visual" +msgstr "Visuell" + +msgid "Text" +msgstr "Text" + +msgid "Click to edit" +msgstr "Zum Bearbeiten klicken" + +msgid "Close" +msgstr "Schließen" + +# oEmbed und Karte +msgid "Not Found" +msgstr "Nicht gefunden" + +msgid "An error occurred while trying to fetch oembed preview." +msgstr "Beim Laden der Einbettungsvorschau ist ein Fehler aufgetreten." + +msgid "The address could not be found." +msgstr "Die Adresse wurde nicht gefunden." + +msgid "Geocode was not successful for the following reason: " +msgstr "Die Geokodierung ist aus folgendem Grund fehlgeschlagen: " + +msgid "Error alert" +msgstr "Fehlermeldung" + +# Seitenleisten +msgid "Please enter the name of the new sidebar:" +msgstr "Bitte geben Sie den Namen der neuen Seitenleiste ein:" + +msgid "An error occurred while trying to create the sidebar." +msgstr "Beim Anlegen der Seitenleiste ist ein Fehler aufgetreten." + +# Allgemein +msgid "An error occured." +msgstr "Es ist ein Fehler aufgetreten." + +# Block-Editor +msgid "Fields" +msgstr "Felder" + +msgid "Show preview" +msgstr "Vorschau anzeigen" + +msgid "Hide preview" +msgstr "Vorschau ausblenden" + +msgid "No results found." +msgstr "Keine Ergebnisse gefunden." + +msgid "Error loading block: %s" +msgstr "Fehler beim Laden des Blocks: %s" + +msgid "Field of type '%s' is not supported in Gutenberg." +msgstr "Feldtyp „%s“ wird im Block-Editor nicht unterstützt." diff --git a/languages/open-data-wizard-en_US.mo b/languages/open-data-wizard-en_US.mo index 5ce3b8c..4a9adf1 100644 Binary files a/languages/open-data-wizard-en_US.mo and b/languages/open-data-wizard-en_US.mo differ diff --git a/languages/open-data-wizard-en_US.po b/languages/open-data-wizard-en_US.po index 8e9deb2..2bdaa40 100644 --- a/languages/open-data-wizard-en_US.po +++ b/languages/open-data-wizard-en_US.po @@ -2296,3 +2296,9 @@ msgstr "Go to sample data" #, php-format msgid "Zum Ausprobieren können Sie %d vollständig ausgefüllte, frei erfundene Datensätze laden (Musterstadt, Musterkreis, Musterland). Sie erscheinen in keinem Harvest-Endpunkt, und im Datenkatalog auf der Website sieht sie nur, wer angemeldet ist und Datensätze bearbeiten darf. Mit einem Klick sind sie wieder entfernt." msgstr "To try things out, you can load %d complete, entirely fictional datasets (Musterstadt, Musterkreis, Musterland). They never appear in a harvest endpoint, and in the data catalogue on the website only signed-in users who may edit datasets can see them. One click removes them again." + +msgid "Auswählen…" +msgstr "Select..." + +msgid "Keine passenden Optionen" +msgstr "No options" diff --git a/languages/open-data-wizard.pot b/languages/open-data-wizard.pot index a650b7d..a3adbb1 100644 --- a/languages/open-data-wizard.pot +++ b/languages/open-data-wizard.pot @@ -1923,3 +1923,9 @@ msgstr "" #, php-format msgid "Zum Ausprobieren können Sie %d vollständig ausgefüllte, frei erfundene Datensätze laden (Musterstadt, Musterkreis, Musterland). Sie erscheinen in keinem Harvest-Endpunkt, und im Datenkatalog auf der Website sieht sie nur, wer angemeldet ist und Datensätze bearbeiten darf. Mit einem Klick sind sie wieder entfernt." msgstr "" + +msgid "Auswählen…" +msgstr "" + +msgid "Keine passenden Optionen" +msgstr "" diff --git a/open-data-wizard.php b/open-data-wizard.php index 35f9261..d4e928a 100644 --- a/open-data-wizard.php +++ b/open-data-wizard.php @@ -3,7 +3,7 @@ * Plugin Name: Open Data Wizard * Plugin URI: https://github.com/daimpad/OpenDataWizard * Description: DCAT-AP 3.0 konforme Open Data Metadatenverwaltung für WordPress. Bereitstellung als maschinenlesbarer JSON-LD-Endpoint für offene Daten. - * Version: 2.47.4 + * Version: 2.47.5 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: nozilla @@ -26,7 +26,7 @@ exit; } -define( 'ODW_VERSION', '2.47.4' ); +define( 'ODW_VERSION', '2.47.5' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); @@ -140,6 +140,7 @@ function () use ( $e ): void { 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-carbon-fields-l10n.php'; require_once ODW_PLUGIN_DIR . 'includes/class-rdf.php'; require_once ODW_PLUGIN_DIR . 'includes/class-rest-api.php'; require_once ODW_PLUGIN_DIR . 'includes/class-validation.php'; @@ -157,6 +158,7 @@ function () use ( $e ): void { ODW_Settings::init(); ODW_Post_Types::init(); ODW_Fields::init(); + ODW_Carbon_Fields_L10n::init(); ODW_Rest_API::init(); ODW_Validation::init(); ODW_Quality::init(); diff --git a/package-lock.json b/package-lock.json index 5156266..482b7ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-data-wizard", - "version": "2.47.4", + "version": "2.47.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-data-wizard", - "version": "2.47.4", + "version": "2.47.5", "license": "GPL-2.0-or-later", "devDependencies": { "@playwright/test": "^1.64.1", diff --git a/package.json b/package.json index 2ffa528..0198b52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-data-wizard", - "version": "2.47.4", + "version": "2.47.5", "description": "WordPress plugin for publishing open data as DCAT-AP 3.0 compliant metadata", "author": "Open Data Wizard Team", "license": "GPL-2.0-or-later", diff --git a/tests/e2e/admin-workflow.spec.js b/tests/e2e/admin-workflow.spec.js index f259292..6179086 100644 --- a/tests/e2e/admin-workflow.spec.js +++ b/tests/e2e/admin-workflow.spec.js @@ -271,6 +271,43 @@ test.describe('Formular', () => { await expect(page.locator(cfField('odw_language'))).toBeVisible(); }); + test('Bedienelemente von Carbon Fields sprechen Deutsch', async ({ page }) => { + await page.goto('/wp-admin/post-new.php?post_type=odw_dataset'); + + // Die Texte stammen aus der Oberfläche von Carbon Fields (Domain + // carbon-fields-ui), nicht aus dem Plugin. Sie kommen über + // ODW_Carbon_Fields_L10n und languages/carbon-fields-ui-de_DE.mo an; ohne + // beides stand hier „There are no entries yet." und „Add Distribution". + + // Mehrfachauswahl in Tab 1 (aktiv): Platzhalter von react-select. + const themen = page.locator('.cf-field.cf-multiselect', { hasText: 'Welchen Themen' }); + await expect(themen.locator('.cf-multiselect__placeholder')).toHaveText('Auswählen…'); + + // Wiederholungsfeld in Tab 3: Leerzustand und Hinzufügen-Button. + await page.locator('.cf-container__tabs-item button', { hasText: 'Datenbereitstellung' }).click(); + const weitere = page.locator('.cf-field.cf-complex', { hasText: 'Weitere Distributionen' }); + await expect(weitere.locator('.cf-complex__placeholder-label')).toHaveText('Noch keine Einträge vorhanden.'); + await expect(weitere.locator('.cf-complex__inserter-button')).toHaveText('Distribution hinzufügen'); + + // Datumsfeld: Beschriftung des Kalender-Buttons. Ohne Sichtbarkeitsprüfung, + // weil das Feld in einer aufklappbaren Gruppe stehen kann — es geht um den + // Text, und Carbon Fields rendert alle Reiter ins DOM. + const zeitraum = page.locator('.cf-field', { has: page.locator(cfField('odw_temporal_start')) }); + await expect(zeitraum.locator('.cf-datetime__button')).toHaveText('Datum wählen'); + + // Kalender (flatpickr, `static: true` — liegt im Feld): deutsche Wochentage, + // Woche beginnt am Montag. Die Kalendersprache folgt seit v2.47.5 der + // Locale (ODW_Fields::date_picker_options()); auf en_US bleibt sie englisch. + await expect(zeitraum.locator('.flatpickr-weekday').first()).toHaveText(/^\s*Mo\s*$/); + + // Erst nach den positiven Zusicherungen: Vorher wäre ein noch nicht + // gerendertes Formular ebenfalls „frei von englischen Texten". + const formular = page.locator('#poststuff'); + for (const englisch of ['There are no entries yet', 'Add Distribution', 'Select Date', 'Select...', 'No options']) { + await expect(formular).not.toContainText(englisch); + } + }); + test('blockt die Veröffentlichung ohne Pflichtangaben und benennt die Felder', async ({ page }) => { await page.goto('/wp-admin/post-new.php?post_type=odw_dataset'); diff --git a/tests/test-carbon-fields-l10n.php b/tests/test-carbon-fields-l10n.php new file mode 100644 index 0000000..808b8c1 --- /dev/null +++ b/tests/test-carbon-fields-l10n.php @@ -0,0 +1,403 @@ + + */ + private array $list; + + /** + * Kopfzeilen. + * + * @var array + */ + private array $head; + + /** + * Legt Einträge und Kopfzeilen fest. + * + * @param array $items Einträge. + * @param array $head Kopfzeilen. + */ + public function __construct( array $items, array $head ) { + $this->list = $items; + $this->head = $head; + } + + /** + * Liefert Einträge und Kopfzeilen nur über den magischen Getter. + * + * @param string $name Eigenschaft. + * @return mixed + */ + public function __get( string $name ) { + if ( 'entries' === $name ) { + return $this->list; + } + if ( 'headers' === $name ) { + return $this->head; + } + return null; + } +} + +/** + * Unit tests for ODW_Carbon_Fields_L10n. + */ +class Test_ODW_Carbon_Fields_L10n extends TestCase { + + /** + * Set up WP_Mock and load the class. + */ + protected function setUp(): void { + \WP_Mock::setUp(); + require_once dirname( __DIR__ ) . '/includes/class-carbon-fields-l10n.php'; + $this->reset_loaded(); + } + + /** + * Tear down WP_Mock. + */ + protected function tearDown(): void { + $this->reset_loaded(); + \WP_Mock::tearDown(); + } + + /** + * Setzt den Merker „deutsche Datei geladen" zurück (statisch, pro Request). + */ + private function reset_loaded(): void { + $prop = new \ReflectionProperty( ODW_Carbon_Fields_L10n::class, 'german_loaded' ); + $prop->setAccessible( true ); + $prop->setValue( null, false ); + } + + /** + * Ein Übersetzungseintrag wie Translation_Entry. + * + * @param string $singular msgid. + * @param string[] $translations msgstr(s). + * @param string|null $context msgctxt. + */ + private function entry( string $singular, array $translations, ?string $context = null ): object { + $entry = new \stdClass(); + $entry->singular = $singular; + $entry->translations = $translations; + $entry->context = $context; + return $entry; + } + + /** + * Liest eine .mo-Datei in ein msgid => msgstr-Array (kleiner Reader für den Test). + * + * @param string $path Pfad. + * @return array + */ + private function read_mo( string $path ): array { + $bin = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- lokale Datei. + $this->assertSame( 0x950412de, unpack( 'V', substr( $bin, 0, 4 ) )[1], 'Keine gültige .mo (Magic Number).' ); + + $header = unpack( 'Vmagic/Vrev/Vcount/Vorig/Vtrans', substr( $bin, 0, 20 ) ); + $result = array(); + for ( $i = 0; $i < $header['count']; $i++ ) { + $o = unpack( 'Vlen/Voff', substr( $bin, $header['orig'] + 8 * $i, 8 ) ); + $t = unpack( 'Vlen/Voff', substr( $bin, $header['trans'] + 8 * $i, 8 ) ); + $id = substr( $bin, $o['off'], $o['len'] ); + $result[ $id ] = substr( $bin, $t['off'], $t['len'] ); + } + return $result; + } + + // ----------------------------------------------------------------------- + // Locale + // ----------------------------------------------------------------------- + + /** + * Alle deutschen Varianten gelten als deutsch, andere Sprachen nicht. + */ + public function test_is_german_locale(): void { + foreach ( array( 'de', 'de_DE', 'de_DE_formal', 'de_AT', 'de_CH', 'de_CH_informal' ) as $locale ) { + $this->assertTrue( ODW_Carbon_Fields_L10n::is_german_locale( $locale ), $locale ); + } + foreach ( array( 'en_US', 'fr_FR', 'dsb_DE', 'hsb_DE', '' ) as $locale ) { + $this->assertFalse( ODW_Carbon_Fields_L10n::is_german_locale( $locale ), $locale ); + } + } + + /** + * Ohne Argument entscheidet determine_locale(). + */ + public function test_is_german_locale_defaults_to_request_locale(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_DE_formal' ); + + $this->assertTrue( ODW_Carbon_Fields_L10n::is_german_locale() ); + } + + /** + * Kalendersprache der Datumsfelder folgt der Oberfläche: „de" nur bei + * deutscher Locale, sonst flatpickrs englisches Original. + */ + public function test_date_picker_locale_follows_interface(): void { + require_once ODW_PLUGIN_DIR . 'includes/class-fields.php'; + + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_AT', 'en_US' ); + + $this->assertSame( + array( + 'dateFormat' => 'Y-m-d', + 'locale' => 'de', + ), + ODW_Fields::date_picker_options() + ); + $this->assertSame( + array( + 'dateFormat' => 'Y-m-d', + 'locale' => 'default', + ), + ODW_Fields::date_picker_options() + ); + } + + // ----------------------------------------------------------------------- + // locale_data() + // ----------------------------------------------------------------------- + + /** + * Der Fall, an dem Carbon Fields scheitert: Einträge als Liste, nur über + * __get() erreichbar. Die Schlüssel müssen die msgids sein, nicht 0, 1, 2. + */ + public function test_locale_data_keys_by_msgid_for_wp_translations(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_DE' ); + + $translations = new ODW_Test_Magic_Translations( + array( + $this->entry( 'There are no entries yet.', array( 'Noch keine Einträge vorhanden.' ) ), + $this->entry( 'Add %s', array( '%s hinzufügen' ) ), + ), + array( 'Plural-Forms' => 'nplurals=2; plural=(n != 1);' ) + ); + + $data = ODW_Carbon_Fields_L10n::locale_data( + $translations, + array( + 'domain' => 'carbon-fields-ui', + 'lang' => 'de_DE', + ) + ); + + $this->assertArrayNotHasKey( 0, $data ); + $this->assertSame( array( 'Noch keine Einträge vorhanden.' ), $data['There are no entries yet.'] ); + $this->assertSame( array( '%s hinzufügen' ), $data['Add %s'] ); + $this->assertSame( 'carbon-fields-ui', $data['']['domain'] ); + $this->assertSame( 'de_DE', $data['']['lang'] ); + $this->assertSame( 'nplurals=2; plural=(n != 1);', $data['']['plural_forms'] ); + } + + /** + * Ältere WordPress-Versionen (MO/Translations): echte Eigenschaften, + * Einträge nach Schlüssel indiziert — Ergebnis identisch. + */ + public function test_locale_data_with_keyed_entries(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_DE' ); + + $translations = new \stdClass(); + $translations->headers = array(); + $translations->entries = array( + 'Select Date' => $this->entry( 'Select Date', array( 'Datum wählen' ) ), + ); + + $data = ODW_Carbon_Fields_L10n::locale_data( $translations ); + + $this->assertSame( array( 'Datum wählen' ), $data['Select Date'] ); + $this->assertSame( 'carbon-fields-ui', $data['']['domain'] ); + $this->assertArrayNotHasKey( 'plural_forms', $data[''] ); + } + + /** + * Kontext wird wie in @wordpress/i18n mit \u0004 vorangestellt; leere + * Übersetzungen fallen weg, damit das englische Original sichtbar bleibt + * statt eines leeren Buttons. + */ + public function test_locale_data_context_and_empty_translations(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_DE' ); + + $translations = new ODW_Test_Magic_Translations( + array( + $this->entry( 'Add', array( 'Hinzufügen' ), 'button' ), + $this->entry( 'Clear', array( '' ) ), + $this->entry( '', array( 'Kopf' ) ), + ), + array() + ); + + $data = ODW_Carbon_Fields_L10n::locale_data( $translations ); + + $this->assertSame( array( 'Hinzufügen' ), $data["button\4Add"] ); + $this->assertArrayNotHasKey( 'Clear', $data ); + $this->assertCount( 2, $data ); + } + + /** + * NOOP_Translations (keine Datei geladen, z. B. en_US): nur der Kopf. + */ + public function test_locale_data_without_translations(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'en_US' ); + + $this->assertSame( + array( + '' => array( + 'domain' => 'carbon-fields-ui', + 'lang' => 'en_US', + ), + ), + ODW_Carbon_Fields_L10n::locale_data( null ) + ); + } + + // ----------------------------------------------------------------------- + // filter_config() + // ----------------------------------------------------------------------- + + /** + * Deutsch: lädt die gebündelte Datei in die Domain carbon-fields-ui und + * ersetzt config.locale; übrige Einträge von window.cf bleiben unberührt. + */ + public function test_filter_config_loads_german_file(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'de_DE' ); + \WP_Mock::userFunction( 'load_textdomain' ) + ->once() + ->with( 'carbon-fields-ui', ODW_PLUGIN_DIR . 'languages/carbon-fields-ui-de_DE.mo' ) + ->andReturn( true ); + \WP_Mock::userFunction( 'get_translations_for_domain' ) + ->with( 'carbon-fields-ui' ) + ->andReturn( + new ODW_Test_Magic_Translations( + array( $this->entry( 'There are no entries yet.', array( 'Noch keine Einträge vorhanden.' ) ) ), + array() + ) + ); + + $config = ODW_Carbon_Fields_L10n::filter_config( + array( + 'config' => array( + 'locale' => array( + '' => array( + 'domain' => 'carbon-fields-ui', + 'lang' => 'de_DE', + ), + 0 => array( 'kaputt' ), + ), + 'pagenow' => 'post.php', + ), + ) + ); + + $this->assertSame( 'post.php', $config['config']['pagenow'] ); + $this->assertArrayNotHasKey( 0, $config['config']['locale'] ); + $this->assertSame( array( 'Noch keine Einträge vorhanden.' ), $config['config']['locale']['There are no entries yet.'] ); + + // Ein zweiter Aufruf im selben Request lädt nicht erneut (once() oben). + ODW_Carbon_Fields_L10n::filter_config( array( 'config' => array() ) ); + } + + /** + * Nicht deutsch: keine deutsche Datei, aber trotzdem korrekt geschlüsselte + * Daten — davon profitieren auch die Übersetzungen, die Carbon Fields + * selbst mitbringt (z. B. fr_FR). + */ + public function test_filter_config_other_locale_does_not_load_german(): void { + \WP_Mock::userFunction( 'determine_locale' )->andReturn( 'fr_FR' ); + \WP_Mock::userFunction( 'load_textdomain' )->never(); + \WP_Mock::userFunction( 'get_translations_for_domain' )->andReturn( + new ODW_Test_Magic_Translations( + array( $this->entry( 'There are no entries yet.', array( "Il n'y a pas encore d'entrées." ) ) ), + array() + ) + ); + + $config = ODW_Carbon_Fields_L10n::filter_config( array( 'config' => array() ) ); + + $this->assertSame( array( "Il n'y a pas encore d'entrées." ), $config['config']['locale']['There are no entries yet.'] ); + } + + /** + * Unerwartete Struktur wird unverändert durchgereicht. + */ + public function test_filter_config_passes_through_unexpected_input(): void { + $this->assertSame( 'x', ODW_Carbon_Fields_L10n::filter_config( 'x' ) ); + $this->assertSame( array( 'other' => 1 ), ODW_Carbon_Fields_L10n::filter_config( array( 'other' => 1 ) ) ); + } + + // ----------------------------------------------------------------------- + // Gebündelte Dateien + // ----------------------------------------------------------------------- + + /** + * Die .mo enthält die Texte aus dem Formular des Plugins und stimmt mit + * der .po überein — eine vergessene Neukompilierung fällt hier auf. + */ + public function test_bundled_german_mo_matches_po(): void { + $mo = $this->read_mo( ODW_Carbon_Fields_L10n::german_mo_path() ); + + $expected = array( + 'There are no entries yet.' => 'Noch keine Einträge vorhanden.', + 'Add %s' => '%s hinzufügen', + 'Select Date' => 'Datum wählen', + 'No options.' => 'Keine Optionen vorhanden.', + 'Collapse All' => 'Alle zuklappen', + 'Expand All' => 'Alle aufklappen', + 'This field is required.' => 'Dieses Feld ist ein Pflichtfeld.', + ); + foreach ( $expected as $msgid => $msgstr ) { + $this->assertSame( $msgstr, $mo[ $msgid ] ?? null, $msgid ); + } + + // Jede msgid der .po steht in der .mo. + $po = (string) file_get_contents( ODW_PLUGIN_DIR . 'languages/carbon-fields-ui-de_DE.po' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- lokale Datei. + preg_match_all( '/^msgid "(.+)"$/m', $po, $matches ); + $this->assertNotEmpty( $matches[1] ); + foreach ( $matches[1] as $msgid ) { + $msgid = stripcslashes( $msgid ); + $this->assertArrayHasKey( $msgid, $mo, 'Nicht kompiliert: ' . $msgid ); + } + + $this->assertStringContainsString( 'Plural-Forms: nplurals=2; plural=(n != 1);', $mo[''] ); + } + + /** + * Die Schlüssel sind die festen Vorgaben von react-select — nur dann + * findet das Skript den Text im DOM. + */ + public function test_react_select_strings(): void { + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $strings = ODW_Carbon_Fields_L10n::react_select_strings(); + + $this->assertSame( array( 'Select...', 'No options' ), array_keys( $strings ) ); + $this->assertSame( 'Auswählen…', $strings['Select...'] ); + $this->assertSame( 'Keine passenden Optionen', $strings['No options'] ); + } +}