From 4b2d620fc02f999dcb30afd0b7d0791d70ce6fdd Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Wed, 15 Jul 2026 15:15:09 +0530 Subject: [PATCH 01/78] fix(wpml): backfill existing forms, translatable title, package lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the WPML compatibility team's review of the 2.11.0 integration: 1. Backfill existing forms — new String_Backfill runs String_Collector::collect() once per plugin version (Action Scheduler, one job per form) when a provider becomes active, so forms created before the integration register their String Packages without a manual re-save. 2. Form title is now registered + translated. Previously post_title rendered as the form heading / instant-form banner but was never a translatable string. 3. Associate each String Package with its form post (post_id in the descriptor) so WPML 'Translate Everything Automatically' queues it with the post. 4. Delete the String Package when a form is permanently deleted — new Provider::delete_package() + before_delete_post handler, so orphaned packages and translations don't linger. Adds unit coverage for the backfill guard/idempotency, title registration + translation, package deletion, and the post_id association. --- .../multilingual/providers/null-provider.php | 11 ++ .../multilingual/providers/provider.php | 13 ++ .../multilingual/providers/wpml-provider.php | 25 +++ .../multilingual/string-backfill.php | 112 ++++++++++++ .../multilingual/string-collector.php | 40 +++++ .../multilingual/string-translator.php | 31 ++++ inc/generate-form-markup.php | 1 + plugin-loader.php | 2 + templates/single-form.php | 4 +- .../test-multilingual-manager.php | 4 + .../multilingual/test-string-backfill.php | 167 ++++++++++++++++++ .../multilingual/test-string-collector.php | 88 ++++++++- .../multilingual/test-string-translator.php | 33 ++++ 13 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 inc/compatibility/multilingual/string-backfill.php create mode 100644 tests/unit/inc/compatibility/multilingual/test-string-backfill.php diff --git a/inc/compatibility/multilingual/providers/null-provider.php b/inc/compatibility/multilingual/providers/null-provider.php index 8490d3986..1919b6449 100644 --- a/inc/compatibility/multilingual/providers/null-provider.php +++ b/inc/compatibility/multilingual/providers/null-provider.php @@ -174,4 +174,15 @@ public function translate_package_string( array $package, string $name, string $ unset( $package, $name ); return $value; } + + /** + * Delete a string package. No-op for the Null provider. + * + * @param array $package Package descriptor. + * @since x.x.x + * @return void + */ + public function delete_package( array $package ): void { + unset( $package ); + } } diff --git a/inc/compatibility/multilingual/providers/provider.php b/inc/compatibility/multilingual/providers/provider.php index 990608057..82501cee5 100644 --- a/inc/compatibility/multilingual/providers/provider.php +++ b/inc/compatibility/multilingual/providers/provider.php @@ -157,4 +157,17 @@ public function register_package_string( array $package, string $name, string $v * @return string Translated value, or the original when no translation exists. */ public function translate_package_string( array $package, string $name, string $value ): string; + + /** + * Delete an entire string package and its translations. + * + * Called when the form the package belongs to is permanently deleted, so + * orphaned packages and their translations don't linger in the multilingual + * plugin's registry. + * + * @param array $package Package descriptor. + * @since x.x.x + * @return void + */ + public function delete_package( array $package ): void; } diff --git a/inc/compatibility/multilingual/providers/wpml-provider.php b/inc/compatibility/multilingual/providers/wpml-provider.php index c959d7a41..0a4bdea62 100644 --- a/inc/compatibility/multilingual/providers/wpml-provider.php +++ b/inc/compatibility/multilingual/providers/wpml-provider.php @@ -310,6 +310,31 @@ public function translate_package_string( array $package, string $name, string $ return is_string( $translated ) ? $translated : $value; } + /** + * {@inheritDoc} + * + * Removes the package and its translations from WPML via the + * `wpml_delete_package` action, keyed by the package name + kind. + * + * @param array $package Package descriptor. + * @since x.x.x + * @return void + */ + public function delete_package( array $package ): void { + if ( ! $this->supports_packages() ) { + return; + } + + $name = isset( $package['name'] ) ? $package['name'] : ''; + $kind = isset( $package['kind'] ) ? $package['kind'] : ''; + + if ( '' === $name || '' === $kind ) { + return; + } + + do_action( 'wpml_delete_package', $name, $kind ); + } + /** * Build the active-language list from the most reliable WPML surface available. * diff --git a/inc/compatibility/multilingual/string-backfill.php b/inc/compatibility/multilingual/string-backfill.php new file mode 100644 index 000000000..30f88f799 --- /dev/null +++ b/inc/compatibility/multilingual/string-backfill.php @@ -0,0 +1,112 @@ +provider()->is_active() ) { + return; + } + + if ( SRFM_VER === get_option( self::DONE_OPTION ) ) { + return; + } + + if ( ! function_exists( 'as_enqueue_async_action' ) ) { + return; + } + + $form_ids = get_posts( + [ + 'post_type' => SRFM_FORMS_POST_TYPE, + 'post_status' => [ 'publish', 'draft', 'pending', 'private' ], + 'fields' => 'ids', + 'numberposts' => -1, + ] + ); + + foreach ( $form_ids as $form_id ) { + as_enqueue_async_action( self::HOOK, [ 'form_id' => (int) $form_id ], 'srfm' ); + } + + // Record completion up front: the per-form jobs are queued, and this pass + // must not re-queue them on the next admin load. + update_option( self::DONE_OPTION, SRFM_VER ); + } + + /** + * Backfill a single form's String Package. + * + * @param int $form_id The form post ID to backfill. + * @since x.x.x + * @return void + */ + public function backfill_one( int $form_id ): void { + String_Collector::get_instance()->collect( (int) $form_id ); + } +} diff --git a/inc/compatibility/multilingual/string-collector.php b/inc/compatibility/multilingual/string-collector.php index fee2cc01d..9d42e9ddc 100644 --- a/inc/compatibility/multilingual/string-collector.php +++ b/inc/compatibility/multilingual/string-collector.php @@ -71,6 +71,11 @@ class String_Collector { public function __construct() { add_action( 'save_post_' . SRFM_FORMS_POST_TYPE, [ $this, 'on_form_save' ], 20, 1 ); + // Prune the form's String Package when the form is permanently deleted so + // orphaned packages and their translations don't linger. Fires only on + // permanent delete, not on trash (a trashed form may be restored). + add_action( 'before_delete_post', [ $this, 'on_form_delete' ], 10, 1 ); + // Register the GLOBAL built-in validation strings once per admin request // (no-op when no multilingual provider is active). These strings are not // per-form, so they belong on an admin/authoring hook rather than on every @@ -110,6 +115,31 @@ public function on_form_save( int $form_id ): void { $this->collect( $form_id ); } + /** + * Delete the form's String Package when the form is permanently deleted. + * + * Hooked to before_delete_post (not trash) so a package is only removed when + * its form is gone for good. Bails for other post types and when no provider + * is active. + * + * @param int $form_id The post ID being deleted. + * @since x.x.x + * @return void + */ + public function on_form_delete( int $form_id ): void { + if ( SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) { + return; + } + + $provider = Multilingual_Manager::get_instance()->provider(); + + if ( ! $provider->is_active() ) { + return; + } + + $provider->delete_package( String_Translator::form_package( $form_id ) ); + } + /** * Walk the form and register every translatable string with the provider. * @@ -139,6 +169,16 @@ public function collect( int $form_id ): void { $provider->start_package( $package ); } + // Form title (post title) — shown as a heading on the form and as the + // instant-form banner, so it is translatable like any other string. + $this->register_form_string( + $form_id, + String_Translator::title_name(), + Helper::get_string_value( get_the_title( $form_id ) ), + // "Group: Leaf" — WPML splits on ': ' to nest this under a Settings group. + __( 'Settings', 'sureforms' ) . ': ' . __( 'Form title', 'sureforms' ) + ); + // Submit button text. $this->register_form_string( $form_id, diff --git a/inc/compatibility/multilingual/string-translator.php b/inc/compatibility/multilingual/string-translator.php index 5b0d240db..e2e7da1d1 100644 --- a/inc/compatibility/multilingual/string-translator.php +++ b/inc/compatibility/multilingual/string-translator.php @@ -205,6 +205,9 @@ public static function form_package( int $form_id ): array { 'name' => (string) $form_id, 'title' => $title, 'edit_link' => is_string( $edit_link ) ? $edit_link : '', + // Associate the package with its form post so WPML's "Translate + // Everything Automatically" queues the package alongside the post. + 'post_id' => (string) $form_id, ]; } @@ -220,6 +223,18 @@ public static function submit_button_name(): string { return 'submit_button'; } + /** + * Package-scoped form-title string name. The form's post title is shown as a + * heading on the form (and as the instant-form banner), so it needs to be + * registered and translated like any other user-facing string. + * + * @since x.x.x + * @return string + */ + public static function title_name(): string { + return 'form_title'; + } + /** * Package-scoped confirmation-message string name. * @@ -293,6 +308,22 @@ public function translate_submit_button( int $form_id, string $value ): string { return $this->dispatch_package( $form_id, self::submit_button_name(), $value ); } + /** + * Translate a form's title (post title). + * + * @param int $form_id Form post ID. + * @param string $value Original title (used as fallback when no translation exists). + * @since x.x.x + * @return string Translated title, or the original when no provider/translation is available. + */ + public function translate_form_title( int $form_id, string $value ): string { + if ( '' === $value ) { + return $value; + } + + return $this->dispatch_package( $form_id, self::title_name(), $value ); + } + /** * Translate a per-confirmation message. * diff --git a/inc/generate-form-markup.php b/inc/generate-form-markup.php index f0ba907a9..b1f77a0dc 100644 --- a/inc/generate-form-markup.php +++ b/inc/generate-form-markup.php @@ -499,6 +499,7 @@ public static function get_form_markup( $id, $show_title_current_page = true, $s translate_form_title( (int) $id, $title ); ?>

translate_form_title( (int) get_the_ID(), Helper::get_string_value( get_the_title() ) ); ?> -

+

diff --git a/tests/unit/inc/compatibility/multilingual/test-multilingual-manager.php b/tests/unit/inc/compatibility/multilingual/test-multilingual-manager.php index 6f9b5085d..3ebb69abc 100644 --- a/tests/unit/inc/compatibility/multilingual/test-multilingual-manager.php +++ b/tests/unit/inc/compatibility/multilingual/test-multilingual-manager.php @@ -66,6 +66,10 @@ public function translate_package_string( array $package, string $name, string $ unset( $package, $name ); return $value; } + + public function delete_package( array $package ): void { + unset( $package ); + } } /** diff --git a/tests/unit/inc/compatibility/multilingual/test-string-backfill.php b/tests/unit/inc/compatibility/multilingual/test-string-backfill.php new file mode 100644 index 000000000..ac9c4bd41 --- /dev/null +++ b/tests/unit/inc/compatibility/multilingual/test-string-backfill.php @@ -0,0 +1,167 @@ +reset_multilingual_manager_singleton(); + } + + protected function tear_down(): void { + if ( null !== $this->filter_callback ) { + remove_filter( 'srfm_multilingual_provider', $this->filter_callback, 10 ); + $this->filter_callback = null; + } + delete_option( String_Backfill::DONE_OPTION ); + $this->reset_multilingual_manager_singleton(); + parent::tear_down(); + } + + private function install_active_provider(): void { + $stub = new Srfm_String_Backfill_Stub_Provider(); + $this->filter_callback = static function () use ( $stub ) { + return $stub; + }; + add_filter( 'srfm_multilingual_provider', $this->filter_callback, 10, 1 ); + $this->reset_multilingual_manager_singleton(); + } + + private function reset_multilingual_manager_singleton(): void { + try { + $ref = new ReflectionClass( \SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager::class ); + if ( $ref->hasProperty( 'instance' ) ) { + $prop = $ref->getProperty( 'instance' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + } + } catch ( \ReflectionException $e ) { + unset( $e ); + } + } + + public function test_maybe_schedule_skips_when_provider_inactive() { + if ( defined( 'ICL_SITEPRESS_VERSION' ) && class_exists( '\SitePress' ) ) { + $this->markTestSkipped( 'WPML appears to be loaded in the test environment.' ); + } + + // No provider installed → inactive → the done-marker must not be written. + String_Backfill::get_instance()->maybe_schedule(); + + $this->assertFalse( (bool) get_option( String_Backfill::DONE_OPTION ) ); + } + + public function test_maybe_schedule_marks_done_when_active() { + $this->install_active_provider(); + + String_Backfill::get_instance()->maybe_schedule(); + + $this->assertSame( SRFM_VER, get_option( String_Backfill::DONE_OPTION ) ); + } + + public function test_maybe_schedule_is_idempotent_for_current_version() { + $this->install_active_provider(); + update_option( String_Backfill::DONE_OPTION, SRFM_VER ); + + // Should early-return (already done for this version) without error and + // leave the marker untouched. + String_Backfill::get_instance()->maybe_schedule(); + + $this->assertSame( SRFM_VER, get_option( String_Backfill::DONE_OPTION ) ); + } + + public function test_backfill_one_runs_collect_for_the_form() { + $this->install_active_provider(); + + if ( ! function_exists( 'wp_insert_post' ) ) { + $this->markTestSkipped( 'WordPress test bootstrap not available.' ); + } + + $form_id = wp_insert_post( + [ + 'post_type' => 'sureforms_form', + 'post_status' => 'publish', + 'post_title' => 'Backfill Test Form', + ] + ); + + if ( is_wp_error( $form_id ) || 0 === (int) $form_id ) { + $this->markTestSkipped( 'Could not create a form post.' ); + } + + // Should not throw and should route to String_Collector::collect() for the + // form (collect() re-checks the active provider internally). + String_Backfill::get_instance()->backfill_one( (int) $form_id ); + + $this->assertTrue( true ); + } +} diff --git a/tests/unit/inc/compatibility/multilingual/test-string-collector.php b/tests/unit/inc/compatibility/multilingual/test-string-collector.php index 3ef14545b..8afcdd9a8 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-collector.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-collector.php @@ -38,6 +38,13 @@ class Srfm_String_Collector_Stub_Provider implements Provider { */ public $package_events = []; + /** + * Recorded delete_package() calls. + * + * @var array> + */ + public $deleted_packages = []; + public function is_active(): bool { return true; } @@ -105,6 +112,10 @@ public function register_package_string( array $package, string $name, string $v public function translate_package_string( array $package, string $name, string $value ): string { return $value; } + + public function delete_package( array $package ): void { + $this->deleted_packages[] = $package; + } } /** @@ -236,6 +247,9 @@ public function test_on_form_save_skips_when_post_is_revision() { $this->markTestSkipped( 'Could not create a revision post.' ); } + // Isolate the call under test from the parent form's own save_post→collect(). + $stub->registered = []; + String_Collector::get_instance()->on_form_save( (int) $revision_id ); $this->assertSame( [], $stub->registered, 'No strings should be registered for revisions.' ); @@ -277,6 +291,54 @@ static function ( $row ) use ( $form_id ) { $this->assertSame( 'sureforms', $row['domain'] ); } + public function test_collect_registers_form_title() { + $stub = $this->install_stub_provider(); + $form_id = $this->make_form( [ 'post_title' => 'Contact Us' ] ); + + String_Collector::get_instance()->collect( $form_id ); + + $this->assertContains( 'form_' . $form_id . '_form_title', $this->registered_names() ); + + $matched = array_filter( + $stub->registered, + static function ( $row ) use ( $form_id ) { + return $row['name'] === 'form_' . $form_id . '_form_title'; + } + ); + $row = array_values( $matched )[0]; + $this->assertSame( 'Contact Us', $row['value'] ); + } + + public function test_on_form_delete_deletes_package() { + $stub = $this->install_stub_provider(); + $form_id = $this->make_form(); + + String_Collector::get_instance()->on_form_delete( $form_id ); + + $this->assertCount( 1, $stub->deleted_packages, 'delete_package() should be called once.' ); + $this->assertSame( (string) $form_id, $stub->deleted_packages[0]['name'] ); + $this->assertSame( String_Translator::PACKAGE_KIND, $stub->deleted_packages[0]['kind'] ); + } + + public function test_on_form_delete_ignores_other_post_types() { + $stub = $this->install_stub_provider(); + $post_id = wp_insert_post( + [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Not a form', + ] + ); + + if ( is_wp_error( $post_id ) || 0 === (int) $post_id ) { + $this->markTestSkipped( 'Could not create a non-form post.' ); + } + + String_Collector::get_instance()->on_form_delete( (int) $post_id ); + + $this->assertSame( [], $stub->deleted_packages, 'Non-form post types must not delete a package.' ); + } + public function test_collect_registers_confirmation_messages_per_index() { $this->install_stub_provider(); $form_id = $this->make_form(); @@ -373,19 +435,31 @@ public function test_collect_skips_empty_strings() { ] ); + // Isolate from make_form()'s save_post→collect(); only the explicit call matters. + $stub->registered = []; + String_Collector::get_instance()->collect( $form_id ); - $this->assertSame( [], $stub->registered, 'Empty / whitespace-only strings should not be registered.' ); + // Every empty/whitespace meta value must be skipped (assert their absence + // rather than an exact set, so the test is robust to other stored meta). + $names = array_column( $stub->registered, 'name' ); + $this->assertNotContains( 'form_' . $form_id . '_submit_button', $names ); + $this->assertNotContains( 'form_' . $form_id . '_confirmation_0_message', $names ); + $this->assertNotContains( 'form_' . $form_id . '_notification_0_subject', $names ); + $this->assertNotContains( 'form_' . $form_id . '_notification_0_body', $names ); + $this->assertNotContains( 'form_' . $form_id . '_notification_0_from_name', $names ); } public function test_collect_handles_missing_meta_gracefully() { $stub = $this->install_stub_provider(); $form_id = $this->make_form(); - // No meta set at all. + // No meta set at all. Isolate from make_form()'s save_post→collect(). + $stub->registered = []; String_Collector::get_instance()->collect( $form_id ); - $this->assertSame( [], $stub->registered, 'A form with no meta should produce no registrations.' ); + // collect() runs without error and registers the title; absent meta is handled gracefully. + $this->assertContains( 'form_' . $form_id . '_form_title', array_column( $stub->registered, 'name' ), 'A form with no meta should still register its title.' ); } public function test_get_meta_string_returns_string_for_existing_meta() { @@ -414,9 +488,12 @@ public function test_register_if_non_empty_skips_whitespace_only_values() { $form_id = $this->make_form(); update_post_meta( $form_id, '_srfm_submit_button_text', " \n\t " ); + // Isolate from make_form()'s save_post→collect(). + $stub->registered = []; String_Collector::get_instance()->collect( $form_id ); - $this->assertSame( [], $stub->registered ); + // The whitespace-only submit button text must be skipped. + $this->assertNotContains( 'form_' . $form_id . '_submit_button', array_column( $stub->registered, 'name' ) ); } public function test_collect_validation_messages_registers_known_keys() { @@ -470,6 +547,9 @@ public function test_collect_block_strings_noops_on_empty_content() { $stub = $this->install_stub_provider(); $form_id = $this->make_form( [ 'post_content' => '' ] ); + // Isolate from make_form()'s save_post→collect(); collect_block_strings() + // itself registers no title, so empty content must yield nothing. + $stub->registered = []; String_Collector::get_instance()->collect_block_strings( $form_id ); $this->assertSame( [], $stub->registered ); diff --git a/tests/unit/inc/compatibility/multilingual/test-string-translator.php b/tests/unit/inc/compatibility/multilingual/test-string-translator.php index f5e9662f2..39e128e30 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-translator.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-translator.php @@ -97,6 +97,10 @@ public function translate_package_string( array $package, string $name, string $ $this->package_calls[] = compact( 'package', 'name', 'value' ); return '' !== $this->translate_returns ? $this->translate_returns : $value; } + + public function delete_package( array $package ): void { + unset( $package ); + } } /** @@ -159,6 +163,10 @@ public function translate_package_string( array $package, string $name, string $ unset( $package, $name ); return 'DE:' . $value; } + + public function delete_package( array $package ): void { + unset( $package ); + } } /** @@ -243,6 +251,31 @@ public function test_translate_submit_button_returns_original_when_empty() { $this->assertSame( '', $result ); } + public function test_translate_form_title_uses_correct_name() { + $result = String_Translator::get_instance()->translate_form_title( 42, 'Contact Us' ); + + $this->assertCount( 1, $this->stub->calls ); + $this->assertSame( 'form_42_form_title', $this->stub->calls[0]['name'] ); + $this->assertSame( 'Contact Us', $this->stub->calls[0]['value'] ); + $this->assertSame( 'Contact Us', $result ); + } + + public function test_translate_form_title_returns_original_when_empty() { + $result = String_Translator::get_instance()->translate_form_title( 42, '' ); + + $this->assertCount( 0, $this->stub->calls ); + $this->assertSame( '', $result ); + } + + public function test_form_package_includes_post_id_for_ate_detection() { + $package = String_Translator::form_package( 42 ); + + $this->assertSame( '42', $package['name'] ); + $this->assertSame( String_Translator::PACKAGE_KIND, $package['kind'] ); + $this->assertArrayHasKey( 'post_id', $package ); + $this->assertSame( '42', $package['post_id'] ); + } + public function test_translate_confirmation_message_includes_index() { $result = String_Translator::get_instance()->translate_confirmation_message( 42, 1, 'Thanks' ); From 2e5ab57fd57e8d2bea9444df301920dfaf0ceaf1 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Wed, 15 Jul 2026 16:04:41 +0530 Subject: [PATCH 02/78] test(wpml): cover delete_package + title_name; use null coalescing - Add the per-function tests the coverage check requires: delete_package() for the Provider interface, Null and WPML providers, and title_name() for String_Translator. - WPML_Provider::delete_package() uses ?? instead of isset()-ternary (PHP Insights style). --- .../multilingual/providers/wpml-provider.php | 4 +- .../providers/test-null-provider.php | 14 +++++++ .../multilingual/providers/test-provider.php | 4 ++ .../providers/test-wpml-provider.php | 39 +++++++++++++++++++ .../multilingual/test-string-translator.php | 4 ++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/inc/compatibility/multilingual/providers/wpml-provider.php b/inc/compatibility/multilingual/providers/wpml-provider.php index 0a4bdea62..2fb057e25 100644 --- a/inc/compatibility/multilingual/providers/wpml-provider.php +++ b/inc/compatibility/multilingual/providers/wpml-provider.php @@ -325,8 +325,8 @@ public function delete_package( array $package ): void { return; } - $name = isset( $package['name'] ) ? $package['name'] : ''; - $kind = isset( $package['kind'] ) ? $package['kind'] : ''; + $name = $package['name'] ?? ''; + $kind = $package['kind'] ?? ''; if ( '' === $name || '' === $kind ) { return; diff --git a/tests/unit/inc/compatibility/multilingual/providers/test-null-provider.php b/tests/unit/inc/compatibility/multilingual/providers/test-null-provider.php index 0cd6fe871..236db2935 100644 --- a/tests/unit/inc/compatibility/multilingual/providers/test-null-provider.php +++ b/tests/unit/inc/compatibility/multilingual/providers/test-null-provider.php @@ -147,4 +147,18 @@ public function test_translate_package_string() { $this->assertSame( 'Send', $this->provider->translate_package_string( $package, 'submit_button', 'Send' ) ); $this->assertSame( '', $this->provider->translate_package_string( $package, 'submit_button', '' ) ); } + + public function test_delete_package() { + $package = [ + 'kind' => 'SureForms Form', + 'name' => '1', + ]; + + $before = did_action( 'wpml_delete_package' ); + + // No-op: deleting a package fires no WPML action for the Null provider. + $this->provider->delete_package( $package ); + + $this->assertSame( $before, did_action( 'wpml_delete_package' ) ); + } } diff --git a/tests/unit/inc/compatibility/multilingual/providers/test-provider.php b/tests/unit/inc/compatibility/multilingual/providers/test-provider.php index a5e5e1f05..05c72f48e 100644 --- a/tests/unit/inc/compatibility/multilingual/providers/test-provider.php +++ b/tests/unit/inc/compatibility/multilingual/providers/test-provider.php @@ -94,4 +94,8 @@ public function test_register_package_string() { public function test_translate_package_string() { $this->assert_signature( 'translate_package_string', 3, 'string' ); } + + public function test_delete_package() { + $this->assert_signature( 'delete_package', 1, 'void' ); + } } diff --git a/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php b/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php index 99f00e2b7..a6a19225f 100644 --- a/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php +++ b/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php @@ -392,6 +392,45 @@ public function test_translate_package_string_returns_original_without_package_s ); } + public function test_delete_package() { + // Package support required: an inactive/no-package provider fires nothing. + $before = did_action( 'wpml_delete_package' ); + $this->provider->delete_package( + [ + 'kind' => 'SureForms Form', + 'name' => '7', + ] + ); + $this->assertSame( $before, did_action( 'wpml_delete_package' ), 'No package support -> no deletion action.' ); + + // Active provider with the package API present -> fires wpml_delete_package + // with the package name + kind. + $noop = static function () {}; + add_action( 'wpml_register_string', $noop ); + + $captured = []; + $spy = static function ( $name, $kind ) use ( &$captured ) { + $captured[] = [ $name, $kind ]; + }; + add_action( 'wpml_delete_package', $spy, 10, 2 ); + + $provider = new Srfm_Active_Wpml_Provider(); + $provider->delete_package( + [ + 'kind' => 'SureForms Form', + 'name' => '7', + ] + ); + + // A descriptor missing name/kind must not fire the action. + $provider->delete_package( [ 'kind' => 'SureForms Form' ] ); + + remove_action( 'wpml_delete_package', $spy, 10 ); + remove_action( 'wpml_register_string', $noop ); + + $this->assertSame( [ [ '7', 'SureForms Form' ] ], $captured ); + } + public function test_guess_current_url() { $active = new Srfm_Active_Wpml_Provider(); $method = new \ReflectionMethod( $active, 'guess_current_url' ); diff --git a/tests/unit/inc/compatibility/multilingual/test-string-translator.php b/tests/unit/inc/compatibility/multilingual/test-string-translator.php index 39e128e30..6084a7e7f 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-translator.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-translator.php @@ -251,6 +251,10 @@ public function test_translate_submit_button_returns_original_when_empty() { $this->assertSame( '', $result ); } + public function test_title_name() { + $this->assertSame( 'form_title', String_Translator::title_name() ); + } + public function test_translate_form_title_uses_correct_name() { $result = String_Translator::get_instance()->translate_form_title( 42, 'Contact Us' ); From 269d6e4e06be68e25c9de0d30cf452608139051f Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Thu, 16 Jul 2026 14:54:18 +0530 Subject: [PATCH 03/78] =?UTF-8?q?fix(wpml):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20schema-versioned=20backfill,=20unique=20enqueue,=20test=20ha?= =?UTF-8?q?rdening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backfill done-marker keyed to a dedicated SCHEMA_VERSION constant instead of SRFM_VER, so ordinary releases no longer re-enqueue a job per form on every update; marker stored with autoload=false (admin-only). - Enqueue with the Action Scheduler unique flag so concurrent/interrupted passes dedupe by (hook, args, group) instead of double-queuing. - Backfill query includes the 'future' post status; drop redundant int casts. - on_form_delete() builds a lightweight { name, kind } descriptor instead of the full form_package() (no get_the_title/get_edit_post_link on delete). - form_package() docblock lists post_id; note cms_id as the WPML fallback pending live verification (tracked in #2942). - Tests: assert one Action Scheduler job per form (via as_has_scheduled_action, shim fallback); backfill_one() actually registers the form's strings; permanent-delete-vs-trash through the real before_delete_post hook; provider-inactive delete bail; empty-title skip; WPML delete_package missing-kind branch. Backfill marker tests assert SCHEMA_VERSION. --- .../multilingual/string-backfill.php | 41 ++++-- .../multilingual/string-collector.php | 10 +- .../multilingual/string-translator.php | 5 +- .../providers/test-wpml-provider.php | 4 +- .../multilingual/test-string-backfill.php | 124 +++++++++++++++--- .../multilingual/test-string-collector.php | 60 +++++++++ 6 files changed, 211 insertions(+), 33 deletions(-) diff --git a/inc/compatibility/multilingual/string-backfill.php b/inc/compatibility/multilingual/string-backfill.php index 30f88f799..143ff25cf 100644 --- a/inc/compatibility/multilingual/string-backfill.php +++ b/inc/compatibility/multilingual/string-backfill.php @@ -22,9 +22,10 @@ /** * String_Backfill. * - * One-time (per plugin version) pass that runs {@see String_Collector::collect()} - * for every existing form once a multilingual provider is active, so their - * String Packages are registered without needing a manual re-save. + * One-time (per {@see self::SCHEMA_VERSION}) pass that runs + * {@see String_Collector::collect()} for every existing form once a multilingual + * provider is active, so their String Packages are registered without needing a + * manual re-save. * * @since x.x.x */ @@ -32,14 +33,22 @@ class String_Backfill { use Get_Instance; /** - * Option that records the plugin version the backfill last completed for. - * Storing the version (not a bare flag) lets a future release re-run the - * backfill if the registered string set changes. + * Option that records the schema version the backfill last completed for. * * @since x.x.x */ public const DONE_OPTION = 'srfm_wpml_backfill_done'; + /** + * Backfill schema version. Bump this ONLY when the set of strings registered + * by String_Collector changes, to force a one-time re-backfill. Deliberately + * NOT tied to SRFM_VER, so ordinary plugin releases don't re-enqueue a job + * per form on every update. + * + * @since x.x.x + */ + public const SCHEMA_VERSION = '1'; + /** * Action Scheduler hook that backfills a single form. * @@ -61,7 +70,7 @@ public function __construct() { * Queue a one-time backfill of all existing forms when a provider is active. * * Bails when no multilingual provider is active, when the backfill has - * already completed for the current plugin version, or when Action Scheduler + * already completed for the current schema version, or when Action Scheduler * is unavailable. Each form is processed in its own async job so a large form * count never blocks the request. * @@ -73,7 +82,7 @@ public function maybe_schedule(): void { return; } - if ( SRFM_VER === get_option( self::DONE_OPTION ) ) { + if ( self::SCHEMA_VERSION === get_option( self::DONE_OPTION ) ) { return; } @@ -84,19 +93,23 @@ public function maybe_schedule(): void { $form_ids = get_posts( [ 'post_type' => SRFM_FORMS_POST_TYPE, - 'post_status' => [ 'publish', 'draft', 'pending', 'private' ], + 'post_status' => [ 'publish', 'draft', 'pending', 'private', 'future' ], 'fields' => 'ids', 'numberposts' => -1, ] ); foreach ( $form_ids as $form_id ) { - as_enqueue_async_action( self::HOOK, [ 'form_id' => (int) $form_id ], 'srfm' ); + $form_id = (int) $form_id; + // $unique = true: dedupe by (hook, args, group) so concurrent admin + // requests or an interrupted-then-retried pass never double-queue a form. + as_enqueue_async_action( self::HOOK, [ 'form_id' => $form_id ], 'srfm', true ); } - // Record completion up front: the per-form jobs are queued, and this pass - // must not re-queue them on the next admin load. - update_option( self::DONE_OPTION, SRFM_VER ); + // Record completion up front so the pass doesn't re-queue on the next admin + // load. autoload=false: this admin-only marker never needs to load on the + // front end. + update_option( self::DONE_OPTION, self::SCHEMA_VERSION, false ); } /** @@ -107,6 +120,6 @@ public function maybe_schedule(): void { * @return void */ public function backfill_one( int $form_id ): void { - String_Collector::get_instance()->collect( (int) $form_id ); + String_Collector::get_instance()->collect( $form_id ); } } diff --git a/inc/compatibility/multilingual/string-collector.php b/inc/compatibility/multilingual/string-collector.php index 9d42e9ddc..fd469dd55 100644 --- a/inc/compatibility/multilingual/string-collector.php +++ b/inc/compatibility/multilingual/string-collector.php @@ -137,7 +137,15 @@ public function on_form_delete( int $form_id ): void { return; } - $provider->delete_package( String_Translator::form_package( $form_id ) ); + // Deletion only needs the package identity (name + kind); build it directly + // rather than String_Translator::form_package(), which also runs + // get_the_title() / get_edit_post_link() the delete path doesn't use. + $provider->delete_package( + [ + 'name' => (string) $form_id, + 'kind' => String_Translator::PACKAGE_KIND, + ] + ); } /** diff --git a/inc/compatibility/multilingual/string-translator.php b/inc/compatibility/multilingual/string-translator.php index e2e7da1d1..ee424da6f 100644 --- a/inc/compatibility/multilingual/string-translator.php +++ b/inc/compatibility/multilingual/string-translator.php @@ -189,7 +189,7 @@ public static function block_type_label( string $block_name ): string { * * @param int $form_id Form post ID. * @since 2.11.0 - * @return array { kind, name, title, edit_link }. + * @return array { kind, name, title, edit_link, post_id }. */ public static function form_package( int $form_id ): array { $title = get_the_title( $form_id ); @@ -207,6 +207,9 @@ public static function form_package( int $form_id ): array { 'edit_link' => is_string( $edit_link ) ? $edit_link : '', // Associate the package with its form post so WPML's "Translate // Everything Automatically" queues the package alongside the post. + // NOTE: pending live-WPML confirmation of the exact key WPML reads + // for post association — `cms_id` is the documented fallback if + // `post_id` isn't consumed. Tracked in issue #2942. 'post_id' => (string) $form_id, ]; } diff --git a/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php b/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php index a6a19225f..b554bfba8 100644 --- a/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php +++ b/tests/unit/inc/compatibility/multilingual/providers/test-wpml-provider.php @@ -422,8 +422,10 @@ public function test_delete_package() { ] ); - // A descriptor missing name/kind must not fire the action. + // A descriptor missing name must not fire the action. $provider->delete_package( [ 'kind' => 'SureForms Form' ] ); + // A descriptor missing kind must not fire the action either. + $provider->delete_package( [ 'name' => '7' ] ); remove_action( 'wpml_delete_package', $spy, 10 ); remove_action( 'wpml_register_string', $noop ); diff --git a/tests/unit/inc/compatibility/multilingual/test-string-backfill.php b/tests/unit/inc/compatibility/multilingual/test-string-backfill.php index ac9c4bd41..811680adf 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-backfill.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-backfill.php @@ -10,18 +10,40 @@ use SRFM\Inc\Compatibility\Multilingual\Providers\Provider; // Action Scheduler may not be loaded in the unit-test bootstrap; the backfill -// guards on this function, so provide a no-op so the "active" path is exercisable. +// guards on this function, so provide a recording shim so both the "active" path +// and the per-form enqueue behaviour are observable. Calls land in +// $GLOBALS['srfm_test_as_calls']; SRFM_TEST_AS_SHIM marks the shim as ours (so a +// test can skip when the real Action Scheduler is loaded and this shim isn't). if ( ! function_exists( 'as_enqueue_async_action' ) ) { + define( 'SRFM_TEST_AS_SHIM', true ); function as_enqueue_async_action( $hook, $args = [], $group = '', $unique = false, $priority = 10 ) { - unset( $hook, $args, $group, $unique, $priority ); - return 0; + unset( $priority ); + if ( ! isset( $GLOBALS['srfm_test_as_calls'] ) || ! is_array( $GLOBALS['srfm_test_as_calls'] ) ) { + $GLOBALS['srfm_test_as_calls'] = []; + } + $GLOBALS['srfm_test_as_calls'][] = [ + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + 'unique' => $unique, + ]; + return count( $GLOBALS['srfm_test_as_calls'] ); } } /** - * Minimal active-provider stub whose only meaningful signal is is_active() = true. + * Active-provider stub that records register_string() calls so the backfill's + * routing into String_Collector::collect() can be asserted. supports_packages() + * is false, so collect() takes the flat `form_{id}_{name}` fallback path. */ class Srfm_String_Backfill_Stub_Provider implements Provider { + /** + * Recorded register_string() names. + * + * @var array + */ + public $registered = []; + public function is_active(): bool { return true; } @@ -32,6 +54,7 @@ public function default_language(): string { return 'xx'; } public function register_string( string $name, string $value, string $domain = 'sureforms' ): void { + $this->registered[] = $name; } public function translate( string $value, string $name, string $domain = 'sureforms', ?string $language = null ): string { return $value; @@ -74,6 +97,7 @@ class Test_String_Backfill extends TestCase { protected function set_up(): void { parent::set_up(); delete_option( String_Backfill::DONE_OPTION ); + $GLOBALS['srfm_test_as_calls'] = []; $this->reset_multilingual_manager_singleton(); } @@ -83,17 +107,20 @@ protected function tear_down(): void { $this->filter_callback = null; } delete_option( String_Backfill::DONE_OPTION ); + $GLOBALS['srfm_test_as_calls'] = []; $this->reset_multilingual_manager_singleton(); parent::tear_down(); } - private function install_active_provider(): void { + private function install_active_provider(): Srfm_String_Backfill_Stub_Provider { $stub = new Srfm_String_Backfill_Stub_Provider(); $this->filter_callback = static function () use ( $stub ) { return $stub; }; add_filter( 'srfm_multilingual_provider', $this->filter_callback, 10, 1 ); $this->reset_multilingual_manager_singleton(); + + return $stub; } private function reset_multilingual_manager_singleton(): void { @@ -120,27 +147,28 @@ public function test_maybe_schedule_skips_when_provider_inactive() { $this->assertFalse( (bool) get_option( String_Backfill::DONE_OPTION ) ); } - public function test_maybe_schedule_marks_done_when_active() { + public function test_maybe_schedule_marks_done_with_schema_version() { $this->install_active_provider(); String_Backfill::get_instance()->maybe_schedule(); - $this->assertSame( SRFM_VER, get_option( String_Backfill::DONE_OPTION ) ); + // Marker is the schema version, NOT SRFM_VER, so ordinary releases don't re-run. + $this->assertSame( String_Backfill::SCHEMA_VERSION, get_option( String_Backfill::DONE_OPTION ) ); } - public function test_maybe_schedule_is_idempotent_for_current_version() { + public function test_maybe_schedule_is_idempotent_for_current_schema_version() { $this->install_active_provider(); - update_option( String_Backfill::DONE_OPTION, SRFM_VER ); + update_option( String_Backfill::DONE_OPTION, String_Backfill::SCHEMA_VERSION ); - // Should early-return (already done for this version) without error and - // leave the marker untouched. String_Backfill::get_instance()->maybe_schedule(); - $this->assertSame( SRFM_VER, get_option( String_Backfill::DONE_OPTION ) ); + // Already done for this schema version → no enqueues, marker untouched. + $this->assertSame( String_Backfill::SCHEMA_VERSION, get_option( String_Backfill::DONE_OPTION ) ); + $this->assertSame( [], $GLOBALS['srfm_test_as_calls'], 'A completed schema version must not re-enqueue.' ); } public function test_backfill_one_runs_collect_for_the_form() { - $this->install_active_provider(); + $stub = $this->install_active_provider(); if ( ! function_exists( 'wp_insert_post' ) ) { $this->markTestSkipped( 'WordPress test bootstrap not available.' ); @@ -158,10 +186,74 @@ public function test_backfill_one_runs_collect_for_the_form() { $this->markTestSkipped( 'Could not create a form post.' ); } - // Should not throw and should route to String_Collector::collect() for the - // form (collect() re-checks the active provider internally). + // Isolate from the save_post→collect() that wp_insert_post already fired. + $stub->registered = []; + String_Backfill::get_instance()->backfill_one( (int) $form_id ); - $this->assertTrue( true ); + // backfill_one() must route into String_Collector::collect(), which + // registers the form's strings — the title always registers for a titled form. + $this->assertContains( 'form_' . (int) $form_id . '_form_title', $stub->registered, 'backfill_one() should collect the form\'s strings.' ); + + wp_delete_post( (int) $form_id, true ); + } + + public function test_maybe_schedule_enqueues_one_job_per_form() { + if ( ! function_exists( 'wp_insert_post' ) ) { + $this->markTestSkipped( 'WordPress test bootstrap not available.' ); + } + + $this->install_active_provider(); + + $form_ids = []; + foreach ( [ 'One', 'Two', 'Three' ] as $suffix ) { + $id = wp_insert_post( + [ + 'post_type' => 'sureforms_form', + 'post_status' => 'publish', + 'post_title' => 'Enqueue Form ' . $suffix, + ] + ); + if ( is_wp_error( $id ) || 0 === (int) $id ) { + $this->markTestSkipped( 'Could not create form posts.' ); + } + $form_ids[] = (int) $id; + } + + // Reset so only the maybe_schedule() enqueues are considered. + $GLOBALS['srfm_test_as_calls'] = []; + + String_Backfill::get_instance()->maybe_schedule(); + + // Prefer Action Scheduler's own API (works in CI where real AS is loaded); + // fall back to the recording shim when AS isn't present. + if ( function_exists( 'as_has_scheduled_action' ) ) { + foreach ( $form_ids as $id ) { + $this->assertTrue( + as_has_scheduled_action( String_Backfill::HOOK, [ 'form_id' => $id ], 'srfm' ), + "Form {$id} should have a backfill action scheduled." + ); + } + } elseif ( defined( 'SRFM_TEST_AS_SHIM' ) ) { + $enqueued_ids = []; + foreach ( $GLOBALS['srfm_test_as_calls'] as $call ) { + $this->assertSame( String_Backfill::HOOK, $call['hook'] ); + $this->assertSame( 'srfm', $call['group'] ); + $this->assertTrue( $call['unique'], 'Enqueue must pass the unique flag to dedupe re-queues.' ); + $this->assertArrayHasKey( 'form_id', $call['args'] ); + $this->assertIsInt( $call['args']['form_id'] ); + $enqueued_ids[] = $call['args']['form_id']; + } + foreach ( $form_ids as $id ) { + $this->assertContains( $id, $enqueued_ids, "Form {$id} should be enqueued." ); + $this->assertSame( 1, count( array_keys( $enqueued_ids, $id, true ) ), "Form {$id} enqueued more than once." ); + } + } else { + $this->markTestSkipped( 'Action Scheduler not available and no recording shim installed.' ); + } + + foreach ( $form_ids as $id ) { + wp_delete_post( $id, true ); + } } } diff --git a/tests/unit/inc/compatibility/multilingual/test-string-collector.php b/tests/unit/inc/compatibility/multilingual/test-string-collector.php index 8afcdd9a8..d92586421 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-collector.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-collector.php @@ -320,6 +320,66 @@ public function test_on_form_delete_deletes_package() { $this->assertSame( String_Translator::PACKAGE_KIND, $stub->deleted_packages[0]['kind'] ); } + public function test_permanent_delete_triggers_package_deletion_via_hook() { + $stub = $this->install_stub_provider(); + $form_id = $this->make_form(); + + // Exercise the real before_delete_post wiring (not a direct on_form_delete call). + $stub->deleted_packages = []; + wp_delete_post( $form_id, true ); + + $this->assertCount( 1, $stub->deleted_packages, 'Permanent delete should delete the package.' ); + $this->assertSame( (string) $form_id, $stub->deleted_packages[0]['name'] ); + $this->assertSame( String_Translator::PACKAGE_KIND, $stub->deleted_packages[0]['kind'] ); + } + + public function test_trashing_a_form_does_not_delete_package() { + $stub = $this->install_stub_provider(); + $form_id = $this->make_form(); + + $stub->deleted_packages = []; + wp_trash_post( $form_id ); + + // Trash is reversible → before_delete_post does not fire → no deletion. + $this->assertSame( [], $stub->deleted_packages, 'Trashing must not delete the package.' ); + + wp_delete_post( $form_id, true ); + } + + public function test_on_form_delete_bails_when_provider_inactive() { + if ( defined( 'ICL_SITEPRESS_VERSION' ) && class_exists( '\SitePress' ) ) { + $this->markTestSkipped( 'WPML appears to be loaded in the test environment.' ); + } + + // No stub installed → provider inactive. Must be a no-op (no fatal). + $form_id = $this->make_form(); + + $before = did_action( 'wpml_delete_package' ); + String_Collector::get_instance()->on_form_delete( $form_id ); + + $this->assertSame( $before, did_action( 'wpml_delete_package' ) ); + } + + public function test_collect_skips_empty_title() { + $stub = $this->install_stub_provider(); + $form_id = $this->make_form(); + + // Force an empty title directly so no default is re-applied, then isolate + // from make_form()'s save_post→collect(). + wp_update_post( + [ + 'ID' => $form_id, + 'post_title' => '', + ] + ); + clean_post_cache( $form_id ); + $stub->registered = []; + + String_Collector::get_instance()->collect( $form_id ); + + $this->assertNotContains( 'form_' . $form_id . '_form_title', array_column( $stub->registered, 'name' ), 'An empty form title must not be registered.' ); + } + public function test_on_form_delete_ignores_other_post_types() { $stub = $this->install_stub_provider(); $post_id = wp_insert_post( From 4e7708c8e4b62c33f7d4e5855bf7c056dfdf5f2b Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Sun, 19 Jul 2026 20:21:56 +0530 Subject: [PATCH 04/78] fix(wpml): declare package kind via wpml_active_string_package_kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WPML's package-level Translate Everything Automatically (TEA) gate reads the wpml_active_string_package_kinds filter to decide which string-package kinds to auto-translate. We registered one package per form (kind 'SureForms Form') and associated it via post_id, but never declared the kind on that filter, so TEA skipped SureForms form packages. Declare it from String_Collector, keyed by sanitize_title( PACKAGE_KIND ) = 'sureforms-form' — the same slug WPML derives for our packages, so the declaration matches already-registered packages. post_id is retained. Verified: filter output shape + registration (live wp eval), PHPCS, PHPStan L9 clean, plus two unit tests. --- .../multilingual/string-collector.php | 37 +++++++++++++++++++ .../multilingual/test-string-collector.php | 32 ++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/inc/compatibility/multilingual/string-collector.php b/inc/compatibility/multilingual/string-collector.php index fd469dd55..d73cc3c38 100644 --- a/inc/compatibility/multilingual/string-collector.php +++ b/inc/compatibility/multilingual/string-collector.php @@ -85,6 +85,43 @@ public function __construct() { if ( is_admin() ) { add_action( 'admin_init', [ $this, 'collect_validation_messages' ] ); } + + // Declare our String Package kind so WPML's "Translate Everything + // Automatically" gate — which reads this filter, not just the per-package + // post association — queues SureForms form packages for auto-translation. + // Registered unconditionally: WPML only fires this filter when active, and + // the callback is a pure array append, so it is a no-op otherwise. + add_filter( 'wpml_active_string_package_kinds', [ $this, 'declare_package_kind' ] ); + } + + /** + * Declare the SureForms String Package kind to WPML. + * + * WPML's package-level "Translate Everything Automatically" gate reads this + * filter to decide which string-package kinds to auto-translate. Each form is + * registered as one package with kind {@see String_Translator::PACKAGE_KIND}, + * from which WPML derives the kind slug via `sanitize_title()`; computing the + * key the same way here guarantees it matches the slug WPML assigns to our + * packages (no hardcoded slug that could drift from the kind label). + * + * @param mixed $kinds Associative map of kind slug => { title, slug, plural }. + * @since x.x.x + * @return mixed The kinds map with the SureForms Form kind added. + */ + public function declare_package_kind( $kinds ) { + if ( ! is_array( $kinds ) ) { + return $kinds; + } + + $slug = sanitize_title( String_Translator::PACKAGE_KIND ); + + $kinds[ $slug ] = [ + 'title' => String_Translator::PACKAGE_KIND, + 'slug' => $slug, + 'plural' => __( 'SureForms Forms', 'sureforms' ), + ]; + + return $kinds; } /** diff --git a/tests/unit/inc/compatibility/multilingual/test-string-collector.php b/tests/unit/inc/compatibility/multilingual/test-string-collector.php index d92586421..84a613893 100644 --- a/tests/unit/inc/compatibility/multilingual/test-string-collector.php +++ b/tests/unit/inc/compatibility/multilingual/test-string-collector.php @@ -320,6 +320,38 @@ public function test_on_form_delete_deletes_package() { $this->assertSame( String_Translator::PACKAGE_KIND, $stub->deleted_packages[0]['kind'] ); } + public function test_declare_package_kind_declares_sureforms_kind_on_the_wpml_filter() { + // Constructing the collector registers the wpml_active_string_package_kinds filter. + String_Collector::get_instance(); + + $kinds = apply_filters( 'wpml_active_string_package_kinds', [] ); + + $this->assertIsArray( $kinds ); + $this->assertArrayHasKey( 'sureforms-form', $kinds, 'The SureForms package kind must be declared for WPML TEA.' ); + $this->assertSame( + [ + 'title' => 'SureForms Form', + 'slug' => 'sureforms-form', + 'plural' => 'SureForms Forms', + ], + $kinds['sureforms-form'] + ); + // Slug must equal what WPML derives from the package kind. + $this->assertSame( sanitize_title( String_Translator::PACKAGE_KIND ), array_key_first( $kinds ) ); + } + + public function test_declare_package_kind_preserves_existing_and_ignores_non_array() { + $collector = String_Collector::get_instance(); + + $existing = [ 'other-kind' => [ 'title' => 'Other', 'slug' => 'other-kind', 'plural' => 'Others' ] ]; + $result = $collector->declare_package_kind( $existing ); + $this->assertArrayHasKey( 'other-kind', $result, 'Existing kinds must be preserved.' ); + $this->assertArrayHasKey( 'sureforms-form', $result ); + + // Non-array input is returned unchanged (defensive against other filter callbacks). + $this->assertSame( 'not-an-array', $collector->declare_package_kind( 'not-an-array' ) ); + } + public function test_permanent_delete_triggers_package_deletion_via_hook() { $stub = $this->install_stub_provider(); $form_id = $this->make_form(); From 7ce98c4a36dc46c609304b6cf05e318134189229 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 09:22:50 +0530 Subject: [PATCH 05/78] Fix: entries search never matched submitted form data The admin Entries search ("Search entries...") only built two WHERE conditions: an exact ID match (numeric terms only) and form_id IN (...) resolved from form titles. form_data was never searched, and a non-numeric term matching no form title forced an always-empty ID = 0 condition - so text a respondent submitted could never be found. Both the React admin REST endpoint (/sureforms/v1/entries/list) and the Abilities/MCP list-entries funnel into the same Entries::get_entries(), so both missed. Fix: add a form_data LIKE condition to the search OR-group for every non-empty term (numeric too - phone numbers, zip codes), and drop the dead forced-empty fallback. form_data stores plain JSON (encode_json uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), so LIKE matches submitted values textually including emails, URLs and non-ASCII input. The Base query compiler already supports LIKE and wraps the value in "%...%" itself; esc_like() neutralizes user-typed wildcard characters. Also corrects the list-entries ability description ("by entry ID" -> ID, form title, or submitted form data). Verified end-to-end on a live install: searching a submitted name returns its entry; a gibberish term returns zero; numeric ID and form-title search behavior unchanged. Adds unit tests for the new condition, the removed forced-empty fallback, and wildcard escaping. --- inc/abilities/entries/list-entries.php | 2 +- inc/entries.php | 29 ++++--- tests/unit/inc/test-entries.php | 108 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/inc/abilities/entries/list-entries.php b/inc/abilities/entries/list-entries.php index aa245ad49..466a6b8b9 100644 --- a/inc/abilities/entries/list-entries.php +++ b/inc/abilities/entries/list-entries.php @@ -74,7 +74,7 @@ public function get_input_schema() { ], 'search' => [ 'type' => 'string', - 'description' => __( 'Search entries by entry ID.', 'sureforms' ), + 'description' => __( 'Search entries by entry ID, form title, or submitted form data.', 'sureforms' ), ], 'date_from' => [ 'type' => 'string', diff --git a/inc/entries.php b/inc/entries.php index 56adfbb12..3bf443c57 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -580,8 +580,10 @@ private static function build_where_conditions( $args ) { } } - // Filter by search (entry ID + form title). + // Filter by search (entry ID + form title + submitted form data). if ( ! empty( $args['search'] ) && is_string( $args['search'] ) ) { + global $wpdb; + $search_term = sanitize_text_field( $args['search'] ); $search_group = [ 'RELATION' => 'OR' ]; @@ -604,18 +606,19 @@ private static function build_where_conditions( $args ) { ]; } - // Only add if we have search conditions, otherwise force empty result. - if ( count( $search_group ) > 1 ) { - $where_conditions[] = $search_group; - } else { - $where_conditions[] = [ - [ - 'key' => 'ID', - 'compare' => '=', - 'value' => 0, - ], - ]; - } + // Match submitted form data. The form_data column stores plain JSON + // (Helper::encode_json() uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + // so a LIKE matches submitted values textually — including emails, URLs and + // non-ASCII input. The query compiler (Base::get_query_clauses()) wraps the + // value in "%...%" itself; esc_like() here neutralizes user-typed wildcard + // characters ("%", "_") so they match literally. + $search_group[] = [ + 'key' => 'form_data', + 'compare' => 'LIKE', + 'value' => $wpdb->esc_like( $search_term ), + ]; + + $where_conditions[] = $search_group; } return $where_conditions; diff --git a/tests/unit/inc/test-entries.php b/tests/unit/inc/test-entries.php index cd19a4662..50f0c1845 100644 --- a/tests/unit/inc/test-entries.php +++ b/tests/unit/inc/test-entries.php @@ -264,6 +264,114 @@ public function test_build_where_conditions_search_filter() { $this->assertTrue( $found_search ); } + /** + * Test build_where_conditions search includes submitted form data (form_data LIKE). + * + * Regression: search previously matched only numeric entry IDs and form titles — + * form_data was never in the WHERE, so text a respondent submitted could never be + * found; a non-numeric term matching no form title even forced an always-empty + * `ID = 0` condition. + */ + public function test_build_where_conditions_search_includes_form_data() { + $args = [ + 'form_id' => 0, + 'status' => 'all', + 'search' => 'jane@example.com', + 'date_from' => '', + 'date_to' => '', + 'entry_ids' => [], + ]; + $result = $this->call_private_method_static( Entries::class, 'build_where_conditions', [ $args ] ); + + $found_form_data_like = false; + $found_forced_empty = false; + foreach ( $result as $group ) { + foreach ( $group as $condition ) { + if ( ! is_array( $condition ) || ! isset( $condition['key'] ) ) { + continue; + } + if ( 'form_data' === $condition['key'] && 'LIKE' === $condition['compare'] ) { + $found_form_data_like = true; + $this->assertSame( 'jane@example.com', $condition['value'] ); + } + if ( 'ID' === $condition['key'] && '=' === $condition['compare'] && 0 === $condition['value'] ) { + $found_forced_empty = true; + } + } + } + + $this->assertTrue( $found_form_data_like, 'Search must include a form_data LIKE condition.' ); + $this->assertFalse( $found_forced_empty, 'Non-numeric search must not force an empty (ID = 0) result.' ); + } + + /** + * Test build_where_conditions numeric search matches both entry ID and form data. + * + * A numeric term can be submitted data too (phone number, zip code), so it must + * produce the form_data LIKE condition alongside the exact ID match. + */ + public function test_build_where_conditions_numeric_search_includes_form_data() { + $args = [ + 'form_id' => 0, + 'status' => 'all', + 'search' => '9876543210', + 'date_from' => '', + 'date_to' => '', + 'entry_ids' => [], + ]; + $result = $this->call_private_method_static( Entries::class, 'build_where_conditions', [ $args ] ); + + $found_id_match = false; + $found_form_data = false; + foreach ( $result as $group ) { + foreach ( $group as $condition ) { + if ( ! is_array( $condition ) || ! isset( $condition['key'] ) ) { + continue; + } + if ( 'ID' === $condition['key'] && '=' === $condition['compare'] ) { + $found_id_match = true; + } + if ( 'form_data' === $condition['key'] && 'LIKE' === $condition['compare'] ) { + $found_form_data = true; + } + } + } + + $this->assertTrue( $found_id_match ); + $this->assertTrue( $found_form_data ); + } + + /** + * Test build_where_conditions escapes LIKE wildcards in the search term. + * + * The query compiler wraps the value in "%...%" itself; user-typed "%" and "_" + * must be escaped so they match literally instead of acting as wildcards. + */ + public function test_build_where_conditions_search_escapes_like_wildcards() { + global $wpdb; + + $args = [ + 'form_id' => 0, + 'status' => 'all', + 'search' => '100%_done', + 'date_from' => '', + 'date_to' => '', + 'entry_ids' => [], + ]; + $result = $this->call_private_method_static( Entries::class, 'build_where_conditions', [ $args ] ); + + $like_value = null; + foreach ( $result as $group ) { + foreach ( $group as $condition ) { + if ( is_array( $condition ) && isset( $condition['key'] ) && 'form_data' === $condition['key'] ) { + $like_value = $condition['value']; + } + } + } + + $this->assertSame( $wpdb->esc_like( '100%_done' ), $like_value ); + } + /** * Helper method to call private static methods for testing. */ From ae88084fa350d41e0f67115161a00a53eb3b8641 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:07:05 +0530 Subject: [PATCH 06/78] Require 3+ characters for entry-data text search The form_data LIKE cannot use an index, so guard it for low-resource hosts: - Backend (authoritative, covers REST + MCP ability): skip the form_data LIKE for text terms under 3 characters (mb_strlen, so multibyte input counts correctly). Numeric terms are exempt - they resolve to an exact, indexed entry-ID lookup. A short text term matching no form title forces an empty result again (an OR-group with no conditions would be dropped by the query compiler and return every entry). - Admin UI: Enter-to-search ignores non-numeric terms under 3 characters; empty submit still clears the search. - Ability description documents the minimum. Tests: short text term produces no form_data condition and forces an empty result; short numeric term still matches the entry ID without scanning. Verified live: 3+ char text matches, 2-char text returns 0, numeric ID exact match works at any length. --- inc/abilities/entries/list-entries.php | 2 +- inc/entries.php | 33 +++++++-- .../entries/components/EntriesFilters.js | 10 ++- tests/unit/inc/test-entries.php | 72 +++++++++++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/inc/abilities/entries/list-entries.php b/inc/abilities/entries/list-entries.php index 466a6b8b9..a239873de 100644 --- a/inc/abilities/entries/list-entries.php +++ b/inc/abilities/entries/list-entries.php @@ -74,7 +74,7 @@ public function get_input_schema() { ], 'search' => [ 'type' => 'string', - 'description' => __( 'Search entries by entry ID, form title, or submitted form data.', 'sureforms' ), + 'description' => __( 'Search entries by entry ID, form title, or submitted form data. Text search requires at least 3 characters; numeric terms match the entry ID exactly.', 'sureforms' ), ], 'date_from' => [ 'type' => 'string', diff --git a/inc/entries.php b/inc/entries.php index 3bf443c57..7453a80cf 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -612,13 +612,34 @@ private static function build_where_conditions( $args ) { // non-ASCII input. The query compiler (Base::get_query_clauses()) wraps the // value in "%...%" itself; esc_like() here neutralizes user-typed wildcard // characters ("%", "_") so they match literally. - $search_group[] = [ - 'key' => 'form_data', - 'compare' => 'LIKE', - 'value' => $wpdb->esc_like( $search_term ), - ]; + // Performance guard: the LIKE cannot use an index (full scan of the LONGTEXT + // column within the other filters), so require at least 3 characters before + // matching form data. Shorter terms would match almost every row anyway while + // costing the most. Numeric terms are exempt above (exact, indexed ID lookup), + // and form-title matching is a cheap separate posts query. + if ( mb_strlen( $search_term ) >= 3 ) { + $search_group[] = [ + 'key' => 'form_data', + 'compare' => 'LIKE', + 'value' => $wpdb->esc_like( $search_term ), + ]; + } - $where_conditions[] = $search_group; + // Guard: a short non-numeric term that matches no form title produces no + // usable condition — force an empty result instead of silently returning + // every entry (an OR-group with no conditions would be dropped by the + // query compiler). + if ( count( $search_group ) > 1 ) { + $where_conditions[] = $search_group; + } else { + $where_conditions[] = [ + [ + 'key' => 'ID', + 'compare' => '=', + 'value' => 0, + ], + ]; + } } return $where_conditions; diff --git a/src/admin/entries/components/EntriesFilters.js b/src/admin/entries/components/EntriesFilters.js index da71c8b3f..611a202f9 100644 --- a/src/admin/entries/components/EntriesFilters.js +++ b/src/admin/entries/components/EntriesFilters.js @@ -115,7 +115,15 @@ const EntriesFilters = ( { const handleSearchKeyDown = ( event ) => { if ( event.key === 'Enter' ) { - onSearchChange( event.target.value ); + const value = event.target.value.trim(); + // Text searches scan submitted form data (unindexed LIKE), so require at + // least 3 characters. Numeric terms are exempt — they resolve to an exact, + // indexed entry-ID lookup. Empty submits are allowed to clear the search. + const isNumeric = /^\d+$/.test( value ); + if ( value !== '' && ! isNumeric && value.length < 3 ) { + return; + } + onSearchChange( value ); } }; diff --git a/tests/unit/inc/test-entries.php b/tests/unit/inc/test-entries.php index 50f0c1845..2e34d5386 100644 --- a/tests/unit/inc/test-entries.php +++ b/tests/unit/inc/test-entries.php @@ -341,6 +341,78 @@ public function test_build_where_conditions_numeric_search_includes_form_data() $this->assertTrue( $found_form_data ); } + /** + * Test build_where_conditions skips form_data LIKE for short text terms. + * + * The LIKE cannot use an index, so text terms under 3 characters must not trigger + * the form_data scan; with no other usable condition the search must force an + * empty result (never fall through to returning all entries). + */ + public function test_build_where_conditions_short_text_search_skips_form_data() { + $args = [ + 'form_id' => 0, + 'status' => 'all', + 'search' => 'ab', + 'date_from' => '', + 'date_to' => '', + 'entry_ids' => [], + ]; + $result = $this->call_private_method_static( Entries::class, 'build_where_conditions', [ $args ] ); + + $found_form_data = false; + $found_forced_empty = false; + foreach ( $result as $group ) { + foreach ( $group as $condition ) { + if ( ! is_array( $condition ) || ! isset( $condition['key'] ) ) { + continue; + } + if ( 'form_data' === $condition['key'] ) { + $found_form_data = true; + } + if ( 'ID' === $condition['key'] && '=' === $condition['compare'] && 0 === $condition['value'] ) { + $found_forced_empty = true; + } + } + } + + $this->assertFalse( $found_form_data, 'Terms under 3 characters must not scan form_data.' ); + $this->assertTrue( $found_forced_empty, 'A short term with no other match must force an empty result.' ); + } + + /** + * Test build_where_conditions short numeric term still searches by entry ID. + */ + public function test_build_where_conditions_short_numeric_search_keeps_id_match() { + $args = [ + 'form_id' => 0, + 'status' => 'all', + 'search' => '55', + 'date_from' => '', + 'date_to' => '', + 'entry_ids' => [], + ]; + $result = $this->call_private_method_static( Entries::class, 'build_where_conditions', [ $args ] ); + + $found_id_match = false; + $found_form_data = false; + foreach ( $result as $group ) { + foreach ( $group as $condition ) { + if ( ! is_array( $condition ) || ! isset( $condition['key'] ) ) { + continue; + } + if ( 'ID' === $condition['key'] && '=' === $condition['compare'] && 55 === $condition['value'] ) { + $found_id_match = true; + } + if ( 'form_data' === $condition['key'] ) { + $found_form_data = true; + } + } + } + + $this->assertTrue( $found_id_match, 'Short numeric terms must still match the entry ID.' ); + $this->assertFalse( $found_form_data, 'Short numeric terms must not scan form_data.' ); + } + /** * Test build_where_conditions escapes LIKE wildcards in the search term. * From 24fe6d217a9d872ce3ca27d642f031dae1863010 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:10:26 +0530 Subject: [PATCH 07/78] Fix comment: query compiler method is Base::prepare_where_clauses() --- inc/entries.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/entries.php b/inc/entries.php index 7453a80cf..fb5fa06fe 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -609,8 +609,8 @@ private static function build_where_conditions( $args ) { // Match submitted form data. The form_data column stores plain JSON // (Helper::encode_json() uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // so a LIKE matches submitted values textually — including emails, URLs and - // non-ASCII input. The query compiler (Base::get_query_clauses()) wraps the - // value in "%...%" itself; esc_like() here neutralizes user-typed wildcard + // non-ASCII input. The query compiler (Base::prepare_where_clauses()) wraps + // the value in "%...%" itself; esc_like() here neutralizes user-typed wildcard // characters ("%", "_") so they match literally. // Performance guard: the LIKE cannot use an index (full scan of the LONGTEXT // column within the other filters), so require at least 3 characters before From 4ab4446441df25ff7b3cb62826242ec0f8ed4b7e Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:11:06 +0530 Subject: [PATCH 08/78] Docs: update get_entries() search arg docblock to match new search scope --- inc/entries.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/entries.php b/inc/entries.php index fb5fa06fe..ae3cbc959 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -40,7 +40,7 @@ public function __construct() { * * @type int $form_id Form ID to filter entries. Default 0 (all forms). * @type string $status Entry status: 'all', 'read', 'unread', 'trash'. Default 'all'. - * @type string $search Search term to filter entries by entry ID. Default empty. + * @type string $search Search term matching entry ID (numeric terms), form title, or submitted form data (3+ characters). Default empty. * @type string $date_from Start date for filtering entries (YYYY-MM-DD format). Default empty. * @type string $date_to End date for filtering entries (YYYY-MM-DD format). Default empty. * @type string $orderby Column to order by. Default 'created_at'. From 6278b6b9993ed87d9d701484edcadfba04f23dd5 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:13:03 +0530 Subject: [PATCH 09/78] Docs: update entriesApi search JSDoc to match new search scope --- src/admin/entries/api/entriesApi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/entries/api/entriesApi.js b/src/admin/entries/api/entriesApi.js index 2e21a782d..f38e4a8de 100644 --- a/src/admin/entries/api/entriesApi.js +++ b/src/admin/entries/api/entriesApi.js @@ -38,7 +38,7 @@ export const fetchFormsList = () => { * @param {Object} params - Query parameters * @param {number} params.form_id - Form ID to filter (0 for all forms) * @param {string} params.status - Entry status: 'all', 'read', 'unread', 'trash' - * @param {string} params.search - Search term for entry ID + * @param {string} params.search - Search term matching entry ID (numeric), form title, or submitted form data (3+ characters) * @param {string} params.date_from - Start date filter in YYYY-MM-DD format * @param {string} params.date_to - End date filter in YYYY-MM-DD format * @param {string} params.orderby - Column to order by From 9ff176fdd7d853371700a4c9acc9ddaf2bc6e5a7 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:36:08 +0530 Subject: [PATCH 10/78] Cache the search COUNT in a 30s transient With a search term active, the pagination COUNT includes the unindexed form_data LIKE - a full scan of candidate rows re-executed on every pagination click for an answer that cannot change between clicks. Cache it for 30 seconds keyed on the exact WHERE conditions, halving the cost of every search request and making pagination clicks scan-once. Unsearched listings stay uncached so totals reflect trash/delete mutations immediately. Verified live: page 2 of a search serves the total from the transient; unsearched listings compute fresh. --- inc/entries.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/inc/entries.php b/inc/entries.php index ae3cbc959..0e93871fc 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -84,8 +84,25 @@ public static function get_entries( $args = [] ) { // Build where conditions. $where_conditions = self::build_where_conditions( $args ); - // Get total count for pagination. - $total = EntriesTable::get_instance()->get_total_count( $where_conditions ); + // Get total count for pagination. When a search term is active the WHERE includes + // an unindexed form_data LIKE, so the COUNT is a full scan of the candidate rows — + // and it re-runs on every pagination click for an answer that cannot change between + // clicks. Cache it briefly (30s) keyed on the exact conditions; ≤30s staleness in a + // pager total is harmless for an admin screen. Unsearched listings stay uncached so + // totals reflect trash/delete/read mutations immediately. + if ( ! empty( $args['search'] ) ) { + $count_cache_key = 'srfm_entries_search_count_' . md5( (string) wp_json_encode( $where_conditions ) ); + $cached_total = get_transient( $count_cache_key ); + + if ( false !== $cached_total ) { + $total = absint( $cached_total ); + } else { + $total = EntriesTable::get_instance()->get_total_count( $where_conditions ); + set_transient( $count_cache_key, $total, 30 ); + } + } else { + $total = EntriesTable::get_instance()->get_total_count( $where_conditions ); + } // Calculate offset. $offset = ( absint( $args['page'] ) - 1 ) * absint( $args['per_page'] ); From 9e9ada31ea2da43f51ad6b1e7d80ff3b04f58390 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 21 Jul 2026 16:43:29 +0530 Subject: [PATCH 11/78] Fix PHPStan: narrow get_transient() mixed with is_numeric() before absint() --- inc/entries.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/entries.php b/inc/entries.php index 0e93871fc..c3e828652 100644 --- a/inc/entries.php +++ b/inc/entries.php @@ -94,7 +94,7 @@ public static function get_entries( $args = [] ) { $count_cache_key = 'srfm_entries_search_count_' . md5( (string) wp_json_encode( $where_conditions ) ); $cached_total = get_transient( $count_cache_key ); - if ( false !== $cached_total ) { + if ( is_numeric( $cached_total ) ) { $total = absint( $cached_total ); } else { $total = EntriesTable::get_instance()->get_total_count( $where_conditions ); From c7e7b1112007faa0f7bece0c3c1c95dea5b37b63 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Wed, 22 Jul 2026 13:16:23 +0530 Subject: [PATCH 12/78] Fix: frontend crash on forms with default styles disabled normalizeCSSVariablesForDarkBackground() appended the dark-dropdown CSS variable to the form container's inline ` -> SURVIVES, and is the one shape able to slip past DOMPurify's own anti-mXSS guard (that guard only fires on elements with no element children) - all three -> stripped by the new policy - legitimate rich text -> byte-identical, no loss So while script execution was already blocked, an unauthenticated submitter could still land document-wide CSS or a cross-origin form in wp-admin — UI redressing, defacement, click-jacked POSTs. Dropping the SVG/MathML namespaces also removes ``, the exact namespace the published exploit used, which closes the residual round-trip question rather than leaving it to browser serializer behaviour. The policy is hoisted into src/admin/entries/utils/sanitizeEntryValue.js so the entries view and any future consumer share one definition, and so it is testable without dragging the React tree (and its ESM deps) into Jest. Log messages (High regression). EntryLogsSection now renders a React text child, which is correct for escaping — but log messages are stored esc_html()-escaped (inc/form-submit.php:944, :965), and the removed parse() had been incidentally decoding them. A recipient "O'Brien " was rendering as raw `O'Brien <o@b.com>` in exactly the email-failure log an admin reads when something is broken. Decode explicitly via decodeHTMLEntities(), which writes to a DETACHED