handling is inconsistent across versions. * * @package sureforms. * @since 2.11.0 */ namespace SRFM\Inc\Compatibility\Multilingual; use SRFM\Inc\Helper; use SRFM\Inc\Traits\Get_Instance; use SRFM\Inc\Translatable; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } /** * String_Collector. * * Walks form metadata at save time and registers every translatable string with * the active multilingual provider. Acts as a belt-and-suspenders safeguard * against inconsistencies in WPML's declarative handling. * * @since 2.11.0 */ class String_Collector { use Get_Instance; /** * The form's String Package descriptor for the current collect() run, or null * when no package run is active. * * @since 2.11.0 * @var array|null */ private $active_package = null; /** * Whether the active provider supports String Packages (decided once per run). * * @since 2.11.0 * @var bool */ private $packages_supported = false; /** * Running 1-based index of the current field within a form, used to build the * "Fields/ #" group path shown in the Translation Editor. Reset at the * start of each form's block walk. * * @since 2.11.0 * @var int */ private $field_index = 0; /** * Constructor. Hooks into save_post for the SureForms form post type. * * Uses priority 20 so this runs after WordPress's own save processing and * any default meta updates from the editor request. * * @since 2.11.0 */ public function __construct() { add_action( 'save_post_' . SRFM_FORMS_POST_TYPE, [ $this, 'on_form_save' ], 20, 1 ); // Prune the form's String Package when the form is permanently deleted so // orphaned packages and their translations don't linger. Fires only on // permanent delete, not on trash (a trashed form may be restored). add_action( 'before_delete_post', [ $this, 'on_form_delete' ], 10, 1 ); // Register the GLOBAL built-in validation strings once per admin request // (no-op when no multilingual provider is active). These strings are not // per-form, so they belong on an admin/authoring hook rather than on every // frontend request. WPML dedupes by (domain, name, value), so re-asserting // on each admin load is cheap and idempotent, and covers fresh installs and // the WPML-activated-after-SureForms case. if ( is_admin() ) { add_action( 'admin_init', [ $this, 'collect_validation_messages' ] ); } // Declare our String Package kind so WPML's "Translate Everything // Automatically" gate — which reads this filter, not just the per-package // post association — queues SureForms form packages for auto-translation. // Registered unconditionally: WPML only fires this filter when active, and // the callback is a pure array append, so it is a no-op otherwise. add_filter( 'wpml_active_string_package_kinds', [ $this, 'declare_package_kind' ] ); } /** * Declare the SureForms String Package kind to WPML. * * WPML's package-level "Translate Everything Automatically" gate reads this * filter to decide which string-package kinds to auto-translate. Each form is * registered as one package with kind {@see String_Translator::PACKAGE_KIND}, * from which WPML derives the kind slug via `sanitize_title()`; computing the * key the same way here guarantees it matches the slug WPML assigns to our * packages (no hardcoded slug that could drift from the kind label). * * @param mixed $kinds Associative map of kind slug => { title, slug, plural }. * @since 2.12.3 * @return mixed The kinds map with the SureForms Form kind added. */ public function declare_package_kind( $kinds ) { if ( ! is_array( $kinds ) ) { return $kinds; } $slug = sanitize_title( String_Translator::PACKAGE_KIND ); $kinds[ $slug ] = [ 'title' => String_Translator::PACKAGE_KIND, 'slug' => $slug, 'plural' => __( 'SureForms Forms', 'sureforms' ), ]; return $kinds; } /** * Entry point hooked to save_post_{post_type}. * * Skips autosaves and revisions, and bails when no multilingual provider is * active. Otherwise delegates to {@see collect()} to register all strings. * * @param int $form_id The form post ID being saved. * @since 2.11.0 * @return void */ public function on_form_save( int $form_id ): void { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( wp_is_post_revision( $form_id ) ) { return; } $provider = Multilingual_Manager::get_instance()->provider(); if ( ! $provider->is_active() ) { return; } $this->collect( $form_id ); } /** * Delete the form's String Package when the form is permanently deleted. * * Hooked to before_delete_post (not trash) so a package is only removed when * its form is gone for good. Bails for other post types and when no provider * is active. * * @param int $form_id The post ID being deleted. * @since 2.12.3 * @return void */ public function on_form_delete( int $form_id ): void { if ( SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) { return; } $provider = Multilingual_Manager::get_instance()->provider(); if ( ! $provider->is_active() ) { return; } // delete_package() is intentionally absent from the Provider interface (see the // note there): declaring it would fatal any third-party provider written against // 2.11.0-2.12.2. Feature-detect instead, so a custom provider without it simply // skips cleanup rather than crashing. if ( ! method_exists( $provider, 'delete_package' ) ) { return; } // Deletion only needs the package identity (name + kind); build it directly // rather than String_Translator::form_package(), which also runs // get_the_title() / get_edit_post_link() the delete path doesn't use. $provider->delete_package( [ 'name' => (string) $form_id, 'kind' => String_Translator::PACKAGE_KIND, ] ); } /** * Walk the form and register every translatable string with the provider. * * Public so unit tests can exercise the collection logic directly without * needing to fire the save_post action, and so migration code paths can * back-fill strings for existing forms. * * @param int $form_id The form post ID. * @since 2.11.0 * @since 2.12.3 Returns whether collection actually ran, so callers (notably the * backfill) can distinguish "collected" from "silently skipped because * the provider went inactive" and avoid recording false progress. * @return bool True when the form's strings were registered, false when the provider * was unavailable and nothing was done. */ public function collect( int $form_id ): bool { $provider = Multilingual_Manager::get_instance()->provider(); if ( ! $provider->is_active() ) { return false; } // Group every per-form string into a single WPML String Package so they // surface together under the form in the Translation Editor (instead of as // flat, global String-Translation entries). start/finish bracket the // registrations so strings for deleted fields are pruned automatically. $package = String_Translator::form_package( $form_id ); $this->packages_supported = $provider->supports_packages(); $this->active_package = $package; if ( $this->packages_supported ) { $provider->start_package( $package ); } // Form title (post title) — shown as a heading on the form and as the // instant-form banner, so it is translatable like any other string. $this->register_form_string( $form_id, String_Translator::title_name(), Helper::get_string_value( get_the_title( $form_id ) ), // "Group: Leaf" — WPML splits on ': ' to nest this under a Settings group. __( 'Settings', 'sureforms' ) . ': ' . __( 'Form title', 'sureforms' ) ); // Submit button text. $this->register_form_string( $form_id, String_Translator::submit_button_name(), $this->get_meta_string( $form_id, '_srfm_submit_button_text' ), // "Group: Leaf" — WPML splits on ': ' to nest this under a Settings group. __( 'Settings', 'sureforms' ) . ': ' . __( 'Submit button text', 'sureforms' ) ); // Form confirmations — stored as a nested array (get_post_meta without $single = true). $confirmations_raw = get_post_meta( $form_id, '_srfm_form_confirmation' ); $confirmations = is_array( $confirmations_raw ) && isset( $confirmations_raw[0] ) && is_array( $confirmations_raw[0] ) ? $confirmations_raw[0] : []; foreach ( $confirmations as $index => $confirmation ) { if ( ! is_array( $confirmation ) ) { continue; } $message = isset( $confirmation['message'] ) ? Helper::get_string_value( $confirmation['message'] ) : ''; $this->register_form_string( $form_id, String_Translator::confirmation_name( (int) $index ), $message, /* translators: %d is the confirmation number. */ __( 'Confirmations', 'sureforms' ) . '/' . sprintf( __( 'Confirmation #%d', 'sureforms' ), (int) $index + 1 ) . ': ' . __( 'Message', 'sureforms' ), 'AREA' ); } // Email notifications — same nested-array shape as confirmations. $notifications_raw = get_post_meta( $form_id, '_srfm_email_notification' ); $notifications = is_array( $notifications_raw ) && isset( $notifications_raw[0] ) && is_array( $notifications_raw[0] ) ? $notifications_raw[0] : []; foreach ( $notifications as $index => $notification ) { if ( ! is_array( $notification ) ) { continue; } // reply_to is an email address (or a smart tag resolving to one), not // human-readable copy, so it is intentionally excluded from the // translatable set. from_name can legitimately be a localized display name. // [ meta key, label, editor type ]. The string name stays keyed on // 'body' for continuity, but the value has to be read from // `email_body` - that is what _srfm_email_notification's sanitize // callback stores, so reading 'body' always found nothing and the // message body was never registered for translation at all. $fields = [ 'subject' => [ 'subject', __( 'Subject', 'sureforms' ), 'LINE' ], 'body' => [ 'email_body', __( 'Message body', 'sureforms' ), 'AREA' ], 'from_name' => [ 'from_name', __( '"From" name', 'sureforms' ), 'LINE' ], ]; /* translators: %d is the notification number. */ $notification_group = __( 'Notifications', 'sureforms' ) . '/' . sprintf( __( 'Notification #%d', 'sureforms' ), (int) $index + 1 ); foreach ( $fields as $field => $meta ) { $meta_key = $meta[0]; $value = isset( $notification[ $meta_key ] ) ? Helper::get_string_value( $notification[ $meta_key ] ) : ''; $this->register_form_string( $form_id, String_Translator::notification_name( (int) $index, $field ), $value, $notification_group . ': ' . $meta[1], $meta[2] ); } } // Form restriction — JSON-encoded string in single meta. $restriction_raw = $this->get_meta_string( $form_id, '_srfm_form_restriction' ); if ( '' !== $restriction_raw ) { $restriction = json_decode( $restriction_raw, true ); if ( is_array( $restriction ) && isset( $restriction['message'] ) ) { $message = Helper::get_string_value( $restriction['message'] ); $this->register_form_string( $form_id, String_Translator::restriction_name(), $message, __( 'Settings', 'sureforms' ) . ': ' . __( 'Form restriction message', 'sureforms' ), 'AREA' ); } } // Block-attribute strings (field labels, placeholders, option labels, etc.). $this->collect_block_strings( $form_id ); if ( $this->packages_supported ) { $provider->finish_package( $package ); } $this->active_package = null; return true; } /** * Register every built-in dynamic validation message with the multilingual * provider, using the raw English source as the value. Names follow * {@see String_Translator::translate_validation_message()}: `validation_{key}`. * * These strings are global (not per-form), so registration runs on `admin_init` * (see the constructor) rather than on the front end. Idempotent — WPML's * `wpml_register_single_string` action deduplicates by (domain, name, value), * so re-asserting on each admin load is safe. Bails when no provider is active. * * @since 2.11.0 * @return void */ public function collect_validation_messages(): void { $provider = Multilingual_Manager::get_instance()->provider(); if ( ! $provider->is_active() ) { return; } foreach ( Translatable::dynamic_messages_source() as $key => $value ) { if ( ! is_string( $key ) || ! is_string( $value ) ) { continue; } $this->register_if_non_empty( 'validation_' . $key, $value ); } // Common strings rendered server-side in field markup that are NOT stored // per-form (so they never reach a form package): JS-validation fallbacks // and shared field defaults such as the dropdown's empty-state placeholder, // which is only serialized into block markup when a site owner overrides it. $common = [ 'srfm_required_field' => 'This field is required.', 'srfm_unique_field' => 'Value needs to be unique.', 'srfm_submit_error' => 'There was an error trying to submit your form. Please try again.', 'srfm_dropdown_placeholder' => 'Select an option', ]; foreach ( $common as $key => $value ) { $this->register_if_non_empty( 'validation_' . $key, $value ); } } /** * Walk the form's parsed blocks and register every translatable block attribute * with the active multilingual provider. * * Names use the same scheme that {@see String_Translator::translate_block_attribute()} * and {@see String_Translator::translate_block_option_label()} consume at render time, * so the collector and the translator stay in lockstep: * * - `form_{form_id}_block_{block_id}_{attribute}` * - `form_{form_id}_block_{block_id}_option_{N}_label` * * Bails early when the form has no block content. * * @param int $form_id The form post ID. * @since 2.11.0 * @return void */ public function collect_block_strings( int $form_id ): void { $post = get_post( $form_id ); if ( ! $post instanceof \WP_Post || '' === trim( $post->post_content ) ) { return; } $blocks = parse_blocks( $post->post_content ); if ( empty( $blocks ) ) { return; } // Self-frame the package when called directly (not from collect()), so the // block strings are still grouped + pruned. collect() leaves $active_package // set, in which case we reuse its framing. $standalone = null === $this->active_package; if ( $standalone ) { $provider = Multilingual_Manager::get_instance()->provider(); $this->packages_supported = $provider->supports_packages(); $this->active_package = String_Translator::form_package( $form_id ); if ( $this->packages_supported ) { $provider->start_package( $this->active_package ); } } $this->field_index = 0; $this->walk_blocks_for_collection( $form_id, $blocks ); if ( $standalone ) { if ( $this->packages_supported && is_array( $this->active_package ) ) { Multilingual_Manager::get_instance()->provider()->finish_package( $this->active_package ); } $this->active_package = null; } } /** * Recursively iterate parsed blocks and register translatable strings. * * @param int $form_id Form post ID. * @param array> $blocks Parsed-blocks structure. * @since 2.11.0 * @return void */ protected function walk_blocks_for_collection( int $form_id, array $blocks ): void { $attribute_map = String_Translator::translatable_block_attributes(); $option_blocks = String_Translator::translatable_option_blocks(); foreach ( $blocks as $block ) { $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : ''; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; $block_id = isset( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ? $attrs['block_id'] : ''; $has_attributes = '' !== $block_id && isset( $attribute_map[ $block_name ] ); $has_options = '' !== $block_id && in_array( $block_name, $option_blocks, true ) && isset( $attrs['options'] ) && is_array( $attrs['options'] ); // Build the "Fields/ #" group path once per translatable field, // so a field's label/placeholder/help/options nest together under one // node in the Translation Editor. The block TYPE (not the user label) is // used so the path can't be corrupted by '/' or ': ' in user content. $field_group = ''; if ( $has_attributes || $has_options ) { $this->field_index++; $field_group = sprintf( '%s/%s #%d', __( 'Fields', 'sureforms' ), String_Translator::block_type_label( $block_name ), $this->field_index ); } if ( $has_attributes ) { foreach ( $attribute_map[ $block_name ] as $attribute_key ) { if ( ! isset( $attrs[ $attribute_key ] ) || ! is_string( $attrs[ $attribute_key ] ) ) { continue; } $this->register_form_string( $form_id, String_Translator::block_attribute_name( $block_id, $attribute_key ), $attrs[ $attribute_key ], $field_group . ': ' . $this->attribute_label( $attribute_key ) ); } } if ( $has_options ) { foreach ( $attrs['options'] as $option_index => $option ) { if ( ! is_array( $option ) || ! isset( $option['label'] ) || ! is_string( $option['label'] ) ) { continue; } $this->register_form_string( $form_id, String_Translator::block_option_name( $block_id, (int) $option_index ), $option['label'], /* translators: %d is the option number. */ $field_group . ': ' . sprintf( __( 'Option %d', 'sureforms' ), (int) $option_index + 1 ) ); } } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $this->walk_blocks_for_collection( $form_id, $block['innerBlocks'] ); } } } /** * Read a single post meta value defensively as a string. * * @param int $form_id The form post ID. * @param string $key Meta key to read. * @since 2.11.0 * @return string Meta value coerced to string, or empty string when missing. */ private function get_meta_string( int $form_id, string $key ): string { return Helper::get_string_value( get_post_meta( $form_id, $key, true ) ); } /** * Register a string with the active provider, skipping empty values. * * @param string $name Unique string identifier. * @param string $value Original string value. * @since 2.11.0 * @return void */ private function register_if_non_empty( string $name, string $value ): void { if ( ! is_string( $value ) || '' === trim( $value ) ) { return; } Multilingual_Manager::get_instance()->provider()->register_string( $name, $value ); } /** * Register a per-form string into the active String Package, skipping empties. * * Falls back to a flat String-Translation string (legacy `form_{id}_{name}` * name) when the provider can't do packages, matching * {@see String_Translator::dispatch_package()} so registration and render stay * in lockstep on either path. * * @param int $form_id Form post ID. * @param string $name Package-scoped string name (see String_Translator *_name() builders). * @param string $value Original string value. * @param string $title Human-readable label shown in the Translation Editor. * @param string $type Editor field type: LINE, AREA or VISUAL. * @since 2.11.0 * @return void */ private function register_form_string( int $form_id, string $name, string $value, string $title = '', string $type = 'LINE' ): void { if ( ! is_string( $value ) || '' === trim( $value ) ) { return; } $provider = Multilingual_Manager::get_instance()->provider(); if ( $this->packages_supported && is_array( $this->active_package ) ) { $provider->register_package_string( $this->active_package, $name, $value, $title, $type ); return; } // Legacy / non-package fallback: flat string with the form-scoped name. $provider->register_string( 'form_' . $form_id . '_' . $name, $value ); } /** * Human-readable label for a block attribute, shown in the Translation Editor. * * @param string $attribute Block attribute key. * @since 2.11.0 * @return string */ private function attribute_label( string $attribute ): string { $labels = [ 'label' => __( 'Label', 'sureforms' ), 'placeholder' => __( 'Placeholder', 'sureforms' ), 'help' => __( 'Help text', 'sureforms' ), 'errorMsg' => __( 'Error message', 'sureforms' ), 'defaultValue' => __( 'Default value', 'sureforms' ), 'duplicateMsg' => __( 'Duplicate message', 'sureforms' ), 'confirmLabel' => __( 'Confirm label', 'sureforms' ), 'prefix' => __( 'Prefix', 'sureforms' ), 'suffix' => __( 'Suffix', 'sureforms' ), 'buttonText' => __( 'Button text', 'sureforms' ), 'amountLabel' => __( 'Amount label', 'sureforms' ), 'paymentDescription' => __( 'Payment description', 'sureforms' ), 'oneTimeLabel' => __( 'One-time label', 'sureforms' ), 'subscriptionLabel' => __( 'Subscription label', 'sureforms' ), ]; return $labels[ $attribute ] ?? $attribute; } }