'en_US', 'fr' => 'fr_FR', ); public function __construct() { // Text overrides (PHP side). JS side: see overrides_for_js() + wp.i18n.setLocaleData. add_filter( 'gettext', array( $this, 'override' ), 20, 3 ); add_filter( 'gettext_with_context', array( $this, 'override_with_context' ), 20, 4 ); // Per-user admin language for this plugin only (PHP .mo/.l10n.php + JS JSON). add_filter( 'load_translation_file', array( $this, 'translation_file' ), 10, 3 ); // WP 6.5+ add_filter( 'load_textdomain_mofile', array( $this, 'mofile' ), 10, 2 ); // WP < 6.5 add_filter( 'pre_load_script_translations', array( $this, 'script_translations' ), 10, 4 ); } /* ------------------------------------------------------------------ */ /* Editable texts */ /* ------------------------------------------------------------------ */ /** * Overridable strings: msgid => [group, admin label]. * * @return array */ public static function keys() { return array( // Buttons & modal. 'Request a quote' => array( 'cart', __( 'Button label', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'From %s' => array( 'cart', __( 'Minimum notice under the button (%s = amount)', 'nuxwp-devis' ) ), 'The products in your cart will be saved in your quote request. A PDF will be generated automatically and our team will get back to you.' => array( 'cart', __( 'Modal text', 'nuxwp-devis' ) ), 'Cart total' => array( 'cart', __( 'Modal: cart total label', 'nuxwp-devis' ) ), 'Message (optional)' => array( 'cart', __( 'Modal: message field label', 'nuxwp-devis' ) ), 'Delivery deadline, project reference, special requirements…' => array( 'cart', __( 'Modal: message placeholder', 'nuxwp-devis' ) ), 'Cancel' => array( 'cart', __( 'Modal: cancel button', 'nuxwp-devis' ) ), 'Confirm the request' => array( 'cart', __( 'Modal: confirm button', 'nuxwp-devis' ) ), 'Your quote request has been sent!' => array( 'cart', __( 'Success message', 'nuxwp-devis' ) ), 'Your account is not allowed to request quotes.' => array( 'cart', __( 'Message for accounts not allowed to request', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'This cart corresponds to quote %s.' => array( 'cart', __( 'Locked cart banner (%s = quote number)', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'The products and quantities of a quote cannot be modified. For any change, please %s.' => array( 'cart', __( 'Locked cart explanation (%s = contact link)', 'nuxwp-devis' ) ), 'contact us' => array( 'cart', __( 'Contact link text', 'nuxwp-devis' ) ), // My account. 'My quotes' => array( 'account', __( 'Account tab title', 'nuxwp-devis' ) ), 'You have not requested any quote yet.' => array( 'account', __( 'Empty state', 'nuxwp-devis' ) ), 'Download the PDF quote' => array( 'account', __( 'PDF download button', 'nuxwp-devis' ) ), 'Accept and pay' => array( 'account', __( 'Pay button', 'nuxwp-devis' ) ), 'Back to my quotes' => array( 'account', __( 'Back link', 'nuxwp-devis' ) ), 'Message from our team' => array( 'account', __( 'Admin message label', 'nuxwp-devis' ) ), 'Pending' => array( 'status', __( 'Status: pending', 'nuxwp-devis' ) ), 'Quoted' => array( 'status', __( 'Status: quoted', 'nuxwp-devis' ) ), 'Accepted' => array( 'status', __( 'Status: accepted', 'nuxwp-devis' ) ), 'Declined' => array( 'status', __( 'Status: declined', 'nuxwp-devis' ) ), 'Expired' => array( 'status', __( 'Status: expired', 'nuxwp-devis' ) ), 'Ordered' => array( 'status', __( 'Status: ordered', 'nuxwp-devis' ) ), // E-mails. /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'Your quote request %1$s — %2$s' => array( 'emails', __( 'Subject: request received (%1$s = quote number, %2$s = site name)', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'New quote request %1$s — %2$s (%3$s)' => array( 'emails', __( 'Subject: new request, to the shop (%1$s = number, %2$s = customer, %3$s = total)', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'Quote %1$s — %2$s — %3$s' => array( 'emails', __( 'Subject: status update (%1$s = number, %2$s = status, %3$s = site name)', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'Hello %s,' => array( 'emails', __( 'Greeting (%s = customer name)', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'We have received your quote request %s.' => array( 'emails', __( 'Request received: first sentence (%s = quote number)', 'nuxwp-devis' ) ), 'Our team is reviewing your cart and will send you the final quote shortly.' => array( 'emails', __( 'Request received: second sentence', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'The quote will remain valid for %s, subject to product availability.' => array( 'emails', __( 'Request received: validity sentence (%s = duration)', 'nuxwp-devis' ) ), 'To view your quote and turn it into an order, open the “My quotes” section of your account.' => array( 'emails', __( 'Request received: closing sentence', 'nuxwp-devis' ) ), 'Quote details' => array( 'emails', __( 'Products table title', 'nuxwp-devis' ) ), 'View my quotes' => array( 'emails', __( 'Button: request received', 'nuxwp-devis' ) ), 'View my quote' => array( 'emails', __( 'Button: status update', 'nuxwp-devis' ) ), 'Your quote is back under review by our team.' => array( 'emails', __( 'Status message: pending', 'nuxwp-devis' ) ), 'Our team has priced your quote. You can review it in your account.' => array( 'emails', __( 'Status message: quoted', 'nuxwp-devis' ) ), 'Your quote has been accepted. You can now proceed to payment from your account.' => array( 'emails', __( 'Status message: accepted', 'nuxwp-devis' ) ), 'Your quote has been declined. Feel free to contact us for more information.' => array( 'emails', __( 'Status message: declined', 'nuxwp-devis' ) ), 'Your quote has expired. You can submit a new quote request at any time.' => array( 'emails', __( 'Status message: expired', 'nuxwp-devis' ) ), 'Your quote has been turned into an order. Thank you!' => array( 'emails', __( 'Status message: ordered', 'nuxwp-devis' ) ), /* translators: the placeholder is shown literally to the shop owner (it names the placeholder to keep); it is not substituted. */ 'Any question? Contact us at %s' => array( 'emails', __( 'Footer line (%s = contact e-mail)', 'nuxwp-devis' ) ), 'New quote request received' => array( 'emails', __( 'Shop notification: title', 'nuxwp-devis' ) ), 'Process the quote' => array( 'emails', __( 'Shop notification: button', 'nuxwp-devis' ) ), ); } /** * Stored overrides (msgid => text), non-empty only. * * @return array */ public static function overrides() { $o = DevisQR_Settings::get( 'text_overrides' ); return is_array( $o ) ? array_filter( $o, 'strlen' ) : array(); } /** * Sanitize the overrides array from the settings form. * * @param mixed $value Raw. * @return array */ public static function sanitize_overrides( $value, &$errors = array() ) { $out = array(); $keys = self::keys(); if ( ! is_array( $value ) ) { return $out; } foreach ( $value as $msgid => $text ) { if ( ! isset( $keys[ $msgid ] ) ) { continue; } $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { continue; } $text = mb_substr( $text, 0, 400 ); // Placeholders: the override must contain exactly those of the original (%s, %d, %1$s …). preg_match_all( '/%(\d+\$)?[sd]/', $msgid, $need ); preg_match_all( '/%(\d+\$)?[sd]/', $text, $have ); $a = $need[0]; $b = $have[0]; sort( $a ); sort( $b ); if ( $a !== $b ) { /* translators: 1: field label, 2: placeholders expected */ $errors[] = sprintf( __( '“%1$s”: keep the placeholder(s) %2$s in your text.', 'nuxwp-devis' ), $keys[ $msgid ][1], $a ? implode( ' ', $a ) : '—' ); continue; } $out[ $msgid ] = $text; // Raw text; '%' is escaped at use time (see for_sprintf()). } return $out; } /** * gettext filter. * * @param string $translation Translation. * @param string $text Original. * @param string $domain Domain. * @return string */ public function override( $translation, $text, $domain ) { if ( self::$bypass || 'nuxwp-devis' !== $domain ) { return $translation; } if ( is_admin() && ! wp_doing_ajax() ) { return $translation; // Back-office screens keep the original wording. } if ( defined( 'REST_REQUEST' ) && REST_REQUEST && isset( $GLOBALS['wp']->query_vars['rest_route'] ) && false !== strpos( (string) $GLOBALS['wp']->query_vars['rest_route'], '/' . DevisQR_API::NAMESPACE . '/admin/' ) ) { return $translation; // Admin REST (React back office) too. } static $overrides = null; if ( null === $overrides ) { $overrides = self::overrides(); } return isset( $overrides[ $text ] ) ? self::for_sprintf( $text, $overrides[ $text ] ) : $translation; } /** * Escape stray '%' in an override when the original string is a sprintf() pattern. * Strings without placeholders are used verbatim (no sprintf), so a literal '%' must stay a single '%'. * * @param string $msgid Original. * @param string $text Override. * @return string */ private static function for_sprintf( $msgid, $text ) { if ( ! preg_match( '/%(\d+\$)?[sd]/', $msgid ) ) { return $text; } return preg_replace( '/%(?!(\d+\$)?[sd])/', '%%', $text ); } /** * gettext_with_context filter (same table, context ignored). * * @param string $translation Translation. * @param string $text Original. * @param string $context Context. * @param string $domain Domain. * @return string */ public function override_with_context( $translation, $text, $context, $domain ) { return $this->override( $translation, $text, $domain ); } /** * Overrides as Jed locale data for wp.i18n.setLocaleData(). * * @return array */ public static function overrides_for_js() { $data = array(); foreach ( self::overrides() as $msgid => $text ) { $data[ $msgid ] = array( self::for_sprintf( $msgid, $text ) ); // wp.i18n.sprintf also reads '%%' as a literal '%'. } return $data; } /** * Rows for the settings UI: msgid, group, label, current (translated) value, override. * * @return array */ public static function rows_for_admin() { $rows = array(); $overrides = self::overrides(); self::$bypass = true; self::begin_customer_output(); // 'current' = what customers see (site language) foreach ( self::keys() as $msgid => $meta ) { $rows[] = array( 'key' => $msgid, 'group' => $meta[0], 'label' => $meta[1], 'current' => translate( $msgid, 'nuxwp-devis' ), // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction, WordPress.WP.I18n.NonSingularStringLiteralText 'override' => isset( $overrides[ $msgid ] ) ? $overrides[ $msgid ] : '', ); } self::end_customer_output(); self::$bypass = false; return $rows; } /* ------------------------------------------------------------------ */ /* Per-user admin language */ /* ------------------------------------------------------------------ */ /** * Current user's choice: auto | en | fr. * * @return string */ public static function user_choice() { $uid = get_current_user_id(); if ( ! $uid ) { return 'auto'; } $v = get_user_meta( $uid, self::USER_META_LANG, true ); return in_array( $v, array( 'en', 'fr' ), true ) ? $v : 'auto'; } /** * Save the choice. * * @param string $choice auto|en|fr. * @return string */ public static function set_user_choice( $choice ) { $choice = in_array( $choice, array( 'en', 'fr' ), true ) ? $choice : 'auto'; $uid = get_current_user_id(); if ( $uid ) { if ( 'auto' === $choice ) { delete_user_meta( $uid, self::USER_META_LANG ); } else { update_user_meta( $uid, self::USER_META_LANG, $choice ); } } return $choice; } /** * Forced locale for this plugin in admin/REST contexts, or '' for none. * * @return string */ private static function forced_locale() { if ( self::$customer_output ) { return ''; } $is_rest = defined( 'REST_REQUEST' ) && REST_REQUEST; if ( ! is_admin() && ! $is_rest ) { return ''; } if ( $is_rest ) { $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? (string) $GLOBALS['wp']->query_vars['rest_route'] : ''; if ( false === strpos( $route, '/' . DevisQR_API::NAMESPACE . '/admin/' ) ) { return ''; } } $choice = self::user_choice(); return isset( self::$locales[ $choice ] ) ? self::$locales[ $choice ] : ''; } /** * Swap the locale segment of a translation file path (.mo / .l10n.php). * * @param string $file Path. * @param string $forced Locale. * @return string Path (non-existent path for en_US so the domain stays untranslated). */ private static function swap_file_locale( $file, $forced ) { if ( 'en_US' === $forced ) { return preg_replace( '/-[A-Za-z_]+(\.mo|\.l10n\.php)$/', '-en_US$1', $file ); // does not exist → source strings } $swapped = preg_replace( '/-[A-Za-z_]+(\.mo|\.l10n\.php)$/', '-' . $forced . '$1', $file ); return ( $swapped && file_exists( $swapped ) ) ? $swapped : $file; } /** * load_translation_file filter (WP 6.5+). * * @param string $file Path. * @param string $domain Domain. * @param string $locale Locale. * @return string */ public function translation_file( $file, $domain, $locale = '' ) { if ( 'nuxwp-devis' !== $domain || ! $file ) { return $file; } $forced = self::forced_locale(); return $forced ? self::swap_file_locale( $file, $forced ) : $file; } /** * load_textdomain_mofile filter (WP < 6.5). * * @param string $mofile Path. * @param string $domain Domain. * @return string */ public function mofile( $mofile, $domain ) { if ( 'nuxwp-devis' !== $domain || ! $mofile || function_exists( 'wp_translation_controller' ) ) { return $mofile; // 6.5+ handled by translation_file(). } $forced = self::forced_locale(); return $forced ? self::swap_file_locale( $mofile, $forced ) : $mofile; } /** * Customer-facing output (PDF, e-mails) must use the site language, never the admin's choice. * Reloads the plugin text domain without the forced locale; call end_customer_output() after. */ public static function begin_customer_output() { self::$customer_output = true; unload_textdomain( 'nuxwp-devis', true ); } /** * Back to the admin's forced locale (if any). */ public static function end_customer_output() { self::$customer_output = false; unload_textdomain( 'nuxwp-devis', true ); } /** * pre_load_script_translations filter (JS JSON): swap the locale in the file name. * * @param string|false|null $translations Pre value. * @param string|false $file File path. * @param string $handle Handle. * @param string $domain Domain. * @return string|false|null */ public function script_translations( $translations, $file, $handle, $domain ) { if ( 'nuxwp-devis' !== $domain || ! $file ) { return $translations; } $forced = self::forced_locale(); if ( ! $forced ) { return $translations; } if ( 'en_US' === $forced ) { return false; // English = source strings. } $swapped = preg_replace( '/-[a-z]{2,3}(_[A-Z]{2})?(_[a-z]+)?-([0-9a-f]{32})\.json$/', '-' . $forced . '-$3.json', $file ); if ( $swapped && $swapped !== $file && is_readable( $swapped ) ) { return file_get_contents( $swapped ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } if ( is_readable( $file ) && false !== strpos( $file, '-' . $forced . '-' ) ) { return $translations; } return false; } }