PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
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 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / compatibility / multilingual / string-collector.php
sureforms / inc / compatibility / multilingual Last commit date
providers 3 weeks ago multilingual-manager.php 2 months ago string-backfill.php 3 weeks ago string-collector.php 3 weeks ago string-translator.php 3 weeks ago
string-collector.php
581 lines
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 $fields = [
285 'subject' => [ __( 'Subject', 'sureforms' ), 'LINE' ],
286 'body' => [ __( 'Message body', 'sureforms' ), 'AREA' ],
287 'from_name' => [ __( '"From" name', 'sureforms' ), 'LINE' ],
288 ];
289 /* translators: %d is the notification number. */
290 $notification_group = __( 'Notifications', 'sureforms' ) . '/' . sprintf( __( 'Notification #%d', 'sureforms' ), (int) $index + 1 );
291 foreach ( $fields as $field => $meta ) {
292 $value = isset( $notification[ $field ] ) ? Helper::get_string_value( $notification[ $field ] ) : '';
293 $this->register_form_string(
294 $form_id,
295 String_Translator::notification_name( (int) $index, $field ),
296 $value,
297 $notification_group . ': ' . $meta[0],
298 $meta[1]
299 );
300 }
301 }
302
303 // Form restriction — JSON-encoded string in single meta.
304 $restriction_raw = $this->get_meta_string( $form_id, '_srfm_form_restriction' );
305 if ( '' !== $restriction_raw ) {
306 $restriction = json_decode( $restriction_raw, true );
307 if ( is_array( $restriction ) && isset( $restriction['message'] ) ) {
308 $message = Helper::get_string_value( $restriction['message'] );
309 $this->register_form_string(
310 $form_id,
311 String_Translator::restriction_name(),
312 $message,
313 __( 'Settings', 'sureforms' ) . ': ' . __( 'Form restriction message', 'sureforms' ),
314 'AREA'
315 );
316 }
317 }
318
319 // Block-attribute strings (field labels, placeholders, option labels, etc.).
320 $this->collect_block_strings( $form_id );
321
322 if ( $this->packages_supported ) {
323 $provider->finish_package( $package );
324 }
325 $this->active_package = null;
326
327 return true;
328 }
329
330 /**
331 * Register every built-in dynamic validation message with the multilingual
332 * provider, using the raw English source as the value. Names follow
333 * {@see String_Translator::translate_validation_message()}: `validation_{key}`.
334 *
335 * These strings are global (not per-form), so registration runs on `admin_init`
336 * (see the constructor) rather than on the front end. Idempotent — WPML's
337 * `wpml_register_single_string` action deduplicates by (domain, name, value),
338 * so re-asserting on each admin load is safe. Bails when no provider is active.
339 *
340 * @since 2.11.0
341 * @return void
342 */
343 public function collect_validation_messages(): void {
344 $provider = Multilingual_Manager::get_instance()->provider();
345 if ( ! $provider->is_active() ) {
346 return;
347 }
348
349 foreach ( Translatable::dynamic_messages_source() as $key => $value ) {
350 if ( ! is_string( $key ) || ! is_string( $value ) ) {
351 continue;
352 }
353 $this->register_if_non_empty( 'validation_' . $key, $value );
354 }
355
356 // Common strings rendered server-side in field markup that are NOT stored
357 // per-form (so they never reach a form package): JS-validation fallbacks
358 // and shared field defaults such as the dropdown's empty-state placeholder,
359 // which is only serialized into block markup when a site owner overrides it.
360 $common = [
361 'srfm_required_field' => 'This field is required.',
362 'srfm_unique_field' => 'Value needs to be unique.',
363 'srfm_submit_error' => 'There was an error trying to submit your form. Please try again.',
364 'srfm_dropdown_placeholder' => 'Select an option',
365 ];
366 foreach ( $common as $key => $value ) {
367 $this->register_if_non_empty( 'validation_' . $key, $value );
368 }
369 }
370
371 /**
372 * Walk the form's parsed blocks and register every translatable block attribute
373 * with the active multilingual provider.
374 *
375 * Names use the same scheme that {@see String_Translator::translate_block_attribute()}
376 * and {@see String_Translator::translate_block_option_label()} consume at render time,
377 * so the collector and the translator stay in lockstep:
378 *
379 * - `form_{form_id}_block_{block_id}_{attribute}`
380 * - `form_{form_id}_block_{block_id}_option_{N}_label`
381 *
382 * Bails early when the form has no block content.
383 *
384 * @param int $form_id The form post ID.
385 * @since 2.11.0
386 * @return void
387 */
388 public function collect_block_strings( int $form_id ): void {
389 $post = get_post( $form_id );
390 if ( ! $post instanceof \WP_Post || '' === trim( $post->post_content ) ) {
391 return;
392 }
393
394 $blocks = parse_blocks( $post->post_content );
395 if ( empty( $blocks ) ) {
396 return;
397 }
398
399 // Self-frame the package when called directly (not from collect()), so the
400 // block strings are still grouped + pruned. collect() leaves $active_package
401 // set, in which case we reuse its framing.
402 $standalone = null === $this->active_package;
403 if ( $standalone ) {
404 $provider = Multilingual_Manager::get_instance()->provider();
405 $this->packages_supported = $provider->supports_packages();
406 $this->active_package = String_Translator::form_package( $form_id );
407 if ( $this->packages_supported ) {
408 $provider->start_package( $this->active_package );
409 }
410 }
411
412 $this->field_index = 0;
413 $this->walk_blocks_for_collection( $form_id, $blocks );
414
415 if ( $standalone ) {
416 if ( $this->packages_supported && is_array( $this->active_package ) ) {
417 Multilingual_Manager::get_instance()->provider()->finish_package( $this->active_package );
418 }
419 $this->active_package = null;
420 }
421 }
422
423 /**
424 * Recursively iterate parsed blocks and register translatable strings.
425 *
426 * @param int $form_id Form post ID.
427 * @param array<int|string, array<string, mixed>> $blocks Parsed-blocks structure.
428 * @since 2.11.0
429 * @return void
430 */
431 protected function walk_blocks_for_collection( int $form_id, array $blocks ): void {
432 $attribute_map = String_Translator::translatable_block_attributes();
433 $option_blocks = String_Translator::translatable_option_blocks();
434
435 foreach ( $blocks as $block ) {
436 $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
437 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
438 $block_id = isset( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ? $attrs['block_id'] : '';
439
440 $has_attributes = '' !== $block_id && isset( $attribute_map[ $block_name ] );
441 $has_options = '' !== $block_id && in_array( $block_name, $option_blocks, true ) && isset( $attrs['options'] ) && is_array( $attrs['options'] );
442
443 // Build the "Fields/<Type> #<n>" group path once per translatable field,
444 // so a field's label/placeholder/help/options nest together under one
445 // node in the Translation Editor. The block TYPE (not the user label) is
446 // used so the path can't be corrupted by '/' or ': ' in user content.
447 $field_group = '';
448 if ( $has_attributes || $has_options ) {
449 $this->field_index++;
450 $field_group = sprintf(
451 '%s/%s #%d',
452 __( 'Fields', 'sureforms' ),
453 String_Translator::block_type_label( $block_name ),
454 $this->field_index
455 );
456 }
457
458 if ( $has_attributes ) {
459 foreach ( $attribute_map[ $block_name ] as $attribute_key ) {
460 if ( ! isset( $attrs[ $attribute_key ] ) || ! is_string( $attrs[ $attribute_key ] ) ) {
461 continue;
462 }
463 $this->register_form_string(
464 $form_id,
465 String_Translator::block_attribute_name( $block_id, $attribute_key ),
466 $attrs[ $attribute_key ],
467 $field_group . ': ' . $this->attribute_label( $attribute_key )
468 );
469 }
470 }
471
472 if ( $has_options ) {
473 foreach ( $attrs['options'] as $option_index => $option ) {
474 if ( ! is_array( $option ) || ! isset( $option['label'] ) || ! is_string( $option['label'] ) ) {
475 continue;
476 }
477 $this->register_form_string(
478 $form_id,
479 String_Translator::block_option_name( $block_id, (int) $option_index ),
480 $option['label'],
481 /* translators: %d is the option number. */
482 $field_group . ': ' . sprintf( __( 'Option %d', 'sureforms' ), (int) $option_index + 1 )
483 );
484 }
485 }
486
487 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
488 $this->walk_blocks_for_collection( $form_id, $block['innerBlocks'] );
489 }
490 }
491 }
492
493 /**
494 * Read a single post meta value defensively as a string.
495 *
496 * @param int $form_id The form post ID.
497 * @param string $key Meta key to read.
498 * @since 2.11.0
499 * @return string Meta value coerced to string, or empty string when missing.
500 */
501 private function get_meta_string( int $form_id, string $key ): string {
502 return Helper::get_string_value( get_post_meta( $form_id, $key, true ) );
503 }
504
505 /**
506 * Register a string with the active provider, skipping empty values.
507 *
508 * @param string $name Unique string identifier.
509 * @param string $value Original string value.
510 * @since 2.11.0
511 * @return void
512 */
513 private function register_if_non_empty( string $name, string $value ): void {
514 if ( ! is_string( $value ) || '' === trim( $value ) ) {
515 return;
516 }
517
518 Multilingual_Manager::get_instance()->provider()->register_string( $name, $value );
519 }
520
521 /**
522 * Register a per-form string into the active String Package, skipping empties.
523 *
524 * Falls back to a flat String-Translation string (legacy `form_{id}_{name}`
525 * name) when the provider can't do packages, matching
526 * {@see String_Translator::dispatch_package()} so registration and render stay
527 * in lockstep on either path.
528 *
529 * @param int $form_id Form post ID.
530 * @param string $name Package-scoped string name (see String_Translator *_name() builders).
531 * @param string $value Original string value.
532 * @param string $title Human-readable label shown in the Translation Editor.
533 * @param string $type Editor field type: LINE, AREA or VISUAL.
534 * @since 2.11.0
535 * @return void
536 */
537 private function register_form_string( int $form_id, string $name, string $value, string $title = '', string $type = 'LINE' ): void {
538 if ( ! is_string( $value ) || '' === trim( $value ) ) {
539 return;
540 }
541
542 $provider = Multilingual_Manager::get_instance()->provider();
543
544 if ( $this->packages_supported && is_array( $this->active_package ) ) {
545 $provider->register_package_string( $this->active_package, $name, $value, $title, $type );
546 return;
547 }
548
549 // Legacy / non-package fallback: flat string with the form-scoped name.
550 $provider->register_string( 'form_' . $form_id . '_' . $name, $value );
551 }
552
553 /**
554 * Human-readable label for a block attribute, shown in the Translation Editor.
555 *
556 * @param string $attribute Block attribute key.
557 * @since 2.11.0
558 * @return string
559 */
560 private function attribute_label( string $attribute ): string {
561 $labels = [
562 'label' => __( 'Label', 'sureforms' ),
563 'placeholder' => __( 'Placeholder', 'sureforms' ),
564 'help' => __( 'Help text', 'sureforms' ),
565 'errorMsg' => __( 'Error message', 'sureforms' ),
566 'defaultValue' => __( 'Default value', 'sureforms' ),
567 'duplicateMsg' => __( 'Duplicate message', 'sureforms' ),
568 'confirmLabel' => __( 'Confirm label', 'sureforms' ),
569 'prefix' => __( 'Prefix', 'sureforms' ),
570 'suffix' => __( 'Suffix', 'sureforms' ),
571 'buttonText' => __( 'Button text', 'sureforms' ),
572 'amountLabel' => __( 'Amount label', 'sureforms' ),
573 'paymentDescription' => __( 'Payment description', 'sureforms' ),
574 'oneTimeLabel' => __( 'One-time label', 'sureforms' ),
575 'subscriptionLabel' => __( 'Subscription label', 'sureforms' ),
576 ];
577
578 return $labels[ $attribute ] ?? $attribute;
579 }
580 }
581