diff --git a/README.md b/README.md index 468a5a71e..4d24eb801 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# SureForms - Contact Form, Payment Form, Survey & Other Custom Form Builder # +# SureForms - Drag & Drop Contact Form, Payment Form, Survey, Quiz, Calculator & Custom Form Builder # **Contributors:** [brainstormforce](https://profiles.wordpress.org/brainstormforce/) -**Tags:** forms, contact form, form builder, payment form, WordPress form plugin +**Tags:** forms, contact form, form builder, survey, payment form **Requires at least:** 6.4 **Tested up to:** 7.0 **Requires PHP:** 7.4 -**Stable tag:** 2.10.1 +**Stable tag:** 2.11.0 **License:** GPLv2 or later **License URI:** http://www.gnu.org/licenses/gpl-2.0.html @@ -73,7 +73,7 @@ SureForms is the first AI-powered form builder for WordPress, making form creati - Calculators - Conversational Forms -#### No-Code Form Builder with Advanced Logic +#### Conditional Logic Forms - No-Code Drag & Drop Form Builder SureForms makes advanced features like conditional logic, multi-step forms, query parameters, and more simple to use through a no-code interface. This empowers anyone to create powerful, interactive WordPress forms without writing a single line of code. @@ -82,10 +82,10 @@ SureForms makes advanced features like conditional logic, multi-step forms, quer SureForms supports a built-in **Payment Forms** feature. With SureForms, anyone can easily create full-fledged donation forms, event registrations, product checkouts, or any kind of form that needs to accept payments — all without relying on any extra add-on or third-party plugins. This makes collecting payments seamless, secure, and effortless directly through your WordPress forms using Stripe. PayPal payments are available with SureForms Business. Here are a few examples of how the Payment Forms feature in SureForms can power your forms: -- [Donation Form](https://sureforms.com/form/donation-form/) -- [Photography Packages Booking Form](https://sureforms.com/form/photography-packages-payment-form/) -- [Monthly Membership Plan Form](https://sureforms.com/form/monthly-membership-plan/) -- [Web Design Packages Booking Form](https://sureforms.com/form/freelance-web-design-packages-payment/) +- [Donation Form](https://sureforms.com/form/donation-form/?utm_source=wordpress.org&utm_medium=donation_form_example) +- [Photography Packages Booking Form](https://sureforms.com/form/photography-packages-payment-form/?utm_source=wordpress.org&utm_medium=photography_payment_example) +- [Monthly Membership Plan Form](https://sureforms.com/form/monthly-membership-plan/?utm_source=wordpress.org&utm_medium=membership_plan_example) +- [Web Design Packages Booking Form](https://sureforms.com/form/freelance-web-design-packages-payment/?utm_source=wordpress.org&utm_medium=web_design_payment_example) #### Instant Forms (Shareable Form Links — No Embed Needed) @@ -130,9 +130,9 @@ Real-time validation for form fields provides instant feedback to users if they Ensure your forms are GDPR compliant and protect user data by simply enabling the built-in GDPR setting. -#### Anti-Spam Forms (reCAPTCHA + Honeypot) +#### Anti-Spam Forms (reCAPTCHA, hCaptcha, Cloudflare Turnstile & Honeypot) -Protect your WordPress forms from spam with built-in measures like Google reCAPTCHA and Honeypot fields. These ensure submissions are genuine, block bots, and keep your forms secure without bothering your users. +Protect your WordPress forms from spam with built-in measures including Google reCAPTCHA, hCaptcha, Cloudflare Turnstile, and Honeypot fields. These ensure submissions are genuine, block bots, and keep your WordPress contact forms secure without bothering your users. #### Personalized Confirmation Messages and Redirects @@ -156,27 +156,44 @@ Analyze and gain insights from form submissions through interactive charts, grap SureForms gives developers clean APIs, hooks, and well-structured code, making it easy to customize, extend, and integrate forms with other systems, plugins, or themes. +#### Multilingual WordPress Forms - Built-In Translations + +SureForms ships with built-in translations for 7 languages. No extra plugins, no manual setup. Install SureForms and it works immediately in: + +- German (Deutsch) +- Spanish (Español) +- French (Français) +- Italian (Italiano) +- Dutch (Nederlands) +- Polish (Polski) +- Portuguese (Português) + +All form labels, admin UI, block editor controls, and error messages are fully translated. Need another language? SureForms is fully translation-ready and compatible with WPML, Polylang, and TranslatePress. + ### Premium Features of SureForms 🔥 SureForms Business unlocks advanced capabilities for power users, agencies, and businesses. Here's everything you get: #### Advanced Form Types -[**Conversational Forms**](https://sureforms.com/features/conversational-form/): +[**Conversational Forms**](https://sureforms.com/features/conversational-form/?utm_source=wordpress.org&utm_medium=conversational_form): Chat-like forms, one question at a time, for higher conversions and lower drop-off rates. -[**Multi-step Forms**](https://sureforms.com/features/multi-step-forms/): +[**Multi-step Forms**](https://sureforms.com/features/multi-step-forms/?utm_source=wordpress.org&utm_medium=multistep_forms): Bite-sized forms with progress indicators, reducing form fatigue and boosting completion rates. -[**Quiz Forms**](https://sureforms.com/features/quiz/): +[**Quiz Forms**](https://sureforms.com/features/quiz/?utm_source=wordpress.org&utm_medium=quiz): Create scored quizzes and personality tests with result output and grading. -[**Conditional Logic Forms**](https://sureforms.com/features/conditional-logic/): +[**Conditional Logic Forms**](https://sureforms.com/features/conditional-logic/?utm_source=wordpress.org&utm_medium=conditional_logic): Show or hide fields, customize email notifications, and display different confirmation messages based on users' answers. -[**Calculator Forms**](https://sureforms.com/features/calculator-form/): +[**Calculator Forms**](https://sureforms.com/features/calculator-form/?utm_source=wordpress.org&utm_medium=calculator_form): Provide instant quotes or results with interactive calculators on your site — designed to bring more traffic and conversions. +[**Survey & Poll Forms**](https://sureforms.com/features/surveys-and-polls/?utm_source=wordpress.org&utm_medium=surveys_polls): +Collect structured feedback, run opinion polls, and measure customer loyalty with the built-in Net Promoter Score (NPS) field. + #### Pro Field Types SureForms Business adds these powerful field types on top of the 15+ free fields: @@ -194,16 +211,22 @@ SureForms Business adds these powerful field types on top of the 15+ free fields #### Advanced Features -[**PDF Generation**](https://sureforms.com/features/pdf-generator/): +[**PDF Generation**](https://sureforms.com/features/pdf-generator/?utm_source=wordpress.org&utm_medium=pdf_generation): Instantly create downloadable PDFs of form submissions for records or sharing. -[**Save & Resume**](https://sureforms.com/features/save-and-resume/): +[**Save & Resume**](https://sureforms.com/features/save-and-resume/?utm_source=wordpress.org&utm_medium=save_and_resume): Let users save their progress and come back to complete the form later. -[**Custom Registration & Login Forms**](https://sureforms.com/features/login-and-registration/): +**Partial Entries**: +Capture in-progress form submissions automatically — recover abandoned forms and follow up with users who dropped off before completing. + +**Form Restrictions:** +Restrict form visibility and submissions by IP address, country, keywords, user login status, or password. Set submission limits per day, week, month, year, or lifetime. + +[**Custom Registration & Login Forms**](https://sureforms.com/features/login-and-registration/?utm_source=wordpress.org&utm_medium=registration_login): Fully functional, branded Registration and Login forms for your WordPress site using just a block. -**Custom Post Type Creation**: +[**Custom Post Type Creation**](https://sureforms.com/features/post-creation/?utm_source=wordpress.org&utm_medium=post_creation): Create WordPress posts or custom post types directly from form submissions. **Field Restrictions:** @@ -220,7 +243,7 @@ Customize form appearance with advanced styling options when embedding forms via #### Integrations & Automation -[**Advanced Native Integrations**](https://sureforms.com/integrations/): +[**Advanced Native Integrations**](https://sureforms.com/integrations/?utm_source=wordpress.org&utm_medium=integrations): Connect your forms natively with top apps and services to automate workflows and save time — no third-party add-ons or plugins required. SureForms connects natively with: @@ -340,6 +363,8 @@ Don't think this is just it. There is no limit to the types of businesses and or ### CONNECT WITH OUR TEAM AND COMMUNITY +[Follow us on Facebook](https://www.facebook.com/sureforms) for the latest SureForms news, feature releases, and updates. + [Join our Facebook group community](https://www.facebook.com/groups/surecart): Learn the tricks and techniques that other SureForms users are using to grow their business. Also, get exciting insider information on upcoming feature releases. @@ -375,7 +400,7 @@ To improve the user experience, SureForms may use the following 3rd party servic ## Blocks ## -This plugin provides 16 blocks for all users: +This plugin provides 16 blocks for all users, with additional advanced blocks available in SureForms Business: - **Address Field:** Add a full-address input to your WordPress contact form, with line 1, line 2, city, state, postal code, and country. @@ -475,6 +500,30 @@ SureForms connects natively with services including Google Sheets, Mailchimp, Hu Yes. SureForms has built-in Payment Form support. You can collect payments securely through Stripe in the free version. PayPal payments are available with SureForms Business. No add-ons or extra plugins needed. +### How is SureForms different from WPForms? ### + +SureForms and WPForms are both powerful WordPress form builders, but they differ on pricing and architecture. WPForms requires the $199/yr Pro tier for multi-step forms and higher tiers for conversational forms and advanced features. SureForms includes every feature AI form generation, conditional logic, multi-step forms, conversational forms, payment forms, quizzes, and surveys, on every paid plan from $59/yr with no feature-gating. SureForms is also built natively on the Gutenberg block editor, while WPForms uses a separate proprietary interface. Read the full comparison: [SureForms vs WPForms](https://sureforms.com/sureforms-vs-wpforms/?utm_source=wordpress.org&utm_medium=wpforms_comparison). + +### How is SureForms different from Fluent Forms? ### + +Both are capable WordPress form plugins. SureForms differentiates with built-in AI form generation (no API key needed), native Gutenberg block editor integration, and flat pricing where every feature is available on every paid plan. SureForms also ships with built-in translations for 7 languages out of the box and stores all form data exclusively on your own site. No cloud storage. Read the full comparison: [SureForms vs Fluent Forms](https://sureforms.com/sureforms-vs-fluent-forms/?utm_source=wordpress.org&utm_medium=fluentform_comparison). + +### Is SureForms a good Contact Form 7 alternative? ### + +Yes. SureForms is one of the best Contact Form 7 alternatives for WordPress. Unlike Contact Form 7, SureForms requires no shortcodes or manual HTML. You build forms visually using the Gutenberg block editor. SureForms also adds payment forms, AI form generation, conditional logic, multi-step forms, conversational forms, file uploads, and 25+ native integrations.All features Contact Form 7 requires add-ons or custom code to replicate. Read the full comparison: [SureForms vs Contact Form 7](https://sureforms.com/news/contact-form-7-alternative-sureforms/?utm_source=wordpress.org&utm_medium=cf7_comparison). + +### Can I build a calculator form in WordPress with SureForms? ### + +Yes. SureForms Business includes a dedicated Calculator Forms feature. You can build pricing calculators, quote generators, cost estimators, BMI calculators, and any other calculation-based form using the built-in formula builder. No coding required. Dynamic values can be shown to users in real time as they fill the form. + +### Does SureForms work with Gutenberg / the WordPress block editor? ### + +Yes. SureForms is built entirely on the WordPress block editor (Gutenberg). Every form field is a native Gutenberg block. This means your forms inherit your theme's typography, colors, and spacing automatically, and you build forms using the same editor you already use to build pages. There is no separate interface to learn, no proprietary canvas, and no page builder required. + +### Can I create a registration form or login form with SureForms? ### + +Yes. SureForms Business includes fully functional user registration forms and login forms for WordPress. You can create branded registration and login experiences using the block editor. No coding required. Registration forms support custom user fields and role assignment. + ## Screenshot ## 1. SureForms dashboard — manage all your WordPress forms in one place with form analytics, entries, and quick actions. @@ -486,6 +535,9 @@ Yes. SureForms has built-in Payment Form support. You can collect payments secur You can report security issues through our [Bug Bounty Program](https://brainstormforce.com/bug-bounty-program/). We collaborate with Patchstack to provide opportunities for researchers to report vulnerabilities. The Patchstack team will help validate, triage, and handle any reported security issues. ## Changelog ## +### 2.11.0 - 10th June 2026 ### +* New: Added a Form Migrator to import forms from Contact Form 7, WPForms, Gravity Forms, and Ninja Forms in a single click. +* New: Added native WPML support to translate each form individually using String Packages. ### 2.10.1 - 1st June 2026 ### * Improvement: Improved compatibility with older Block API versions so the SureForms editor loads reliably across WordPress environments. * Fix: Resolved an issue where the Textarea character counter was misaligned. @@ -496,9 +548,7 @@ You can report security issues through our [Bug Bounty Program](https://brainsto * Improvement: Added dynamic default value support for multi-select Dropdown and Multi-Choice fields. * Improvement: Entry URL now records actual submission page. * Fix: CSV export was stripping line breaks from Textarea submissions. -### 2.9.1 - 19th May 2026 ### -* Improvement: Added compatibility with WordPress 7.0. -The full changelog is available [here](https://sureforms.com/whats-new/). +The full changelog is available [here](https://sureforms.com/whats-new/?utm_source=wordpress.org&utm_medium=whats_new). ## Upgrade Notice ## diff --git a/admin/admin.php b/admin/admin.php index c8e5efd78..0f33e81d5 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -128,6 +128,79 @@ public function __construct() { add_action( 'admin_init', [ $this, 'save_first_form_creation_time_stamp' ] ); add_action( 'admin_notices', [ $this, 'display_srfm_rating_notice' ] ); add_action( 'admin_notices', [ $this, 'display_srfm_getting_started_notice' ] ); + + /** + * Suppress foreign (third-party) admin notices on SureForms admin screens. + * + * Some plugins (e.g. Ninja Forms) print large promotional banners on every + * admin page via the admin_notices / all_admin_notices / network_admin_notices + * hooks. These bleed onto SureForms' own React admin screens and break the UI. + * We run at the EARLIEST priority on each notice hook (all third-party + * callbacks are registered before these hooks fire, during admin_init / + * plugin load) and strip the foreign ones before they are echoed, while + * preserving SureForms' own notices. Scoped strictly to SureForms screens. + */ + add_action( 'admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN ); + add_action( 'all_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN ); + add_action( 'network_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN ); + } + + /** + * Remove third-party admin notices on SureForms admin screens. + * + * Iterates over the callbacks registered on the admin notice hooks and + * removes any that are not owned by SureForms. A callback is considered + * owned by SureForms when it belongs to a class in the `SRFM` / `SRFM_PRO` + * namespaces or to the bundled `BSF_Admin_Notices` / `Astra_Notices` + * notices library. SureForms' own notices are therefore preserved while + * foreign promotional banners are suppressed. + * + * This callback is hooked at `PHP_INT_MIN` so that it runs first on each + * notice hook and removes the foreign callbacks before WordPress echoes + * them (WP_Hook honours removals made during iteration). It is strictly + * scoped to SureForms admin screens via {@see Helper::is_sureforms_admin_page()} + * so no other admin page is affected. + * + * @since 2.10.0 + * @return void + */ + public function suppress_foreign_admin_notices() { + // Bail early if we are not on a SureForms admin screen. This keeps the + // suppression strictly scoped and avoids touching any other admin page. + // is_sureforms_admin_page() covers the core screens (dashboard, add-new, + // settings, entries, the form CPT); we additionally match any admin page + // whose `page` slug is SureForms-owned (sureforms_* / srfm_*) so the + // suppression also applies to the payments/quiz/survey/learn/SMTP screens. + if ( ! Helper::is_sureforms_admin_page() && ! $this->is_sureforms_owned_admin_page() ) { + return; + } + + global $wp_filter; + + // The hook currently being fired (admin_notices, all_admin_notices or network_admin_notices). + $current_hook = current_action(); + + if ( empty( $current_hook ) || empty( $wp_filter[ $current_hook ] ) || ! ( $wp_filter[ $current_hook ] instanceof \WP_Hook ) ) { + return; + } + + foreach ( $wp_filter[ $current_hook ]->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + $function = $callback['function'] ?? null; + + // Never remove our own suppression callback. + if ( is_array( $function ) && isset( $function[0] ) && $function[0] === $this && 'suppress_foreign_admin_notices' === $function[1] ) { + continue; + } + + // Preserve SureForms-owned notices, remove everything else. + if ( $this->is_sureforms_owned_notice_callback( $function ) ) { + continue; + } + + remove_action( $current_hook, $function, $priority ); + } + } } /** @@ -946,6 +1019,8 @@ public function enqueue_scripts() { 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ), 'is_rtl' => $is_rtl, 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false, + 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false, + 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ), 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection. 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ), 'general_settings_url' => admin_url( '/options-general.php' ), @@ -1931,6 +2006,63 @@ public function render_dashboard_widget() { [ 'type' => 'string', 'description' => __( 'Column to order results by.', 'sureforms' ), - 'enum' => [ 'created_at', 'ID', 'form_id', 'status' ], + 'enum' => [ 'created_at', 'ID', 'form_id', 'status', 'language' ], 'default' => 'created_at', ], 'order' => [ @@ -128,6 +128,7 @@ public function get_output_schema() { 'form_id' => [ 'type' => 'integer' ], 'form_title' => [ 'type' => 'string' ], 'status' => [ 'type' => 'string' ], + 'language' => [ 'type' => 'string' ], 'created_at' => [ 'type' => 'string' ], ], ], @@ -181,6 +182,7 @@ public function execute( $input ) { 'form_id' => $form_id, 'form_title' => $form_title, 'status' => $entry['status'] ?? '', + 'language' => Helper::get_string_value( $entry['language'] ?? '' ), 'created_at' => $entry['created_at'] ?? '', ]; } diff --git a/inc/abilities/forms/form-field-schema.php b/inc/abilities/forms/form-field-schema.php index 7c09c8681..4efbd1416 100644 --- a/inc/abilities/forms/form-field-schema.php +++ b/inc/abilities/forms/form-field-schema.php @@ -107,6 +107,10 @@ protected function get_form_field_schema() { 'type' => 'string', 'description' => __( 'Placeholder text shown inside the empty input/textarea or as the empty first option in a dropdown.', 'sureforms' ), ], + 'className' => [ + 'type' => 'string', + 'description' => __( 'Additional CSS class(es) for the field wrapper. Space-separate multiple classes, e.g. "vk-0 highlight".', 'sureforms' ), + ], ]; /** @@ -151,6 +155,12 @@ protected function sanitize_form_fields( array $fields ) { if ( isset( $field['placeholder'] ) && is_string( $field['placeholder'] ) ) { $fields[ $index ]['placeholder'] = sanitize_text_field( $field['placeholder'] ); } + if ( isset( $field['className'] ) && is_string( $field['className'] ) ) { + $classes = preg_split( '/\s+/', trim( $field['className'] ) ); + $fields[ $index ]['className'] = is_array( $classes ) + ? implode( ' ', array_filter( array_map( 'sanitize_html_class', $classes ) ) ) + : ''; + } if ( ! empty( $field['fieldOptions'] ) && is_array( $field['fieldOptions'] ) ) { // @phpstan-ignore-next-line -- $field['fieldOptions'] is validated as array above. $field_options = $field['fieldOptions']; diff --git a/inc/ai-form-builder/field-mapping.php b/inc/ai-form-builder/field-mapping.php index a032803b5..e7337991a 100644 --- a/inc/ai-form-builder/field-mapping.php +++ b/inc/ai-form-builder/field-mapping.php @@ -126,6 +126,20 @@ public static function generate_gutenberg_fields_from_questions( $request ) { $merged_attributes['placeholder'] = wp_html_excerpt( sanitize_text_field( $question['placeholder'] ), 500 ); } + // Forward `className` (Additional CSS Class) to the block attrs. + // Field blocks inherit core's className support and render it onto the + // field wrapper (see inc/fields/base.php::set_properties()). Lands from + // multiple callers (AI, MCP, HTML converter), so sanitize each token. + if ( isset( $question['className'] ) && is_string( $question['className'] ) && '' !== $question['className'] ) { + $classes = preg_split( '/\s+/', trim( $question['className'] ) ); + if ( is_array( $classes ) ) { + $clean = implode( ' ', array_filter( array_map( 'sanitize_html_class', $classes ) ) ); + if ( '' !== $clean ) { + $merged_attributes['className'] = $clean; + } + } + } + // Apply filter to modify field type. $field_type = apply_filters( 'srfm_ai_field_modify_field_type', $question['fieldType'], $question, $is_conversational, $form_type ); diff --git a/inc/blocks/input/block.json b/inc/blocks/input/block.json index dca5ca500..fa2cd02d2 100644 --- a/inc/blocks/input/block.json +++ b/inc/blocks/input/block.json @@ -5,7 +5,7 @@ "title": "Text", "description": "Displays a SureForms Text Field", "category": "sureforms", - "keywords": [ "text", "input", "custom", "field", "name" ], + "keywords": [ "text", "input", "custom", "field", "name", "color" ], "textdomain": "sureforms", "supports": { "reusable": false, diff --git a/inc/compatibility/multilingual/multilingual-manager.php b/inc/compatibility/multilingual/multilingual-manager.php new file mode 100644 index 000000000..989651970 --- /dev/null +++ b/inc/compatibility/multilingual/multilingual-manager.php @@ -0,0 +1,95 @@ +provider = $this->resolve_provider(); + } + + /** + * Get the resolved multilingual provider. + * + * @since 2.11.0 + * @return Provider Active provider instance, or Null_Provider when none is available. + */ + public function provider(): Provider { + return $this->provider; + } + + /** + * Resolve which provider to use based on the active multilingual plugin. + * + * Resolution order: + * 1. WPML_Provider when WPML 4.5+ is active. + * 2. Null_Provider otherwise. + * 3. The `srfm_multilingual_provider` filter may override the default with any + * {@see Provider} implementation; non-conforming values are ignored. + * + * @since 2.11.0 + * @return Provider Resolved provider instance. + */ + protected function resolve_provider(): Provider { + $wpml = new WPML_Provider(); + + $provider = $wpml->is_active() ? $wpml : new Null_Provider(); + + /** + * Filter the resolved multilingual provider. + * + * Third parties can return their own {@see Provider} implementation to + * replace SureForms' default resolution. Non-conforming return values are ignored. + * + * @since 2.11.0 + * @param Provider $provider The resolved provider instance. + */ + $filtered = apply_filters( 'srfm_multilingual_provider', $provider ); + + if ( $filtered instanceof Provider ) { + return $filtered; + } + + return $provider; + } +} diff --git a/inc/compatibility/multilingual/providers/null-provider.php b/inc/compatibility/multilingual/providers/null-provider.php new file mode 100644 index 000000000..8490d3986 --- /dev/null +++ b/inc/compatibility/multilingual/providers/null-provider.php @@ -0,0 +1,177 @@ + $package Package descriptor. + * @since 2.11.0 + * @return void + */ + public function start_package( array $package ): void { + unset( $package ); + } + + /** + * Finish a package registration. No-op for the Null provider. + * + * @param array $package Package descriptor. + * @since 2.11.0 + * @return void + */ + public function finish_package( array $package ): void { + unset( $package ); + } + + /** + * Register a package string. No-op for the Null provider. + * + * @param array $package Package descriptor. + * @param string $name String identifier. + * @param string $value Original value. + * @param string $title Editor label. + * @param string $type Editor field type. + * @since 2.11.0 + * @return void + */ + public function register_package_string( array $package, string $name, string $value, string $title = '', string $type = 'LINE' ): void { + unset( $package, $name, $value, $title, $type ); + } + + /** + * Translate a package string. Returns the original value for the Null provider. + * + * @param array $package Package descriptor. + * @param string $name String identifier. + * @param string $value Original value. + * @since 2.11.0 + * @return string The original value unchanged. + */ + public function translate_package_string( array $package, string $name, string $value ): string { + unset( $package, $name ); + return $value; + } +} diff --git a/inc/compatibility/multilingual/providers/provider.php b/inc/compatibility/multilingual/providers/provider.php new file mode 100644 index 000000000..990608057 --- /dev/null +++ b/inc/compatibility/multilingual/providers/provider.php @@ -0,0 +1,160 @@ + $package Package descriptor: kind, name, title, edit_link. + * @since 2.11.0 + * @return void + */ + public function start_package( array $package ): void; + + /** + * Finish registering a string package, removing any strings that were not + * re-registered since {@see start_package()} (e.g. fields deleted from a form). + * + * @param array $package Package descriptor. + * @since 2.11.0 + * @return void + */ + public function finish_package( array $package ): void; + + /** + * Register a single string within a package. + * + * @param array $package Package descriptor. + * @param string $name Stable string identifier within the package. + * @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 + */ + public function register_package_string( array $package, string $name, string $value, string $title = '', string $type = 'LINE' ): void; + + /** + * Translate a single package string for the current language. + * + * @param array $package Package descriptor. + * @param string $name Stable string identifier within the package. + * @param string $value Original value (fallback when untranslated). + * @since 2.11.0 + * @return string Translated value, or the original when no translation exists. + */ + public function translate_package_string( array $package, string $name, string $value ): string; +} diff --git a/inc/compatibility/multilingual/providers/wpml-provider.php b/inc/compatibility/multilingual/providers/wpml-provider.php new file mode 100644 index 000000000..b0a9b884b --- /dev/null +++ b/inc/compatibility/multilingual/providers/wpml-provider.php @@ -0,0 +1,379 @@ + + */ + private $previous_language = []; + + /** + * Whether WPML is active and at the minimum supported version. + * + * @since 2.11.0 + * @return bool True when WPML 4.5+ is available, false otherwise. + */ + public function is_active(): bool { + if ( null !== $this->is_active_cache ) { + return $this->is_active_cache; + } + + $active = defined( 'ICL_SITEPRESS_VERSION' ) + && class_exists( '\SitePress' ) + && version_compare( (string) constant( 'ICL_SITEPRESS_VERSION' ), self::MIN_WPML_VERSION, '>=' ); + + $this->is_active_cache = $active; + return $this->is_active_cache; + } + + /** + * Current visitor language code as reported by WPML. + * + * @since 2.11.0 + * @return string Language code, or empty string when WPML is inactive or the filter returns a non-string. + */ + public function current_language(): string { + if ( ! $this->is_active() ) { + return ''; + } + + $language = apply_filters( 'wpml_current_language', null ); + return is_string( $language ) ? $language : ''; + } + + /** + * Site default language code as reported by WPML. + * + * @since 2.11.0 + * @return string Language code, or empty string when WPML is inactive or the filter returns a non-string. + */ + public function default_language(): string { + if ( ! $this->is_active() ) { + return ''; + } + + $language = apply_filters( 'wpml_default_language', null ); + return is_string( $language ) ? $language : ''; + } + + /** + * Register a string with WPML's String Translation registry. + * + * @param string $name Unique string identifier within the domain. + * @param string $value Original string value to register. + * @param string $domain Translation domain. Defaults to the sureforms text domain. + * @since 2.11.0 + * @return void + */ + public function register_string( string $name, string $value, string $domain = 'sureforms' ): void { + if ( ! $this->is_active() ) { + return; + } + + do_action( 'wpml_register_single_string', $domain, $name, $value ); + } + + /** + * Translate a registered string via WPML. + * + * @param string $value Original string value (used as fallback). + * @param string $name Unique string identifier within the domain. + * @param string $domain Translation domain. Defaults to the sureforms text domain. + * @param string|null $language Optional target language code. When null, uses the current language. + * @since 2.11.0 + * @return string Translated value, or the original $value when WPML is inactive or no translation exists. + */ + public function translate( string $value, string $name, string $domain = 'sureforms', ?string $language = null ): string { + if ( ! $this->is_active() ) { + return $value; + } + + if ( null !== $language ) { + $translated = apply_filters( 'wpml_translate_single_string', $value, $domain, $name, $language ); + } else { + $translated = apply_filters( 'wpml_translate_single_string', $value, $domain, $name ); + } + + if ( null === $translated || ! is_string( $translated ) ) { + return $value; + } + + return $translated; + } + + /** + * Push the current language onto an internal stack and switch WPML to $language. + * + * @param string $language Target language code to switch to. + * @since 2.11.0 + * @return void + */ + public function switch_language( string $language ): void { + if ( ! $this->is_active() ) { + return; + } + + $this->previous_language[] = $this->current_language(); + do_action( 'wpml_switch_language', $language ); + } + + /** + * Pop the previous language off the internal stack and restore WPML's context. + * + * @since 2.11.0 + * @return void + */ + public function restore_language(): void { + if ( ! $this->is_active() ) { + return; + } + + if ( empty( $this->previous_language ) ) { + return; + } + + $previous = array_pop( $this->previous_language ); + do_action( 'wpml_switch_language', $previous ); + } + + /** + * {@inheritDoc} + * + * Builds a self-contained list-based switcher from WPML's `wpml_active_languages` + * data. Doesn't depend on the site's WPML "switcher slot" being enabled (footer, + * post-actions, etc.), so it works on custom templates that don't include the + * theme footer — like SureForms' single-form.php instant-form template. + * + * Returns empty when WPML is inactive or fewer than two languages are configured. + * + * @since 2.11.0 + * @return string Rendered switcher HTML, or empty string. + */ + public function render_language_switcher(): string { + if ( ! $this->is_active() ) { + return ''; + } + + $languages = $this->get_active_languages(); + if ( count( $languages ) < 2 ) { + return ''; + } + + $current = $this->current_language(); + + $items = ''; + foreach ( $languages as $code => $language ) { + $url = isset( $language['url'] ) && is_string( $language['url'] ) ? $language['url'] : ''; + $native = isset( $language['native_name'] ) && is_string( $language['native_name'] ) ? $language['native_name'] : ''; + $lang = isset( $language['language_code'] ) && is_string( $language['language_code'] ) ? $language['language_code'] : (string) $code; + + if ( '' === $url || '' === $native ) { + continue; + } + + // Prefer WPML's own `active` flag from wpml_active_languages (the canonical + // current-language signal for the switcher), falling back to comparing against + // current_language() when the flag isn't present. + $is_current = isset( $language['active'] ) ? ! empty( $language['active'] ) : ( $lang === $current ); + $class = $is_current ? 'srfm-lang-item srfm-lang-item-current' : 'srfm-lang-item'; + $items .= sprintf( + '
  • %4$s
  • ', + esc_attr( $class ), + esc_url( $url ), + esc_attr( $lang ), + esc_html( $native ) + ); + } + + if ( '' === $items ) { + return ''; + } + + return ''; + } + + /** + * Whether WPML's String Package API is available. + * + * Package translation is provided by the WPML String Translation plugin's + * package module. Gate on the `wpml_register_string` action so we degrade + * gracefully (callers fall back to flat strings) if only WPML core is active. + * + * @since 2.11.0 + * @return bool + */ + public function supports_packages(): bool { + return $this->is_active() && has_action( 'wpml_register_string' ); + } + + /** + * {@inheritDoc} + * + * @param array $package Package descriptor (kind, name, title, edit_link). + * @since 2.11.0 + * @return void + */ + public function start_package( array $package ): void { + if ( ! $this->supports_packages() ) { + return; + } + do_action( 'wpml_start_string_package_registration', $package ); + } + + /** + * {@inheritDoc} + * + * @param array $package Package descriptor. + * @since 2.11.0 + * @return void + */ + public function finish_package( array $package ): void { + if ( ! $this->supports_packages() ) { + return; + } + do_action( 'wpml_delete_unused_package_strings', $package ); + } + + /** + * {@inheritDoc} + * + * @param array $package Package descriptor. + * @param string $name String identifier within the package. + * @param string $value Original value. + * @param string $title Editor label. + * @param string $type Editor field type (LINE|AREA|VISUAL). + * @since 2.11.0 + * @return void + */ + public function register_package_string( array $package, string $name, string $value, string $title = '', string $type = 'LINE' ): void { + if ( ! $this->supports_packages() ) { + return; + } + do_action( 'wpml_register_string', $value, $name, $package, '' !== $title ? $title : $name, $type ); + } + + /** + * {@inheritDoc} + * + * @param array $package Package descriptor. + * @param string $name String identifier within the package. + * @param string $value Original value (fallback). + * @since 2.11.0 + * @return string + */ + public function translate_package_string( array $package, string $name, string $value ): string { + if ( ! $this->supports_packages() ) { + return $value; + } + $translated = apply_filters( 'wpml_translate_string', $value, $name, $package ); + return is_string( $translated ) ? $translated : $value; + } + + /** + * Build the active-language list from the most reliable WPML surface available. + * + * Tries `apply_filters( 'wpml_active_languages', ... )` first (the documented + * public API that returns URLs for the current page). When that returns no + * data — which happens on custom template paths that run before WPML has + * fully bootstrapped its language switcher — falls back to SitePress's + * internal `get_active_languages()` data and constructs URLs via the + * `wpml_permalink` filter so the switcher still links to translated copies. + * + * @since 2.11.0 + * @return array> Map of language code → {url, native_name, language_code}. + */ + protected function get_active_languages(): array { + $languages = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' ); + if ( is_array( $languages ) && ! empty( $languages ) ) { + return $languages; + } + + global $sitepress; + if ( ! is_object( $sitepress ) || ! method_exists( $sitepress, 'get_active_languages' ) ) { + return []; + } + + $sp_languages = $sitepress->get_active_languages(); + if ( ! is_array( $sp_languages ) || empty( $sp_languages ) ) { + return []; + } + + $current_url = $this->guess_current_url(); + $out = []; + foreach ( $sp_languages as $code => $data ) { + if ( ! is_string( $code ) || '' === $code || ! is_array( $data ) ) { + continue; + } + $native = isset( $data['native_name'] ) && is_string( $data['native_name'] ) ? $data['native_name'] : $code; + + $url = apply_filters( 'wpml_permalink', $current_url, $code ); + if ( ! is_string( $url ) || '' === $url ) { + continue; + } + + $out[ $code ] = [ + 'language_code' => $code, + 'native_name' => $native, + 'url' => $url, + ]; + } + return $out; + } + + /** + * Best-effort current page URL for use with the `wpml_permalink` filter when + * SitePress hasn't pre-computed per-language URLs for the request. + * + * @since 2.11.0 + * @return string Current request URL, or home URL as a last resort. + */ + protected function guess_current_url(): string { + if ( function_exists( 'home_url' ) ) { + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + if ( '' !== $uri ) { + return home_url( $uri ); + } + return home_url( '/' ); + } + return ''; + } +} diff --git a/inc/compatibility/multilingual/string-collector.php b/inc/compatibility/multilingual/string-collector.php new file mode 100644 index 000000000..fee2cc01d --- /dev/null +++ b/inc/compatibility/multilingual/string-collector.php @@ -0,0 +1,481 @@ + 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 ); + + // 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' ] ); + } + } + + /** + * 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 ); + } + + /** + * 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 + * @return void + */ + public function collect( int $form_id ): void { + $provider = Multilingual_Manager::get_instance()->provider(); + + if ( ! $provider->is_active() ) { + return; + } + + // 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 ); + } + + // 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. + $fields = [ + 'subject' => [ __( 'Subject', 'sureforms' ), 'LINE' ], + 'body' => [ __( 'Message body', 'sureforms' ), 'AREA' ], + '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 ) { + $value = isset( $notification[ $field ] ) ? Helper::get_string_value( $notification[ $field ] ) : ''; + $this->register_form_string( + $form_id, + String_Translator::notification_name( (int) $index, $field ), + $value, + $notification_group . ': ' . $meta[0], + $meta[1] + ); + } + } + + // 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; + } + + /** + * 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; + } +} diff --git a/inc/compatibility/multilingual/string-translator.php b/inc/compatibility/multilingual/string-translator.php new file mode 100644 index 000000000..5b0d240db --- /dev/null +++ b/inc/compatibility/multilingual/string-translator.php @@ -0,0 +1,648 @@ +> + */ + public static function translatable_block_attributes(): array { + $map = [ + 'srfm/input' => [ 'label', 'placeholder', 'help', 'errorMsg', 'defaultValue', 'duplicateMsg' ], + 'srfm/email' => [ 'label', 'placeholder', 'help', 'errorMsg', 'defaultValue', 'duplicateMsg', 'confirmLabel' ], + 'srfm/phone' => [ 'label', 'placeholder', 'help', 'errorMsg', 'duplicateMsg' ], + 'srfm/number' => [ 'label', 'placeholder', 'help', 'errorMsg', 'defaultValue', 'prefix', 'suffix' ], + 'srfm/url' => [ 'label', 'placeholder', 'help', 'errorMsg', 'defaultValue' ], + 'srfm/textarea' => [ 'label', 'placeholder', 'help', 'errorMsg', 'defaultValue' ], + 'srfm/address' => [ 'label', 'help' ], + 'srfm/dropdown' => [ 'label', 'placeholder', 'help', 'errorMsg' ], + 'srfm/multi-choice' => [ 'label', 'help', 'errorMsg' ], + 'srfm/checkbox' => [ 'label', 'help', 'errorMsg' ], + 'srfm/gdpr' => [ 'label', 'help', 'errorMsg' ], + 'srfm/inline-button' => [ 'buttonText' ], + 'srfm/payment' => [ 'label', 'help', 'errorMsg', 'amountLabel', 'paymentDescription', 'oneTimeLabel', 'subscriptionLabel' ], + ]; + + /** + * Filters the map of block name → translatable scalar attribute keys. + * + * Both the string-collector (registration on save) and the string-translator + * (translation at render) read this method, so a single filter keeps the two + * sides in lockstep. Pro field blocks register their translatable attributes here. + * + * @since 2.11.0 + * @param array> $map Block name → attribute keys. + */ + $filtered = apply_filters( 'srfm_translatable_block_attributes', $map ); + + return is_array( $filtered ) ? $filtered : $map; + } + + /** + * Block names whose `options` array contains translatable `label` entries. + * + * @since 2.11.0 + * @return array + */ + public static function translatable_option_blocks(): array { + $blocks = [ 'srfm/dropdown', 'srfm/multi-choice' ]; + + /** + * Filters the list of block names whose `options[*].label` entries are translatable. + * + * Read by both the string-collector (registration) and the string-translator + * (translation), so a single filter keeps the two sides in lockstep. Pro + * option-bearing blocks opt into option-label translation here. + * + * @since 2.11.0 + * @param array $blocks Block names with translatable option labels. + */ + $filtered = apply_filters( 'srfm_translatable_option_blocks', $blocks ); + + return is_array( $filtered ) ? $filtered : $blocks; + } + + /** + * Human-readable field-type label for a SureForms block, used to group a + * field's strings in the Translation Editor (e.g. the "Email #2" heading). + * + * Deliberately uses the block TYPE rather than the user-entered field label: + * WPML builds the Translation-Editor tree by splitting the string title on + * `/` (nesting) and `: ` (path vs. leaf), so a user who types either sequence + * into a field label would otherwise corrupt the grouping. Block types are + * plugin-controlled and safe. + * + * @since 2.11.0 + * @param string $block_name Full block name, e.g. `srfm/email`. + * @return string Friendly type label, e.g. `Email`. + */ + public static function block_type_label( string $block_name ): string { + $labels = [ + 'srfm/input' => __( 'Text', 'sureforms' ), + 'srfm/email' => __( 'Email', 'sureforms' ), + 'srfm/phone' => __( 'Phone', 'sureforms' ), + 'srfm/number' => __( 'Number', 'sureforms' ), + 'srfm/url' => __( 'URL', 'sureforms' ), + 'srfm/textarea' => __( 'Textarea', 'sureforms' ), + 'srfm/address' => __( 'Address', 'sureforms' ), + 'srfm/dropdown' => __( 'Dropdown', 'sureforms' ), + 'srfm/multi-choice' => __( 'Multiple Choice', 'sureforms' ), + 'srfm/checkbox' => __( 'Checkbox', 'sureforms' ), + 'srfm/gdpr' => __( 'GDPR', 'sureforms' ), + 'srfm/inline-button' => __( 'Button', 'sureforms' ), + 'srfm/payment' => __( 'Payment', 'sureforms' ), + ]; + + /** + * Filters the block-name → field-type label map used to group a field's + * strings in the Translation Editor. Pro field blocks add their labels here. + * + * @since 2.11.0 + * @param array $labels Block name → friendly type label. + */ + $labels = apply_filters( 'srfm_block_type_labels', $labels ); + + if ( isset( $labels[ $block_name ] ) && is_string( $labels[ $block_name ] ) ) { + return $labels[ $block_name ]; + } + + // Derive from the block name: strip the namespace, turn dashes into spaces + // and title-case (e.g. `srfm/date-time-picker` → `Date Time Picker`). + $slug = false !== strpos( $block_name, '/' ) ? substr( $block_name, strpos( $block_name, '/' ) + 1 ) : $block_name; + $slug = str_replace( '-', ' ', $slug ); + return '' !== $slug ? ucwords( $slug ) : __( 'Field', 'sureforms' ); + } + + /** + * Build the string-package descriptor for a form. + * + * One package per form (named by post ID) so every form string surfaces + * together under the form in the multilingual plugin's Translation Editor, + * instead of as flat, global String-Translation entries. The collector + * (registration) and the translator (render) both call this, keeping the two + * sides in lockstep. + * + * @param int $form_id Form post ID. + * @since 2.11.0 + * @return array { kind, name, title, edit_link }. + */ + public static function form_package( int $form_id ): array { + $title = get_the_title( $form_id ); + if ( '' === $title ) { + /* translators: %d is the form ID. */ + $title = sprintf( __( 'SureForms Form #%d', 'sureforms' ), $form_id ); + } + + $edit_link = get_edit_post_link( $form_id, 'raw' ); + + return [ + 'kind' => self::PACKAGE_KIND, + 'name' => (string) $form_id, + 'title' => $title, + 'edit_link' => is_string( $edit_link ) ? $edit_link : '', + ]; + } + + /** + * Package-scoped submit-button string name. Names are unique within a form's + * package (which is itself scoped to the form), so they carry no `form_{id}_` + * prefix; the legacy flat-string fallback re-adds it in {@see dispatch_package()}. + * + * @since 2.11.0 + * @return string + */ + public static function submit_button_name(): string { + return 'submit_button'; + } + + /** + * Package-scoped confirmation-message string name. + * + * @param int $index Confirmation index. + * @since 2.11.0 + * @return string + */ + public static function confirmation_name( int $index ): string { + return 'confirmation_' . $index . '_message'; + } + + /** + * Package-scoped notification-field string name. + * + * @param int $index Notification index. + * @param string $field Notification field (subject|body|from_name|reply_to). + * @since 2.11.0 + * @return string + */ + public static function notification_name( int $index, string $field ): string { + return 'notification_' . $index . '_' . $field; + } + + /** + * Package-scoped restriction-message string name. + * + * @since 2.11.0 + * @return string + */ + public static function restriction_name(): string { + return 'restriction_message'; + } + + /** + * Package-scoped block-attribute string name. + * + * @param string $block_id Stable block identifier. + * @param string $attribute Attribute key. + * @since 2.11.0 + * @return string + */ + public static function block_attribute_name( string $block_id, string $attribute ): string { + return 'block_' . $block_id . '_' . $attribute; + } + + /** + * Package-scoped block-option-label string name. + * + * @param string $block_id Stable block identifier. + * @param int $option_index Option index. + * @since 2.11.0 + * @return string + */ + public static function block_option_name( string $block_id, int $option_index ): string { + return 'block_' . $block_id . '_option_' . $option_index . '_label'; + } + + /** + * Translate a form's submit button text. + * + * @param int $form_id Form post ID. + * @param string $value Original value (used as fallback when no translation exists). + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_submit_button( int $form_id, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::submit_button_name(), $value ); + } + + /** + * Translate a per-confirmation message. + * + * @param int $form_id Form post ID. + * @param int $index Zero-based index of the confirmation within the form's confirmation set. + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_confirmation_message( int $form_id, int $index, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::confirmation_name( $index ), $value ); + } + + /** + * Translate an email notification subject. + * + * @param int $form_id Form post ID. + * @param int $index Zero-based index of the notification within the form's notification set. + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_notification_subject( int $form_id, int $index, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::notification_name( $index, 'subject' ), $value ); + } + + /** + * Translate an email notification body. + * + * @param int $form_id Form post ID. + * @param int $index Zero-based index of the notification within the form's notification set. + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_notification_body( int $form_id, int $index, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::notification_name( $index, 'body' ), $value ); + } + + /** + * Translate an email notification "from name". + * + * @param int $form_id Form post ID. + * @param int $index Zero-based index of the notification within the form's notification set. + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_notification_from_name( int $form_id, int $index, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::notification_name( $index, 'from_name' ), $value ); + } + + /** + * Translate a single built-in validation message keyed by its srfm_* identifier. + * + * Falls back to the supplied `$value` (which is typically already the + * WP-locale-translated output of {@see Translatable::dynamic_messages()}), + * so existing `.mo`-based translations keep working when the active + * multilingual provider has no translation for the string. + * + * @param string $key Stable identifier (e.g., `srfm_valid_email`). + * @param string $value Already-i18n'd value to use as fallback. + * @since 2.11.0 + * @return string Translated value, or `$value` when the provider has no + * better translation. + */ + public function translate_validation_message( string $key, string $value ): string { + if ( '' === $key || '' === $value ) { + return $value; + } + + $name = 'validation_' . $key; + return $this->dispatch( $value, $name ); + } + + /** + * Translate an associative array of validation messages in one shot. + * + * Convenience wrapper around {@see translate_validation_message()} for the + * frontend-localize call site. Preserves the original array's keys and + * leaves the structure intact when the provider is inactive. + * + * @param array $messages Map of `srfm_*` keys to message values. + * @since 2.11.0 + * @return array Map with translated values. + */ + public function translate_validation_messages( array $messages ): array { + $out = []; + foreach ( $messages as $key => $value ) { + if ( ! is_string( $key ) || ! is_string( $value ) ) { + $out[ $key ] = $value; + continue; + } + $out[ $key ] = $this->translate_validation_message( $key, $value ); + } + return $out; + } + + /** + * Translate the form restriction message. + * + * @param int $form_id Form post ID. + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_restriction_message( int $form_id, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::restriction_name(), $value ); + } + + /** + * Translate a scalar block attribute (label, placeholder, help, errorMsg, etc). + * + * @param int $form_id Form post ID. + * @param string $block_id Stable block identifier from the block's `block_id` attribute. + * @param string $attribute Attribute key (e.g., `label`, `placeholder`). + * @param string $value Original value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_block_attribute( int $form_id, string $block_id, string $attribute, string $value ): string { + if ( '' === $value || '' === $block_id || '' === $attribute ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::block_attribute_name( $block_id, $attribute ), $value ); + } + + /** + * Translate a single option label inside a dropdown / multi-choice block. + * + * @param int $form_id Form post ID. + * @param string $block_id Stable block identifier from the block's `block_id` attribute. + * @param int $option_index Zero-based index of the option within the block's options array. + * @param string $value Original label value. + * @since 2.11.0 + * @return string Translated value, or the original when no provider/translation is available. + */ + public function translate_block_option_label( int $form_id, string $block_id, int $option_index, string $value ): string { + if ( '' === $value || '' === $block_id ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::block_option_name( $block_id, $option_index ), $value ); + } + + /** + * Pre-translate a form's raw block markup and also return the parsed top-level + * blocks so the render path can derive its block count without re-parsing the + * rendered HTML. + * + * Parses the post content, walks every translatable SureForms block attribute + * (per {@see translatable_block_attributes()} and {@see translatable_option_blocks()}), + * substitutes the translated value where the active multilingual provider has one, + * and re-serialises the markup. Inner blocks are walked recursively. + * + * This is the canonical, idempotent entry point that every render path (the + * standard form markup, and Pro multi-step / conversational renderers) should + * call AFTER applying the `srfm_get_form_post_content` filter and AFTER any + * path-specific marker injection — never before. + * + * The translated markup is cached per (form, language, post-modified, content) + * in the object cache: `post_modified_gmt` auto-invalidates on every form edit, + * and the content hash guards against content-mutating filters (e.g. randomised + * question order). Acts as a pure pass-through — no cache access, no parse — when + * no provider is active or the content is empty. + * + * @param int $form_id Form post ID. + * @param string $post_content Raw block markup (typically `$post->post_content`). + * @param \WP_Post|null $post Form post, used only for cache invalidation via `post_modified_gmt`. + * @since 2.11.0 + * @return array{0: string, 1: array>} [ markup, parsed top-level blocks ]. + */ + public function translate_form_content_with_blocks( int $form_id, string $post_content, ?\WP_Post $post = null ): array { + $provider = Multilingual_Manager::get_instance()->provider(); + + // Single-language / no-provider path: pure pass-through, zero overhead. + if ( ! $provider->is_active() || '' === trim( $post_content ) ) { + return [ $post_content, [] ]; + } + + $language = $provider->current_language(); + $modified = $post instanceof \WP_Post ? (string) $post->post_modified_gmt : ''; + $cache_key = 'srfm_xl_form_' . $form_id . '_' . md5( $language . '|' . $modified . '|' . md5( $post_content ) ); + + $cached = wp_cache_get( $cache_key, 'srfm_multilingual' ); + if ( is_string( $cached ) ) { + return [ $cached, parse_blocks( $cached ) ]; + } + + $blocks = parse_blocks( $post_content ); + + if ( empty( $blocks ) ) { + return [ $post_content, [] ]; + } + + $translated_blocks = $this->translate_blocks_recursive( $form_id, $blocks ); + + // $translated_blocks is the round-tripped output of parse_blocks() with only + // its scalar attribute values swapped, so serialize_blocks() is safe here. + // PHPStan's stricter shape declaration for serialize_blocks() can't see through + // the generic array we use internally to keep the recursion + // type-stable across innerBlocks. The runtime shape is identical. + // @phpstan-ignore-next-line argument.type + $markup = serialize_blocks( $translated_blocks ); + + wp_cache_set( $cache_key, $markup, 'srfm_multilingual', HOUR_IN_SECONDS ); + + return [ $markup, $translated_blocks ]; + } + + /** + * Back-compatible single-string entry point. Pre-translate a form's raw block + * markup before it reaches `do_blocks()`. + * + * Thin wrapper over {@see translate_form_content_with_blocks()} preserved for + * existing callers (including Pro) that only need the markup string. Idempotent: + * re-feeding already-translated markup is safe. + * + * @param int $form_id Form post ID. + * @param string $post_content Raw block markup (typically `$post->post_content`). + * @since 2.11.0 + * @return string The (possibly translated) block markup. + */ + public function translate_form_content( int $form_id, string $post_content ): string { + $post = get_post( $form_id ); + [ $markup ] = $this->translate_form_content_with_blocks( $form_id, $post_content, $post instanceof \WP_Post ? $post : null ); + + return $markup; + } + + /** + * Walk a list of parsed blocks (and their innerBlocks) translating attributes + * in-place. Block markers without recognised SureForms attributes pass through + * unchanged. + * + * @param int $form_id Form post ID. + * @param array> $blocks Parsed-blocks structure. + * @since 2.11.0 + * @return array> The same structure with translatable attribute values swapped. + */ + protected function translate_blocks_recursive( int $form_id, array $blocks ): array { + $attribute_map = self::translatable_block_attributes(); + $option_blocks = self::translatable_option_blocks(); + + foreach ( $blocks as $index => $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'] : ''; + + // Translate scalar attributes for known SureForms blocks. + if ( '' !== $block_id && isset( $attribute_map[ $block_name ] ) ) { + foreach ( $attribute_map[ $block_name ] as $attribute_key ) { + if ( ! isset( $attrs[ $attribute_key ] ) || ! is_string( $attrs[ $attribute_key ] ) ) { + continue; + } + $attrs[ $attribute_key ] = $this->translate_block_attribute( + $form_id, + $block_id, + $attribute_key, + $attrs[ $attribute_key ] + ); + } + } + + // Translate option labels for dropdown / multi-choice. + if ( '' !== $block_id && in_array( $block_name, $option_blocks, true ) && isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) { + foreach ( $attrs['options'] as $option_index => $option ) { + if ( ! is_array( $option ) || ! isset( $option['label'] ) || ! is_string( $option['label'] ) ) { + continue; + } + $attrs['options'][ $option_index ]['label'] = $this->translate_block_option_label( + $form_id, + $block_id, + (int) $option_index, + $option['label'] + ); + } + } + + $blocks[ $index ]['attrs'] = $attrs; + + // Recurse into innerBlocks (e.g., page-break wrappers). + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { + $blocks[ $index ]['innerBlocks'] = $this->translate_blocks_recursive( $form_id, $block['innerBlocks'] ); + } + } + + return $blocks; + } + + /** + * Dispatch the translate call to the active multilingual provider. + * + * @param string $value Original value (used as fallback when no translation exists). + * @param string $name Fully built string name per the documented naming scheme. + * @since 2.11.0 + * @return string Translated value, or the original value when the provider has no translation. + */ + private function dispatch( string $value, string $name ): string { + $provider = Multilingual_Manager::get_instance()->provider(); + return $provider->translate( $value, $name, self::DOMAIN ); + } + + /** + * Translate a per-form string via the provider's String Package, so it shows + * grouped under the form in the Translation Editor. + * + * Falls back to a flat String-Translation string (legacy `form_{id}_{name}` + * naming) when the provider can't do packages — keeping older WPML setups and + * non-package providers working. + * + * @param int $form_id Form post ID. + * @param string $name Package-scoped string name (see the *_name() builders). + * @param string $value Original value (fallback when untranslated). + * @since 2.11.0 + * @return string Translated value, or the original when no translation exists. + */ + private function dispatch_package( int $form_id, string $name, string $value ): string { + if ( '' === $value ) { + return $value; + } + + $provider = Multilingual_Manager::get_instance()->provider(); + + if ( $provider->supports_packages() ) { + return $provider->translate_package_string( self::form_package( $form_id ), $name, $value ); + } + + // Legacy / non-package fallback: flat string with the form-scoped name. + return $provider->translate( $value, 'form_' . $form_id . '_' . $name, self::DOMAIN ); + } +} diff --git a/inc/database/base.php b/inc/database/base.php index 42797f3fa..0763a566f 100644 --- a/inc/database/base.php +++ b/inc/database/base.php @@ -421,8 +421,20 @@ public function maybe_add_new_columns( $new_columns = [] ) { $result = $wpdb->query( $query ); // phpcs:ignore -- It is okay. We are already using prepare above and we need to do DB query directly here. if ( false === $result ) { - // Stop DB alteration if we have any error. + // Stop DB alteration if we have any error. A failed ALTER leaves the table + // version un-bumped, so it retries on every request — expose the underlying + // error so persistent failures are diagnosable (hook for logging/monitoring). $this->db_upgradable = false; + + /** + * Fires when a SureForms DB schema-upgrade query fails. + * + * @since 2.11.0 + * @param string $last_error The DB error message ( $wpdb->last_error ). + * @param string $query The ALTER query that failed. + * @param string $table The table being altered. + */ + do_action( 'srfm_db_upgrade_query_failed', $this->wpdb->last_error, $query, $this->get_tablename() ); } return $result; diff --git a/inc/database/tables/entries.php b/inc/database/tables/entries.php index 38cc9f56d..70f1c73a5 100644 --- a/inc/database/tables/entries.php +++ b/inc/database/tables/entries.php @@ -37,7 +37,7 @@ class Entries extends Base { * * @var int */ - protected $table_version = 1; + protected $table_version = 2; /** * Current logs. @@ -110,6 +110,11 @@ public function get_schema() { 'type' => 'array', 'default' => [], ], + // Submission language code (e.g. 'en', 'de'). Empty when no multilingual provider is active. + 'language' => [ + 'type' => 'string', + 'default' => '', + ], ]; } @@ -128,11 +133,13 @@ public function get_columns_definition() { 'status VARCHAR(10)', 'type VARCHAR(20)', // Note: @since 0.0.13 -- We have added type column, it will have entry's form type eg quiz, standard etc. 'extras LONGTEXT', + 'language VARCHAR(20)', // Note: @since 2.11.0 -- Submission language code, captured from the active multilingual provider. Nullable to match status/type column convention; INSERT path always supplies a value or empty string. Width matches the `type` column precedent and covers extended BCP-47 codes (e.g. `ca-valencia`). 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'INDEX idx_form_id (form_id)', // Indexing for the performance improvements. 'INDEX idx_user_id (user_id)', 'INDEX idx_form_id_created_at_status (form_id, created_at, status)', // Composite index for performance improvements. + 'INDEX idx_form_id_language (form_id, language)', // Composite index for per-language entry filtering. ]; } @@ -146,6 +153,13 @@ public function get_new_columns_definition() { 'extras LONGTEXT AFTER status', 'user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER form_id', 'INDEX idx_user_id (user_id)', + // Note: @since 2.11.0 -- Added language column for multilingual submission tracking. + // No `AFTER` clause: on a pre-0.0.13 install upgrading straight to this version, + // `extras` is added in the SAME combined ALTER, and MySQL resolves `AFTER extras` + // against the pre-ALTER schema, throwing "Unknown column 'extras'" and failing the + // whole atomic ALTER. Column ordinal position is cosmetic and addressed by name everywhere. + 'language VARCHAR(20)', + 'INDEX idx_form_id_language (form_id, language)', ]; } @@ -582,6 +596,6 @@ public static function get_entry_data( $entry_id ) { * @return array */ protected function get_allowed_orderby_columns() { - return [ 'ID', 'id', 'form_id', 'user_id', 'status', 'type', 'created_at', 'updated_at' ]; + return [ 'ID', 'id', 'form_id', 'user_id', 'status', 'type', 'language', 'created_at', 'updated_at' ]; } } diff --git a/inc/fields/dropdown-markup.php b/inc/fields/dropdown-markup.php index a3a042956..50bc8b0bb 100644 --- a/inc/fields/dropdown-markup.php +++ b/inc/fields/dropdown-markup.php @@ -11,6 +11,7 @@ require SRFM_DIR . 'modules/gutenberg/classes/class-spec-gb-helper.php'; use Spec_Gb_Helper; +use SRFM\Inc\Compatibility\Multilingual\String_Translator; use SRFM\Inc\Helper; use SRFM\Inc\Smart_Tags; @@ -103,9 +104,12 @@ public function __construct( $attributes ) { $this->placeholder = $original_placeholder; } - // Translate the default placeholder text for frontend display. + // Translate the default placeholder text for frontend display. Routed through + // the multilingual provider (not just the textdomain) so it is translated on + // WPML/Polylang sites too — the default placeholder is not serialized into + // block markup, so it never reaches the per-form string package. if ( 'Select an option' === $this->placeholder ) { - $this->placeholder = __( 'Select an option', 'sureforms' ); + $this->placeholder = String_Translator::get_instance()->translate_validation_message( 'srfm_dropdown_placeholder', __( 'Select an option', 'sureforms' ) ); } $this->show_values = apply_filters( 'srfm_show_options_values', false, $attributes['showValues'] ?? false ); diff --git a/inc/form-restriction.php b/inc/form-restriction.php index a293a1b53..c10af6ea6 100644 --- a/inc/form-restriction.php +++ b/inc/form-restriction.php @@ -8,6 +8,7 @@ namespace SRFM\Inc; +use SRFM\Inc\Compatibility\Multilingual\String_Translator; use SRFM\Inc\Database\Tables\Entries; use SRFM\Inc\Traits\Get_Instance; @@ -256,6 +257,10 @@ public static function display_form_restriction_message( $form_id ) { $scheduling_state = self::get_form_scheduling_state( $form_restriction ); $form_restriction_message = self::get_restriction_message_by_state( $scheduling_state, $form_restriction ); + // Translate the restriction message via the active multilingual provider + // (no-op when no WPML/Polylang is installed). + $form_restriction_message = String_Translator::get_instance()->translate_restriction_message( (int) $form_id, $form_restriction_message ); + $form_restriction_message = apply_filters( 'srfm_form_restriction_message', $form_restriction_message, $form_id, $form_restriction ); ob_start(); diff --git a/inc/form-submit.php b/inc/form-submit.php index 9f083dc02..a78346cfd 100644 --- a/inc/form-submit.php +++ b/inc/form-submit.php @@ -8,6 +8,7 @@ namespace SRFM\Inc; +use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager; use SRFM\Inc\Database\Tables\Entries; use SRFM\Inc\Email\Email_Template; use SRFM\Inc\Lib\Browser\Browser; @@ -584,32 +585,11 @@ public function handle_form_entry( $form_data ) { $browser_name = sanitize_text_field( $browser->getBrowser() ); $device_name = sanitize_text_field( $browser->getPlatform() ); - // Capture submission page URL server-side from the Referer header. The - // stored value is rebuilt from parsed components so a non-browser client - // cannot inject attacker-controlled bits a real browser would never send - // (userinfo, fragment) or mismatch the legitimate origin's port. Anything - // not same-origin http(s) is stored as empty. - $referer = isset( $_SERVER['HTTP_REFERER'] ) - ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) - : ''; - if ( '' !== $referer && strlen( $referer ) <= 2048 ) { - $parts = wp_parse_url( $referer ); - $home_parts = wp_parse_url( home_url() ); - if ( - is_array( $parts ) - && is_array( $home_parts ) - && isset( $parts['scheme'], $parts['host'], $home_parts['host'] ) - && in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) - && 0 === strcasecmp( (string) $parts['host'], (string) $home_parts['host'] ) - && ( $parts['port'] ?? null ) === ( $home_parts['port'] ?? null ) - ) { - $clean = $parts['scheme'] . '://' . $parts['host'] - . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) - . ( $parts['path'] ?? '' ) - . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); - $submission_url = esc_url_raw( $clean, [ 'http', 'https' ] ); - } - } + // Capture submission page URL server-side from the Referer header. + // esc_url_raw() (not sanitize_text_field) preserves percent-encoded + // non-ASCII slugs; normalize_submission_url() then validates same-origin. + $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; + $submission_url = $this->normalize_submission_url( $referer ); } $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) ); @@ -621,10 +601,26 @@ public function handle_form_entry( $form_data ) { 'device_name' => $device_name, 'submission_url' => $submission_url, ]; - $entries_data = [ + // Prefer the language the visitor saw at form-render time (captured in a + // hidden srfm-form-language input), since WPML's language detection on the + // REST submit endpoint frequently falls back to the default. The hidden + // input is client-supplied, so: + // 1. Validate shape with a BCP-47 regex. + // 2. Cross-check against the active multilingual provider's known + // languages (active + default) so a crafted request can't pollute + // the column with codes the site doesn't support. + // 3. Fall back to the provider's current_language() on either failure. + $entry_language = Multilingual_Manager::get_instance()->provider()->current_language(); + $submitted_language = isset( $form_data['srfm-form-language'] ) ? sanitize_text_field( Helper::get_string_value( $form_data['srfm-form-language'] ) ) : ''; + if ( '' !== $submitted_language && preg_match( '/^[a-z]{2,3}([_-][A-Za-z0-9]{2,8})?$/', $submitted_language ) === 1 && $this->is_known_language( $submitted_language ) ) { + $entry_language = $submitted_language; + } + + $entries_data = [ 'form_id' => $id, 'form_data' => $submission_data, 'submission_info' => $submission_info, + 'language' => $entry_language, 'created_at' => current_time( 'mysql' ), ]; if ( is_user_logged_in() ) { @@ -646,6 +642,17 @@ public function handle_form_entry( $form_data ) { // Inject entry_id so {entry_id} smart tag resolves in confirmation message, redirect URL, email notifications, and downstream integrations. $form_data['entry_id'] = intval( $entry_id ); + // Switch the multilingual provider to the entry's language so the + // confirmation message, redirect URL, and email notifications render + // in the language the visitor saw at submit time. The REST submit + // endpoint doesn't carry the ?lang= URL parameter, so without this + // switch the provider would return strings in its default language + // even though the entry itself is correctly tagged. + $provider = Multilingual_Manager::get_instance()->provider(); + if ( $provider->is_active() && '' !== $entry_language ) { + $provider->switch_language( $entry_language ); + } + // Send email after entry creation so {entry_id} is available when smart tags are processed. $send_email = $this->send_email( $id, $submission_data, $form_data ); if ( $send_email ) { @@ -653,6 +660,11 @@ public function handle_form_entry( $form_data ) { } $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ); + $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ); + + if ( $provider->is_active() && '' !== $entry_language ) { + $provider->restore_language(); + } $response = [ 'success' => true, @@ -663,7 +675,7 @@ public function handle_form_entry( $form_data ) { 'after_submit' => true, 'after_submit_nonce' => wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ), ], - 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ), + 'redirect_url' => $redirect_url, ]; $form_submit_response = apply_filters( @@ -1217,6 +1229,100 @@ public function recaptcha_error_message( $type, $api_response ) { ]; } + /** + * Sanitise and validate a Referer into a storable submission URL. + * + * The value is rebuilt from parsed components so a non-browser client cannot + * inject bits a real browser would never send (userinfo, fragment) or mismatch + * the legitimate origin's port. Anything that is not a same-origin http(s) URL, + * or is longer than 2048 chars, is rejected and returns an empty string. + * + * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips + * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated + * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic + * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding + * so the recorded submission URL stays accurate. + * + * @param string $referer Raw (unslashed) Referer header value. + * @since 2.11.0 + * @return string Same-origin http(s) URL, or empty string when invalid. + */ + protected function normalize_submission_url( string $referer ): string { + $referer = esc_url_raw( $referer ); + + if ( '' === $referer || strlen( $referer ) > 2048 ) { + return ''; + } + + $parts = wp_parse_url( $referer ); + $home_parts = wp_parse_url( home_url() ); + + if ( + ! is_array( $parts ) + || ! is_array( $home_parts ) + || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] ) + || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) + || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] ) + || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null ) + ) { + return ''; + } + + $clean = $parts['scheme'] . '://' . $parts['host'] + . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) + . ( $parts['path'] ?? '' ) + . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); + + return esc_url_raw( $clean, [ 'http', 'https' ] ); + } + + /** + * Check whether the given language code is known to the active multilingual + * provider (i.e. in its active-languages set or matches the default language). + * + * Used to reject crafted srfm-form-language hidden-input values that pass + * the BCP-47 shape regex but reference languages the site doesn't actually + * support. + * + * @param string $language Language code to check (e.g. 'hi', 'de-AT'). + * @since 2.11.0 + * @return bool True when the code is known, false otherwise. + */ + protected function is_known_language( string $language ): bool { + if ( '' === $language ) { + return false; + } + + $provider = Multilingual_Manager::get_instance()->provider(); + + // When no provider is active there's no authoritative set to check + // against. Accept whatever the visitor sent (shape-validated) so the + // column still reflects the visitor's intent on non-WPML sites. + if ( ! $provider->is_active() ) { + return true; + } + + // Default language is always considered known. + if ( $language === $provider->default_language() ) { + return true; + } + + // Use WPML's filter when available — works regardless of which + // multilingual plugin is the active provider, as Polylang implements + // the same filter for compatibility. + $active = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' ); + if ( is_array( $active ) && ! empty( $active ) ) { + return array_key_exists( $language, $active ); + } + + // A provider IS active but its language list is unavailable. Rather than + // fail open and trust an arbitrary client-supplied code, accept it only when + // it matches the server-resolved current language. The caller already + // defaults $entry_language to current_language(), so this keeps mis-tagging + // to the server's own determination instead of the (cacheable) client value. + return $language === $provider->current_language(); + } + /** * Check if the current request is rate-limited for unique validation. * diff --git a/inc/frontend-assets.php b/inc/frontend-assets.php index e7ced4e17..1eaa612eb 100644 --- a/inc/frontend-assets.php +++ b/inc/frontend-assets.php @@ -9,6 +9,7 @@ namespace SRFM\Inc; +use SRFM\Inc\Compatibility\Multilingual\String_Translator; use SRFM\Inc\Traits\Get_Instance; use SRFM\Inc\Payments\Payment_Helper; use SRFM\Inc\Payments\Stripe\Stripe_Helper; @@ -123,20 +124,27 @@ public function register_scripts() { } } + $validation_messages = array_merge( + Translatable::get_frontend_validation_messages(), + [ + 'srfm_turnstile_error_message' => __( 'Turnstile sitekey verification failed. Please contact your site administrator.', 'sureforms' ), + 'srfm_google_captcha_error_message' => __( 'Google Captcha sitekey verification failed. Please contact your site administrator.', 'sureforms' ), + 'srfm_captcha_h_error_message' => __( 'HCaptcha sitekey verification failed. Please contact your site administrator.', 'sureforms' ), + ] + ); + + // Translate each validation message through the active provider so + // admin-supplied translations from WPML String Translation win over the + // .mo-file fallback. When no provider is active, this is a pass-through. + $validation_messages = String_Translator::get_instance()->translate_validation_messages( $validation_messages ); + wp_localize_script( SRFM_SLUG . '-form-submit', SRFM_SLUG . '_submit', [ 'site_url' => site_url(), 'nonce' => wp_create_nonce( 'wp_rest' ), - 'messages' => array_merge( - Translatable::get_frontend_validation_messages(), - [ - 'srfm_turnstile_error_message' => __( 'Turnstile sitekey verification failed. Please contact your site administrator.', 'sureforms' ), - 'srfm_google_captcha_error_message' => __( 'Google Captcha sitekey verification failed. Please contact your site administrator.', 'sureforms' ), - 'srfm_captcha_h_error_message' => __( 'HCaptcha sitekey verification failed. Please contact your site administrator.', 'sureforms' ), - ] - ), + 'messages' => $validation_messages, 'is_rtl' => $is_rtl, ] ); diff --git a/inc/generate-form-markup.php b/inc/generate-form-markup.php index 0efc7263d..ca40c2a52 100644 --- a/inc/generate-form-markup.php +++ b/inc/generate-form-markup.php @@ -8,6 +8,8 @@ namespace SRFM\Inc; +use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager; +use SRFM\Inc\Compatibility\Multilingual\String_Translator; use SRFM\Inc\Traits\Get_Instance; if ( ! defined( 'ABSPATH' ) ) { @@ -101,7 +103,8 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s do_action( 'srfm_localize_conditional_logic_data', $id ); $post = get_post( Helper::get_integer_value( $id ) ); - $content = ''; + $content = ''; + $form_blocks = []; $active_plugins = Helper::get_array_value( get_option( 'active_plugins', [] ) ); $is_learndash_active = in_array( 'sfwd-lms/sfwd_lms.php', $active_plugins, true ); @@ -114,6 +117,12 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s // Filter to get the post content for the form. $post_content = apply_filters( 'srfm_get_form_post_content', $post->post_content, $id ); + // Pre-translate block-attribute strings (labels, placeholders, options, etc.) + // before rendering, so the visitor's chosen language is honoured. Returns the + // translated markup plus the parsed top-level blocks so we can derive the block + // count without re-parsing the rendered HTML. No-op when no provider is active. + [ $post_content, $form_blocks ] = String_Translator::get_instance()->translate_form_content_with_blocks( (int) $id, Helper::get_string_value( $post_content ), $post ); + if ( ! empty( $do_blocks ) ) { $content = do_blocks( $post_content ); } else { @@ -121,8 +130,10 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s } } - $blocks = parse_blocks( $content ); - $block_count = count( $blocks ); + // Reuse the translator's parse on the multilingual path; otherwise parse once here + // (single-language path). Either way the content is parsed exactly once, never three times. + $form_blocks = ! empty( $form_blocks ) ? $form_blocks : parse_blocks( $content ); + $block_count = count( $form_blocks ); $current_post_type = get_post_type(); // load all the frontend assets. @@ -267,6 +278,7 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s // Submit button. $button_text = Helper::get_meta_value( $id, '_srfm_submit_button_text' ); + $button_text = String_Translator::get_instance()->translate_submit_button( (int) $id, Helper::get_string_value( $button_text ) ); $submit_button_alignment = ! empty( $form_styling['submit_button_alignment'] ) ? $form_styling['submit_button_alignment'] : 'left'; if ( is_rtl() && ( 'left' === $submit_button_alignment || 'right' === $submit_button_alignment ) ) { @@ -576,6 +588,19 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s ?> + + @@ -788,7 +813,7 @@ public static function common_error_message( $position = 'footer' ) { $icon = Helper::fetch_svg( 'info_circle', '', 'aria-hidden="true"' ); $classes = "srfm-common-error-message srfm-error-message srfm-{$position}-error"; ?> - + translate_confirmation_message( (int) $form_id, 0, $confirmation_message ); } if ( empty( $submission_data ) ) { return $confirmation_message; diff --git a/inc/gutenberg-hooks.php b/inc/gutenberg-hooks.php index b21d6c9b3..dced5e4a5 100644 --- a/inc/gutenberg-hooks.php +++ b/inc/gutenberg-hooks.php @@ -206,7 +206,7 @@ public function block_editor_assets() { 'srfm_form_markup_nonce' => wp_create_nonce( 'srfm_form_markup' ), 'get_form_markup_url' => 'sureforms/v1/generate-form-markup', 'is_pro_active' => Helper::has_pro(), - 'srfm_default_dynamic_block_option' => get_option( 'srfm_default_dynamic_block_option', Helper::default_dynamic_block_option() ), + 'srfm_default_dynamic_block_option' => wp_parse_args( Helper::get_array_value( get_option( 'srfm_default_dynamic_block_option', [] ) ), Helper::default_dynamic_block_option() ), 'form_selector_nonce' => Helper::current_user_can( 'edit_posts' ) ? wp_create_nonce( 'wp_rest' ) : '', 'is_admin_user' => Helper::current_user_can(), 'site_url' => $site_url, diff --git a/inc/helper.php b/inc/helper.php index 0d234d647..130a2354a 100644 --- a/inc/helper.php +++ b/inc/helper.php @@ -8,6 +8,7 @@ namespace SRFM\Inc; +use SRFM\Inc\Compatibility\Multilingual\String_Translator; use SRFM\Inc\Database\Tables\Entries; use SRFM\Inc\Traits\Get_Instance; use WP_Error; @@ -66,9 +67,10 @@ class Helper { * @return array */ public static function get_common_err_msg() { + $translator = String_Translator::get_instance(); return [ - 'required' => __( 'This field is required.', 'sureforms' ), - 'unique' => __( 'Value needs to be unique.', 'sureforms' ), + 'required' => $translator->translate_validation_message( 'srfm_required_field', __( 'This field is required.', 'sureforms' ) ), + 'unique' => $translator->translate_validation_message( 'srfm_unique_field', __( 'Value needs to be unique.', 'sureforms' ) ), ]; } @@ -412,7 +414,7 @@ public static function generate_common_form_markup( $form_id, $type, $label = '' ob_start(); ?>