Translations, * group "Abientot") and translated only where visitors see them. Admin screens and * saved records always keep the original text. Without WPML or Polylang every * method returns its input unchanged. */ final class MultilingualText { public const CONTEXT = 'Abientot'; private const FINGERPRINT = 'abientot_wpml_strings_fingerprint'; /** Free-text form settings visitors read. */ private const FORM_TEXT = array( 'form_title', 'form_intro', 'form_button_label', 'policy_checkbox_text', 'confirmation_message', 'payment_instructions' ); /** Hidden customer snapshot entry holding the visitor's language (empty label: never shown). */ public const SNAPSHOT_KEY = '_abientot_language'; /** Customer email templates whose text the business can translate per language. */ public const CUSTOMER_EMAILS = array( 'customer_confirmation', 'customer_confirmed', 'customer_cancellation', 'customer_rescheduling', 'customer_completed' ); /** Language forced while a message for one booking is rendered outside the visitor's request. */ private static string $forced = ''; public static function register_hooks(): void { add_action( 'admin_init', array( self::class, 'register_strings' ), 20 ); } private static function wpml(): bool { return defined( 'ICL_SITEPRESS_VERSION' ); } private static function polylang(): bool { return ! self::wpml() && function_exists( 'pll__' ) && function_exists( 'pll_register_string' ); } public static function active(): bool { return self::wpml() || self::polylang(); } /** Current visitor language code for AJAX requests (both plugins read a "lang" request field), or ''. */ public static function current_language(): string { if ( '' !== self::$forced ) { return self::$forced; } if ( self::polylang() && function_exists( 'pll_current_language' ) ) { $language = pll_current_language(); } elseif ( self::wpml() ) { $language = apply_filters( 'wpml_current_language', null ); } else { return ''; } return is_string( $language ) ? sanitize_key( $language ) : ''; } public static function translate( string $value, string $name ): string { if ( '' === trim( $value ) || ! self::active() ) { return $value; } if ( '' !== self::$forced ) { $translated = self::polylang() ? ( function_exists( 'pll_translate_string' ) ? pll_translate_string( $value, self::$forced ) : $value ) : apply_filters( 'wpml_translate_single_string', $value, self::CONTEXT, $name, self::$forced ); } else { $translated = self::polylang() ? pll__( $value ) : apply_filters( 'wpml_translate_single_string', $value, self::CONTEXT, $name ); } return is_string( $translated ) && '' !== $translated ? $translated : $value; } /** * Snapshot entry recording the visitor's language at booking time, or null without WPML/Polylang. * * @return array|null */ public static function snapshot_entry( string $language = '' ): ?array { if ( ! self::active() ) { return null; } // A form posted outside the page's language context (admin-post.php) sends the language it was shown in. $language = '' !== $language ? ( self::known_language( $language ) ? sanitize_key( $language ) : '' ) : self::current_language(); if ( '' === $language ) { return null; } return array( 'key' => self::SNAPSHOT_KEY, 'label' => '', 'type' => 'metadata', 'required' => '0', 'value' => $language, ); } /** * Language code the visitor booked in, read from the booking's customer snapshot, or ''. * * @param array $booking Booking row with customer_fields_json. */ public static function booking_language( array $booking ): string { $fields = json_decode( (string) ( $booking['customer_fields_json'] ?? '' ), true ); if ( ! is_array( $fields ) ) { return ''; } foreach ( $fields as $field ) { if ( is_array( $field ) && self::SNAPSHOT_KEY === ( $field['key'] ?? '' ) && is_string( $field['value'] ?? null ) ) { return sanitize_key( $field['value'] ); } } return ''; } /** True when $code is a language configured in WPML/Polylang. */ public static function known_language( string $code ): bool { if ( '' === $code || sanitize_key( $code ) !== $code ) { return false; } if ( self::polylang() && function_exists( 'pll_languages_list' ) ) { return in_array( $code, (array) pll_languages_list( array( 'fields' => 'slug' ) ), true ); } if ( self::wpml() ) { $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) ); return is_array( $languages ) && isset( $languages[ $code ] ); } return false; } /** WordPress locale of a WPML/Polylang language code (fr → fr_FR), or ''. */ public static function language_locale( string $code ): string { if ( '' === $code ) { return ''; } $locale = ''; if ( self::polylang() && function_exists( 'pll_languages_list' ) ) { $slugs = (array) pll_languages_list( array( 'fields' => 'slug' ) ); $locales = (array) pll_languages_list( array( 'fields' => 'locale' ) ); $index = array_search( $code, $slugs, true ); $locale = false !== $index && isset( $locales[ $index ] ) ? (string) $locales[ $index ] : ''; } elseif ( self::wpml() ) { $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) ); $locale = is_array( $languages ) && isset( $languages[ $code ]['default_locale'] ) ? (string) $languages[ $code ]['default_locale'] : ''; } $names = Settings::language_names(); if ( isset( $names[ $locale ] ) ) { return $locale; } // Unknown regional variant (fr_BE) or no locale from the plugin: same language, bundled locale. $prefix = strtolower( strtok( '' !== $locale ? $locale : $code, '_-' ) ); foreach ( array_keys( $names ) as $known ) { if ( strtolower( strtok( $known, '_' ) ) === $prefix ) { return $known; } } return ''; } /** * Runs $render in the language the booking was made in: plugin messages in that locale and * business texts translated into that language. Without a recorded language, or outside * WPML/Polylang, $render runs unchanged (site language). * * @param array $booking Booking row. * @return mixed What $render returns. */ public static function for_booking( array $booking, callable $render ) { $language = self::active() ? self::booking_language( $booking ) : ''; if ( '' === $language ) { return $render(); } $previous = self::$forced; self::$forced = $language; try { $locale = self::language_locale( $language ); return '' !== $locale && $locale !== determine_locale() ? Translations::with_locale( $locale, $render ) : $render(); } finally { self::$forced = $previous; } } /** * Booking status name in the forced booking language. System statuses are stored in the site * language; a name the business changed is kept as written. */ public static function status_label( string $slug, string $label ): string { if ( '' === self::$forced ) { return $label; } $english = \Abientot\Database\Seeder::default_status_labels(); if ( ! isset( $english[ $slug ] ) ) { return $label; } $state = get_option( \Abientot\Database\Seeder::STATUS_LABELS_OPTION ); $site = is_array( $state ) && is_string( $state['labels'][ $slug ] ?? null ) ? $state['labels'][ $slug ] : $english[ $slug ]; if ( $label !== $site && $label !== $english[ $slug ] ) { return $label; } return (string) ( \Abientot\Database\Seeder::translated_status_labels()[ $slug ] ?? $label ); } /** True while a message is rendered in a booking's language (see for_booking()). */ public static function forced(): bool { return '' !== self::$forced; } /** * @param array $service Service row, optionally joined with its category. * @return array */ public static function service( array $service ): array { if ( ! self::active() ) { return $service; } $id = (int) ( $service['id'] ?? 0 ); foreach ( array( 'name', 'description' ) as $field ) { if ( isset( $service[ $field ] ) ) { $service[ $field ] = self::translate( (string) $service[ $field ], 'service-' . $id . '-' . $field ); } } $category = (int) ( $service['category_id'] ?? 0 ); foreach ( array( 'name', 'description' ) as $field ) { if ( $category > 0 && isset( $service[ 'category_' . $field ] ) ) { $service[ 'category_' . $field ] = self::translate( (string) $service[ 'category_' . $field ], 'category-' . $category . '-' . $field ); } } return $service; } /** * @param array $location Location row. * @return array */ public static function location( array $location ): array { if ( self::active() && isset( $location['name'] ) ) { $location['name'] = self::translate( (string) $location['name'], 'location-' . (int) ( $location['id'] ?? 0 ) . '-name' ); } return $location; } /** * @param array $settings Booking form settings. * @return array */ public static function form_settings( array $settings, int $form_id ): array { if ( ! self::active() ) { return $settings; } $main = $form_id > 0 ? BookingForms::settings( 0 ) : $settings; foreach ( self::FORM_TEXT as $key ) { if ( isset( $settings[ $key ] ) && is_string( $settings[ $key ] ) ) { $settings[ $key ] = self::translate( $settings[ $key ], self::form_string_name( $form_id, $key, $settings[ $key ], $main ) ); } } return $settings; } /** A form that keeps the main form's text shares its string, so it is translated once. */ private static function form_string_name( int $form_id, string $key, string $value, array $main ): string { return 'form-' . ( $form_id > 0 && $value === ( $main[ $key ] ?? null ) ? 0 : $form_id ) . '-' . $key; } /** * Every translatable string, keyed by its stable name. * * @return array */ public static function strings(): array { $strings = array(); $add = static function ( string $name, $value, bool $multiline = false ) use ( &$strings ): void { if ( is_string( $value ) && '' !== trim( $value ) ) { $strings[ $name ] = array( $value, $multiline ); } }; foreach ( ( new ServicesRepository() )->list_for_admin() as $service ) { if ( 'archived' === (string) ( $service['status'] ?? '' ) ) { continue; } $id = (int) $service['id']; $add( 'service-' . $id . '-name', $service['name'] ?? '' ); $add( 'service-' . $id . '-description', $service['description'] ?? '', true ); $category = (int) ( $service['category_id'] ?? 0 ); if ( $category > 0 ) { $add( 'category-' . $category . '-name', $service['category_name'] ?? '' ); $add( 'category-' . $category . '-description', $service['category_description'] ?? '', true ); } } $location = ( new LocationsRepository() )->default_location(); if ( $location ) { $add( 'location-' . (int) $location['id'] . '-name', $location['name'] ?? '' ); } $settings = Settings::values(); $subjects = EmailService::template_subject_defaults(); $bodies = EmailService::template_body_defaults(); foreach ( self::CUSTOMER_EMAILS as $key ) { // Untouched defaults are plugin text, already translated with the plugin's language files. if ( ! EmailService::same_template( (string) ( $settings[ 'email_' . $key . '_subject' ] ?? '' ), (string) ( $subjects[ $key ] ?? '' ) ) ) { $add( 'email-' . $key . '-subject', $settings[ 'email_' . $key . '_subject' ] ?? '' ); } if ( ! EmailService::same_template( (string) ( $settings[ 'email_' . $key . '_body' ] ?? '' ), (string) ( $bodies[ $key ] ?? '' ) ) ) { $add( 'email-' . $key . '-body', $settings[ 'email_' . $key . '_body' ] ?? '', true ); } } $reminder = \Abientot\Reminders\Settings::normalize( get_option( \Abientot\Reminders\Settings::OPTION ) ); $default = \Abientot\Reminders\Settings::defaults(); if ( $reminder['subject'] !== $default['subject'] ) { $add( 'reminder-subject', $reminder['subject'] ); } if ( $reminder['body'] !== $default['body'] ) { $add( 'reminder-body', $reminder['body'], true ); } $main = BookingForms::settings( 0 ); foreach ( array_keys( BookingForms::names() ) as $form_id ) { $settings = BookingForms::settings( (int) $form_id ); foreach ( self::FORM_TEXT as $key ) { $value = is_string( $settings[ $key ] ?? null ) ? $settings[ $key ] : ''; $add( self::form_string_name( (int) $form_id, $key, $value, $main ), $value, in_array( $key, array( 'form_intro', 'confirmation_message', 'payment_instructions' ), true ) ); } } return (array) apply_filters( 'abientot_translatable_strings', $strings ); } /** * Polylang keeps registered strings for the current request only, so they are registered on * each admin load. WPML stores them, so they are sent again only when the text changed. */ public static function register_strings(): void { if ( ! self::active() || ! current_user_can( 'abientot_manage_settings' ) ) { return; } try { $strings = self::strings(); } catch ( \Throwable $error ) { return; } if ( self::polylang() ) { foreach ( $strings as $name => $string ) { pll_register_string( $name, $string[0], self::CONTEXT, $string[1] ); } return; } $fingerprint = md5( (string) wp_json_encode( $strings ) ); if ( get_option( self::FINGERPRINT ) === $fingerprint ) { return; } foreach ( $strings as $name => $string ) { do_action( 'wpml_register_single_string', self::CONTEXT, $name, $string[0] ); } update_option( self::FINGERPRINT, $fingerprint, false ); } }