PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
sureforms / inc / compatibility / multilingual / string-collector.php

string-collector.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.8, at inc/compatibility/multilingual/string-collector.php

587 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Multilingual String Collector.
4 *
5 * Walks a SureForms form post and meta on save, registering every user-facing
6 * translatable string with the active multilingual provider. Guarantees strings
7 * appear in WPML's String Translation registry even when WPML's declarative
8 * <custom-fields-texts> handling is inconsistent across versions.
9 *
10 * @package sureforms.
11 * @since 2.11.0
12 */
13
14 namespace SRFM\Inc\Compatibility\Multilingual;
15
16 use SRFM\Inc\Helper;
17 use SRFM\Inc\Traits\Get_Instance;
18 use SRFM\Inc\Translatable;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit; // Exit if accessed directly.
22 }
23
24 /**
25 * String_Collector.
26 *
27 * Walks form metadata at save time and registers every translatable string with
28 * the active multilingual provider. Acts as a belt-and-suspenders safeguard
29 * against inconsistencies in WPML's declarative <custom-fields-texts> handling.
30 *
31 * @since 2.11.0
32 */
33 class String_Collector {
34 use Get_Instance;
35
36 /**
37 * The form's String Package descriptor for the current collect() run, or null
38 * when no package run is active.
39 *
40 * @since 2.11.0
41 * @var array<string,string>|null
42 */
43 private $active_package = null;
44
45 /**
46 * Whether the active provider supports String Packages (decided once per run).
47 *
48 * @since 2.11.0
49 * @var bool
50 */
51 private $packages_supported = false;
52
53 /**
54 * Running 1-based index of the current field within a form, used to build the
55 * "Fields/<Type> #<n>" group path shown in the Translation Editor. Reset at the
56 * start of each form's block walk.
57 *
58 * @since 2.11.0
59 * @var int
60 */
61 private $field_index = 0;
62
63 /**
64 * Constructor. Hooks into save_post for the SureForms form post type.
65 *
66 * Uses priority 20 so this runs after WordPress's own save processing and
67 * any default meta updates from the editor request.
68 *
69 * @since 2.11.0
70 */
71 public function __construct() {
72 add_action( 'save_post_' . SRFM_FORMS_POST_TYPE, [ $this, 'on_form_save' ], 20, 1 );
73
74 // Prune the form's String Package when the form is permanently deleted so
75 // orphaned packages and their translations don't linger. Fires only on
76 // permanent delete, not on trash (a trashed form may be restored).
77 add_action( 'before_delete_post', [ $this, 'on_form_delete' ], 10, 1 );
78
79 // Register the GLOBAL built-in validation strings once per admin request
80 // (no-op when no multilingual provider is active). These strings are not
81 // per-form, so they belong on an admin/authoring hook rather than on every
82 // frontend request. WPML dedupes by (domain, name, value), so re-asserting
83 // on each admin load is cheap and idempotent, and covers fresh installs and
84 // the WPML-activated-after-SureForms case.
85 if ( is_admin() ) {
86 add_action( 'admin_init', [ $this, 'collect_validation_messages' ] );
87 }
88
89 // Declare our String Package kind so WPML's "Translate Everything
90 // Automatically" gate — which reads this filter, not just the per-package
91 // post association — queues SureForms form packages for auto-translation.
92 // Registered unconditionally: WPML only fires this filter when active, and
93 // the callback is a pure array append, so it is a no-op otherwise.
94 add_filter( 'wpml_active_string_package_kinds', [ $this, 'declare_package_kind' ] );
95 }
96
97 /**
98 * Declare the SureForms String Package kind to WPML.
99 *
100 * WPML's package-level "Translate Everything Automatically" gate reads this
101 * filter to decide which string-package kinds to auto-translate. Each form is
102 * registered as one package with kind {@see String_Translator::PACKAGE_KIND},
103 * from which WPML derives the kind slug via `sanitize_title()`; computing the
104 * key the same way here guarantees it matches the slug WPML assigns to our
105 * packages (no hardcoded slug that could drift from the kind label).
106 *
107 * @param mixed $kinds Associative map of kind slug => { title, slug, plural }.
108 * @since 2.12.3
109 * @return mixed The kinds map with the SureForms Form kind added.
110 */
111 public function declare_package_kind( $kinds ) {
112 if ( ! is_array( $kinds ) ) {
113 return $kinds;
114 }
115
116 $slug = sanitize_title( String_Translator::PACKAGE_KIND );
117
118 $kinds[ $slug ] = [
119 'title' => String_Translator::PACKAGE_KIND,
120 'slug' => $slug,
121 'plural' => __( 'SureForms Forms', 'sureforms' ),
122 ];
123
124 return $kinds;
125 }
126
127 /**
128 * Entry point hooked to save_post_{post_type}.
129 *
130 * Skips autosaves and revisions, and bails when no multilingual provider is
131 * active. Otherwise delegates to {@see collect()} to register all strings.
132 *
133 * @param int $form_id The form post ID being saved.
134 * @since 2.11.0
135 * @return void
136 */
137 public function on_form_save( int $form_id ): void {
138 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
139 return;
140 }
141
142 if ( wp_is_post_revision( $form_id ) ) {
143 return;
144 }
145
146 $provider = Multilingual_Manager::get_instance()->provider();
147
148 if ( ! $provider->is_active() ) {
149 return;
150 }
151
152 $this->collect( $form_id );
153 }
154
155 /**
156 * Delete the form's String Package when the form is permanently deleted.
157 *
158 * Hooked to before_delete_post (not trash) so a package is only removed when
159 * its form is gone for good. Bails for other post types and when no provider
160 * is active.
161 *
162 * @param int $form_id The post ID being deleted.
163 * @since 2.12.3
164 * @return void
165 */
166 public function on_form_delete( int $form_id ): void {
167 if ( SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) {
168 return;
169 }
170
171 $provider = Multilingual_Manager::get_instance()->provider();
172
173 if ( ! $provider->is_active() ) {
174 return;
175 }
176
177 // delete_package() is intentionally absent from the Provider interface (see the
178 // note there): declaring it would fatal any third-party provider written against
179 // 2.11.0-2.12.2. Feature-detect instead, so a custom provider without it simply
180 // skips cleanup rather than crashing.
181 if ( ! method_exists( $provider, 'delete_package' ) ) {
182 return;
183 }
184
185 // Deletion only needs the package identity (name + kind); build it directly
186 // rather than String_Translator::form_package(), which also runs
187 // get_the_title() / get_edit_post_link() the delete path doesn't use.
188 $provider->delete_package(
189 [
190 'name' => (string) $form_id,
191 'kind' => String_Translator::PACKAGE_KIND,
192 ]
193 );
194 }
195
196 /**
197 * Walk the form and register every translatable string with the provider.
198 *
199 * Public so unit tests can exercise the collection logic directly without
200 * needing to fire the save_post action, and so migration code paths can
201 * back-fill strings for existing forms.
202 *
203 * @param int $form_id The form post ID.
204 * @since 2.11.0
205 * @since 2.12.3 Returns whether collection actually ran, so callers (notably the
206 * backfill) can distinguish "collected" from "silently skipped because
207 * the provider went inactive" and avoid recording false progress.
208 * @return bool True when the form's strings were registered, false when the provider
209 * was unavailable and nothing was done.
210 */
211 public function collect( int $form_id ): bool {
212 $provider = Multilingual_Manager::get_instance()->provider();
213
214 if ( ! $provider->is_active() ) {
215 return false;
216 }
217
218 // Group every per-form string into a single WPML String Package so they
219 // surface together under the form in the Translation Editor (instead of as
220 // flat, global String-Translation entries). start/finish bracket the
221 // registrations so strings for deleted fields are pruned automatically.
222 $package = String_Translator::form_package( $form_id );
223 $this->packages_supported = $provider->supports_packages();
224 $this->active_package = $package;
225 if ( $this->packages_supported ) {
226 $provider->start_package( $package );
227 }
228
229 // Form title (post title) — shown as a heading on the form and as the
230 // instant-form banner, so it is translatable like any other string.
231 $this->register_form_string(
232 $form_id,
233 String_Translator::title_name(),
234 Helper::get_string_value( get_the_title( $form_id ) ),
235 // "Group: Leaf" — WPML splits on ': ' to nest this under a Settings group.
236 __( 'Settings', 'sureforms' ) . ': ' . __( 'Form title', 'sureforms' )
237 );
238
239 // Submit button text.
240 $this->register_form_string(
241 $form_id,
242 String_Translator::submit_button_name(),
243 $this->get_meta_string( $form_id, '_srfm_submit_button_text' ),
244 // "Group: Leaf" — WPML splits on ': ' to nest this under a Settings group.
245 __( 'Settings', 'sureforms' ) . ': ' . __( 'Submit button text', 'sureforms' )
246 );
247
248 // Form confirmations — stored as a nested array (get_post_meta without $single = true).
249 $confirmations_raw = get_post_meta( $form_id, '_srfm_form_confirmation' );
250 $confirmations = is_array( $confirmations_raw ) && isset( $confirmations_raw[0] ) && is_array( $confirmations_raw[0] )
251 ? $confirmations_raw[0]
252 : [];
253
254 foreach ( $confirmations as $index => $confirmation ) {
255 if ( ! is_array( $confirmation ) ) {
256 continue;
257 }
258
259 $message = isset( $confirmation['message'] ) ? Helper::get_string_value( $confirmation['message'] ) : '';
260 $this->register_form_string(
261 $form_id,
262 String_Translator::confirmation_name( (int) $index ),
263 $message,
264 /* translators: %d is the confirmation number. */
265 __( 'Confirmations', 'sureforms' ) . '/' . sprintf( __( 'Confirmation #%d', 'sureforms' ), (int) $index + 1 ) . ': ' . __( 'Message', 'sureforms' ),
266 'AREA'
267 );
268 }
269
270 // Email notifications — same nested-array shape as confirmations.
271 $notifications_raw = get_post_meta( $form_id, '_srfm_email_notification' );
272 $notifications = is_array( $notifications_raw ) && isset( $notifications_raw[0] ) && is_array( $notifications_raw[0] )
273 ? $notifications_raw[0]
274 : [];
275
276 foreach ( $notifications as $index => $notification ) {
277 if ( ! is_array( $notification ) ) {
278 continue;
279 }
280
281 // reply_to is an email address (or a smart tag resolving to one), not
282 // human-readable copy, so it is intentionally excluded from the
283 // translatable set. from_name can legitimately be a localized display name.
284 // [ meta key, label, editor type ]. The string name stays keyed on
285 // 'body' for continuity, but the value has to be read from
286 // `email_body` - that is what _srfm_email_notification's sanitize
287 // callback stores, so reading 'body' always found nothing and the
288 // message body was never registered for translation at all.
289 $fields = [
290 'subject' => [ 'subject', __( 'Subject', 'sureforms' ), 'LINE' ],
291 'body' => [ 'email_body', __( 'Message body', 'sureforms' ), 'AREA' ],
292 'from_name' => [ 'from_name', __( '"From" name', 'sureforms' ), 'LINE' ],
293 ];
294 /* translators: %d is the notification number. */
295 $notification_group = __( 'Notifications', 'sureforms' ) . '/' . sprintf( __( 'Notification #%d', 'sureforms' ), (int) $index + 1 );
296 foreach ( $fields as $field => $meta ) {
297 $meta_key = $meta[0];
298 $value = isset( $notification[ $meta_key ] ) ? Helper::get_string_value( $notification[ $meta_key ] ) : '';
299 $this->register_form_string(
300 $form_id,
301 String_Translator::notification_name( (int) $index, $field ),
302 $value,
303 $notification_group . ': ' . $meta[1],
304 $meta[2]
305 );
306 }
307 }
308
309 // Form restriction — JSON-encoded string in single meta.
310 $restriction_raw = $this->get_meta_string( $form_id, '_srfm_form_restriction' );
311 if ( '' !== $restriction_raw ) {
312 $restriction = json_decode( $restriction_raw, true );
313 if ( is_array( $restriction ) && isset( $restriction['message'] ) ) {
314 $message = Helper::get_string_value( $restriction['message'] );
315 $this->register_form_string(
316 $form_id,
317 String_Translator::restriction_name(),
318 $message,
319 __( 'Settings', 'sureforms' ) . ': ' . __( 'Form restriction message', 'sureforms' ),
320 'AREA'
321 );
322 }
323 }
324
325 // Block-attribute strings (field labels, placeholders, option labels, etc.).
326 $this->collect_block_strings( $form_id );
327
328 if ( $this->packages_supported ) {
329 $provider->finish_package( $package );
330 }
331 $this->active_package = null;
332
333 return true;
334 }
335
336 /**
337 * Register every built-in dynamic validation message with the multilingual
338 * provider, using the raw English source as the value. Names follow
339 * {@see String_Translator::translate_validation_message()}: `validation_{key}`.
340 *
341 * These strings are global (not per-form), so registration runs on `admin_init`
342 * (see the constructor) rather than on the front end. Idempotent — WPML's
343 * `wpml_register_single_string` action deduplicates by (domain, name, value),
344 * so re-asserting on each admin load is safe. Bails when no provider is active.
345 *
346 * @since 2.11.0
347 * @return void
348 */
349 public function collect_validation_messages(): void {
350 $provider = Multilingual_Manager::get_instance()->provider();
351 if ( ! $provider->is_active() ) {
352 return;
353 }
354
355 foreach ( Translatable::dynamic_messages_source() as $key => $value ) {
356 if ( ! is_string( $key ) || ! is_string( $value ) ) {
357 continue;
358 }
359 $this->register_if_non_empty( 'validation_' . $key, $value );
360 }
361
362 // Common strings rendered server-side in field markup that are NOT stored
363 // per-form (so they never reach a form package): JS-validation fallbacks
364 // and shared field defaults such as the dropdown's empty-state placeholder,
365 // which is only serialized into block markup when a site owner overrides it.
366 $common = [
367 'srfm_required_field' => 'This field is required.',
368 'srfm_unique_field' => 'Value needs to be unique.',
369 'srfm_submit_error' => 'There was an error trying to submit your form. Please try again.',
370 'srfm_dropdown_placeholder' => 'Select an option',
371 ];
372 foreach ( $common as $key => $value ) {
373 $this->register_if_non_empty( 'validation_' . $key, $value );
374 }
375 }
376
377 /**
378 * Walk the form's parsed blocks and register every translatable block attribute
379 * with the active multilingual provider.
380 *
381 * Names use the same scheme that {@see String_Translator::translate_block_attribute()}
382 * and {@see String_Translator::translate_block_option_label()} consume at render time,
383 * so the collector and the translator stay in lockstep:
384 *
385 * - `form_{form_id}_block_{block_id}_{attribute}`
386 * - `form_{form_id}_block_{block_id}_option_{N}_label`
387 *
388 * Bails early when the form has no block content.
389 *
390 * @param int $form_id The form post ID.
391 * @since 2.11.0
392 * @return void
393 */
394 public function collect_block_strings( int $form_id ): void {
395 $post = get_post( $form_id );
396 if ( ! $post instanceof \WP_Post || '' === trim( $post->post_content ) ) {
397 return;
398 }
399
400 $blocks = parse_blocks( $post->post_content );
401 if ( empty( $blocks ) ) {
402 return;
403 }
404
405 // Self-frame the package when called directly (not from collect()), so the
406 // block strings are still grouped + pruned. collect() leaves $active_package
407 // set, in which case we reuse its framing.
408 $standalone = null === $this->active_package;
409 if ( $standalone ) {
410 $provider = Multilingual_Manager::get_instance()->provider();
411 $this->packages_supported = $provider->supports_packages();
412 $this->active_package = String_Translator::form_package( $form_id );
413 if ( $this->packages_supported ) {
414 $provider->start_package( $this->active_package );
415 }
416 }
417
418 $this->field_index = 0;
419 $this->walk_blocks_for_collection( $form_id, $blocks );
420
421 if ( $standalone ) {
422 if ( $this->packages_supported && is_array( $this->active_package ) ) {
423 Multilingual_Manager::get_instance()->provider()->finish_package( $this->active_package );
424 }
425 $this->active_package = null;
426 }
427 }
428
429 /**
430 * Recursively iterate parsed blocks and register translatable strings.
431 *
432 * @param int $form_id Form post ID.
433 * @param array<int|string, array<string, mixed>> $blocks Parsed-blocks structure.
434 * @since 2.11.0
435 * @return void
436 */
437 protected function walk_blocks_for_collection( int $form_id, array $blocks ): void {
438 $attribute_map = String_Translator::translatable_block_attributes();
439 $option_blocks = String_Translator::translatable_option_blocks();
440
441 foreach ( $blocks as $block ) {
442 $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
443 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
444 $block_id = isset( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ? $attrs['block_id'] : '';
445
446 $has_attributes = '' !== $block_id && isset( $attribute_map[ $block_name ] );
447 $has_options = '' !== $block_id && in_array( $block_name, $option_blocks, true ) && isset( $attrs['options'] ) && is_array( $attrs['options'] );
448
449 // Build the "Fields/<Type> #<n>" group path once per translatable field,
450 // so a field's label/placeholder/help/options nest together under one
451 // node in the Translation Editor. The block TYPE (not the user label) is
452 // used so the path can't be corrupted by '/' or ': ' in user content.
453 $field_group = '';
454 if ( $has_attributes || $has_options ) {
455 $this->field_index++;
456 $field_group = sprintf(
457 '%s/%s #%d',
458 __( 'Fields', 'sureforms' ),
459 String_Translator::block_type_label( $block_name ),
460 $this->field_index
461 );
462 }
463
464 if ( $has_attributes ) {
465 foreach ( $attribute_map[ $block_name ] as $attribute_key ) {
466 if ( ! isset( $attrs[ $attribute_key ] ) || ! is_string( $attrs[ $attribute_key ] ) ) {
467 continue;
468 }
469 $this->register_form_string(
470 $form_id,
471 String_Translator::block_attribute_name( $block_id, $attribute_key ),
472 $attrs[ $attribute_key ],
473 $field_group . ': ' . $this->attribute_label( $attribute_key )
474 );
475 }
476 }
477
478 if ( $has_options ) {
479 foreach ( $attrs['options'] as $option_index => $option ) {
480 if ( ! is_array( $option ) || ! isset( $option['label'] ) || ! is_string( $option['label'] ) ) {
481 continue;
482 }
483 $this->register_form_string(
484 $form_id,
485 String_Translator::block_option_name( $block_id, (int) $option_index ),
486 $option['label'],
487 /* translators: %d is the option number. */
488 $field_group . ': ' . sprintf( __( 'Option %d', 'sureforms' ), (int) $option_index + 1 )
489 );
490 }
491 }
492
493 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
494 $this->walk_blocks_for_collection( $form_id, $block['innerBlocks'] );
495 }
496 }
497 }
498
499 /**
500 * Read a single post meta value defensively as a string.
501 *
502 * @param int $form_id The form post ID.
503 * @param string $key Meta key to read.
504 * @since 2.11.0
505 * @return string Meta value coerced to string, or empty string when missing.
506 */
507 private function get_meta_string( int $form_id, string $key ): string {
508 return Helper::get_string_value( get_post_meta( $form_id, $key, true ) );
509 }
510
511 /**
512 * Register a string with the active provider, skipping empty values.
513 *
514 * @param string $name Unique string identifier.
515 * @param string $value Original string value.
516 * @since 2.11.0
517 * @return void
518 */
519 private function register_if_non_empty( string $name, string $value ): void {
520 if ( ! is_string( $value ) || '' === trim( $value ) ) {
521 return;
522 }
523
524 Multilingual_Manager::get_instance()->provider()->register_string( $name, $value );
525 }
526
527 /**
528 * Register a per-form string into the active String Package, skipping empties.
529 *
530 * Falls back to a flat String-Translation string (legacy `form_{id}_{name}`
531 * name) when the provider can't do packages, matching
532 * {@see String_Translator::dispatch_package()} so registration and render stay
533 * in lockstep on either path.
534 *
535 * @param int $form_id Form post ID.
536 * @param string $name Package-scoped string name (see String_Translator *_name() builders).
537 * @param string $value Original string value.
538 * @param string $title Human-readable label shown in the Translation Editor.
539 * @param string $type Editor field type: LINE, AREA or VISUAL.
540 * @since 2.11.0
541 * @return void
542 */
543 private function register_form_string( int $form_id, string $name, string $value, string $title = '', string $type = 'LINE' ): void {
544 if ( ! is_string( $value ) || '' === trim( $value ) ) {
545 return;
546 }
547
548 $provider = Multilingual_Manager::get_instance()->provider();
549
550 if ( $this->packages_supported && is_array( $this->active_package ) ) {
551 $provider->register_package_string( $this->active_package, $name, $value, $title, $type );
552 return;
553 }
554
555 // Legacy / non-package fallback: flat string with the form-scoped name.
556 $provider->register_string( 'form_' . $form_id . '_' . $name, $value );
557 }
558
559 /**
560 * Human-readable label for a block attribute, shown in the Translation Editor.
561 *
562 * @param string $attribute Block attribute key.
563 * @since 2.11.0
564 * @return string
565 */
566 private function attribute_label( string $attribute ): string {
567 $labels = [
568 'label' => __( 'Label', 'sureforms' ),
569 'placeholder' => __( 'Placeholder', 'sureforms' ),
570 'help' => __( 'Help text', 'sureforms' ),
571 'errorMsg' => __( 'Error message', 'sureforms' ),
572 'defaultValue' => __( 'Default value', 'sureforms' ),
573 'duplicateMsg' => __( 'Duplicate message', 'sureforms' ),
574 'confirmLabel' => __( 'Confirm label', 'sureforms' ),
575 'prefix' => __( 'Prefix', 'sureforms' ),
576 'suffix' => __( 'Suffix', 'sureforms' ),
577 'buttonText' => __( 'Button text', 'sureforms' ),
578 'amountLabel' => __( 'Amount label', 'sureforms' ),
579 'paymentDescription' => __( 'Payment description', 'sureforms' ),
580 'oneTimeLabel' => __( 'One-time label', 'sureforms' ),
581 'subscriptionLabel' => __( 'Subscription label', 'sureforms' ),
582 ];
583
584 return $labels[ $attribute ] ?? $attribute;
585 }
586 }
587