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 / migrator / importers / wpforms-importer.php
sureforms / inc / migrator / importers Last commit date
cf7-importer.php 2 months ago gravity-importer.php 2 months ago ninja-importer.php 2 months ago wpforms-importer.php 2 months ago
wpforms-importer.php
1056 lines
1 <?php
2 /**
3 * WPForms importer — translates the JSON form schema stored in the
4 * `wpforms` CPT's `post_content` into SureForms block markup.
5 *
6 * Storage shape: a wp-slashed JSON blob (see WPForms' `wpforms_encode()`).
7 * Each field is keyed by integer id and carries `type`, `label`, `required`,
8 * plus type-specific keys. We iterate the field list and dispatch to a
9 * `Block_Templates::*` emitter — extending the same four filter seams the
10 * CF7 importer added in PR #2789 so SureForms Pro can plug in extra
11 * mappings for Pro-only fields (date-time, signature, repeater, …).
12 *
13 * @package sureforms
14 * @since 2.11.0
15 */
16
17 namespace SRFM\Inc\Migrator\Importers;
18
19 use SRFM\Inc\Migrator\Base_Migrator;
20 use SRFM\Inc\Migrator\Block_Templates;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 /**
27 * Wpforms_Importer
28 *
29 * @since 2.11.0
30 */
31 class Wpforms_Importer extends Base_Migrator {
32 /**
33 * Operator translation table — WPForms condition operator → SureForms
34 * operator slug (per Pro's `conditional-logic-options.json`). Rules using
35 * operators absent from this map are dropped during migration with a
36 * one-line warning.
37 */
38 private const OPERATOR_MAP = [
39 '==' => '==',
40 '!=' => '!=',
41 'c' => 'includes',
42 '!c' => '!includes',
43 '^' => 'startWith',
44 '~' => 'endWith',
45 'e' => 'null',
46 '!e' => '!null',
47 ];
48
49 /**
50 * Maximum layout/repeater nesting depth translated before bailing. Real
51 * WPForms layouts can't nest, so this only guards crafted/corrupt JSON.
52 */
53 private const MAX_NESTING_DEPTH = 5;
54
55 /**
56 * Conditional-logic payload accumulated while emitting field blocks for
57 * the form currently being built. Reset per form in `build_form_content`.
58 * Each entry holds the source-side target field id, the show/hide
59 * action, the unconverted rules, and a choice-id map.
60 *
61 * @var array<int,array<string,mixed>>
62 */
63 private $conditional_logic = [];
64
65 /**
66 * Mapping from WPForms field id → assembled SureForms block_id for the
67 * current form. Used to rewrite conditional-logic rule targets (which
68 * reference WPForms ids) onto SureForms block ids in `get_form_metas`.
69 *
70 * @var array<string,string>
71 */
72 private $field_id_to_block_id = [];
73
74 /**
75 * Mapping from WPForms field id → block type bucket (`default`, `text`,
76 * `number`, `list`) — required by SureForms Pro's CL schema so its rule
77 * editor knows which operator set to expose.
78 *
79 * @var array<string,string>
80 */
81 private $field_id_to_block_type = [];
82
83 /**
84 * Mapping from WPForms choice-field id → [ choice key => SureForms option
85 * label ]. WPForms stores a conditional rule's value as the choice KEY, but
86 * SureForms' CL engine compares against the option label — so convert_rule()
87 * re-keys list-source rule values through this map.
88 *
89 * @var array<string,array<string,string>>
90 */
91 private $field_id_to_choices = [];
92
93 /**
94 * Submit-button text discovered while parsing form settings; threaded
95 * through to `get_form_metas` as `_srfm_submit_button_text`.
96 *
97 * @var string
98 */
99 private $submit_label = '';
100
101 /**
102 * Confirmation message + recipient discovered while parsing settings.
103 *
104 * @var array<string,mixed>
105 */
106 private $form_settings = [];
107
108 /**
109 * Set source identifiers.
110 *
111 * @since 2.11.0
112 */
113 public function __construct() {
114 $this->key = 'wpforms';
115 $this->title = __( 'WPForms', 'sureforms' );
116 }
117
118 /**
119 * Whether WPForms (Lite or Pro) is currently installed/active.
120 *
121 * @since 2.11.0
122 *
123 * @return bool
124 */
125 public function exist() {
126 return class_exists( 'WPForms' ) || defined( 'WPFORMS_VERSION' );
127 }
128
129 /**
130 * Map of WPForms field-type slug → `Block_Templates` method name for the
131 * Lite-only fields. Pro and addon fields are added by Pro's
132 * `Migrator_WPForms` subscriber via the `srfm_migrator_tag_to_template_map`
133 * filter — the same filter the CF7 importer uses.
134 *
135 * Public so tests can read it; Pro subscribers extend it through the
136 * filter, not through inheritance.
137 *
138 * @since 2.11.0
139 *
140 * @return array<string,string>
141 */
142 public function default_field_map() {
143 return [
144 'text' => 'input',
145 'textarea' => 'textarea',
146 'email' => 'email',
147 'number' => 'number',
148 'number-slider' => 'slider',
149 'select' => 'dropdown',
150 'radio' => 'multi_choice',
151 'checkbox' => 'multi_choice',
152 'gdpr-checkbox' => 'gdpr',
153 ];
154 }
155
156 /**
157 * Fetch all `wpforms` posts. The CPT is `public=false, show_ui=false` so we
158 * have to query it explicitly.
159 *
160 * @since 2.11.0
161 *
162 * @return array<int,array<string,mixed>>
163 */
164 protected function get_source_forms() {
165 if ( ! $this->exist() ) {
166 return [];
167 }
168 $posts = get_posts(
169 [
170 'post_type' => 'wpforms',
171 'post_status' => 'publish',
172 'posts_per_page' => -1,
173 'orderby' => 'ID',
174 'order' => 'ASC',
175 ]
176 );
177 $out = [];
178 foreach ( $posts as $post ) {
179 $out[] = [
180 'id' => (int) $post->ID,
181 'name' => $post->post_title,
182 'post_content' => $post->post_content,
183 ];
184 }
185 return $out;
186 }
187
188 /**
189 * Return the WP post id for a source descriptor.
190 *
191 * @since 2.11.0
192 *
193 * @param array<string,mixed> $form Source descriptor.
194 * @return int
195 */
196 protected function get_source_form_id( array $form ) {
197 return isset( $form['id'] ) && is_numeric( $form['id'] ) ? (int) $form['id'] : 0;
198 }
199
200 /**
201 * Return the WPForms form title for a source descriptor.
202 *
203 * @since 2.11.0
204 *
205 * @param array<string,mixed> $form Source descriptor.
206 * @return string
207 */
208 protected function get_source_form_name( array $form ) {
209 $name = $this->str_arg( $form, 'name' );
210 return '' !== $name ? $name : __( '(untitled WPForms form)', 'sureforms' );
211 }
212
213 /**
214 * Decode the JSON form schema and iterate fields, emitting SureForms
215 * block markup. Sub-fields of composite types (Name, Layout, Repeater)
216 * are handled inline rather than via the field map.
217 *
218 * @since 2.11.0
219 *
220 * @param array<string,mixed> $form Source descriptor.
221 * @return string
222 */
223 protected function build_form_content( array $form ) {
224 // Reset per-form accumulators — used_slugs lives on Base_Migrator.
225 $this->used_slugs = [];
226 $this->conditional_logic = [];
227 $this->field_id_to_block_id = [];
228 $this->field_id_to_block_type = [];
229 $this->field_id_to_choices = [];
230 $this->submit_label = '';
231 $this->form_settings = [];
232
233 $data = $this->parse_form_json( $form );
234 if ( empty( $data ) ) {
235 return '';
236 }
237
238 // Allow add-on subscribers to rewrite the form-data array before we
239 // iterate it (e.g. expand a Multi-Step addon section into synthetic
240 // page-break markers, much like CF7 importer's [step] preprocessing).
241 /**
242 * Filter the parsed WPForms form-data array before block emission.
243 *
244 * @since 2.11.0
245 *
246 * @param array<string,mixed> $data Decoded form_data.
247 * @param string $key Migrator source key (`wpforms`).
248 * @param array<string,mixed> $form Source descriptor.
249 */
250 $data = (array) apply_filters( 'srfm_migrator_preprocess_template', $data, $this->key, $form );
251
252 // Stash form settings so get_form_metas can read them — WPForms keeps
253 // notifications/confirmations/submit_text under `settings`.
254 if ( isset( $data['settings'] ) && is_array( $data['settings'] ) ) {
255 $this->form_settings = $data['settings'];
256 $this->submit_label = $this->str_arg( $data['settings'], 'submit_text' );
257 }
258
259 $fields = isset( $data['fields'] ) && is_array( $data['fields'] ) ? $data['fields'] : [];
260 // WPForms keys fields by integer id but the JSON is associative —
261 // preserve source order by iterating with foreach (PHP keeps insertion
262 // order for assoc arrays).
263 $markup = '';
264 foreach ( $fields as $field ) {
265 if ( ! is_array( $field ) ) {
266 continue;
267 }
268 $markup .= $this->translate_field( $field );
269 }
270 return $markup;
271 }
272
273 /**
274 * Build the SureForms meta payload — email notifications, confirmation,
275 * submit text, and the conditional-logic blob assembled during emission.
276 *
277 * @since 2.11.0
278 *
279 * @param array<string,mixed> $form Source descriptor.
280 * @return array<string,mixed>
281 */
282 protected function get_form_metas( array $form ) {
283 unset( $form );
284
285 $metas = [
286 '_srfm_submit_button_text' => '' !== $this->submit_label ? $this->submit_label : __( 'Submit', 'sureforms' ),
287 ];
288
289 $confirmation = $this->translate_confirmation( $this->form_settings );
290 if ( ! empty( $confirmation ) ) {
291 $metas['_srfm_form_confirmation'] = $confirmation;
292 }
293
294 $email = $this->translate_email_notifications( $this->form_settings );
295 if ( ! empty( $email ) ) {
296 $metas['_srfm_email_notification'] = $email;
297 }
298
299 // `_srfm_conditional_logic` is registered by SureForms Pro
300 // (inc/extensions/conditional-logic.php). When Pro is inactive the meta
301 // is still written but simply sits unused until Pro is enabled — harmless.
302 $cl_meta = $this->assemble_conditional_logic_meta();
303 if ( ! empty( $cl_meta ) ) {
304 $metas['_srfm_conditional_logic'] = $cl_meta;
305 }
306
307 return $metas;
308 }
309
310 /**
311 * Decode the JSON form schema from `post_content`. Mirrors WPForms' own
312 * `wpforms_decode()` which calls `json_decode()` directly — `post_content`
313 * arrives from `get_post_field()` already unslashed, so a second pass
314 * would corrupt the escaped quotes inside string values (e.g.
315 * `replyto:"{field_id=\"1\"}"`).
316 *
317 * @since 2.11.0
318 *
319 * @param array<string,mixed> $form Source descriptor.
320 * @return array<string,mixed>
321 */
322 private function parse_form_json( array $form ) {
323 $raw = $this->str_arg( $form, 'post_content' );
324 if ( '' === $raw ) {
325 return [];
326 }
327 $decoded = json_decode( $raw, true );
328 return is_array( $decoded ) ? $decoded : [];
329 }
330
331 /**
332 * Translate a single WPForms field array into block markup.
333 *
334 * Branches:
335 * - `name` — composite, emits 1–3 `srfm/input` blocks based on `format`.
336 * - `layout` — emits `core/columns` and recurses children.
337 * - everything else — looked up in the field map (extended via filter),
338 * dispatched via `Base_Migrator::dispatch_template()`, with the
339 * `srfm_migrator_block_template` filter giving Pro subscribers a
340 * chance for unmapped types before we flag them unsupported.
341 *
342 * @since 2.11.0
343 *
344 * @param array<string,mixed> $field WPForms field array.
345 * @param int $depth Current nesting depth (layout/repeater recursion).
346 * @return string
347 */
348 private function translate_field( array $field, $depth = 0 ) {
349 // Guard against a crafted/corrupt self-nesting layout/repeater chain that
350 // would otherwise recurse unbounded during an authenticated import.
351 if ( $depth > self::MAX_NESTING_DEPTH ) {
352 $this->note_unsupported( $this->str_arg( $field, 'label', $this->str_arg( $field, 'type', 'nested field' ) ) );
353 return '';
354 }
355
356 $type = $this->str_arg( $field, 'type' );
357 if ( '' === $type ) {
358 return '';
359 }
360
361 if ( 'name' === $type ) {
362 return $this->translate_name_field( $field );
363 }
364
365 if ( 'layout' === $type ) {
366 return $this->translate_layout_field( $field, $depth );
367 }
368
369 if ( 'repeater' === $type ) {
370 return $this->translate_repeater_field( $field, $depth );
371 }
372
373 // captcha is rendered form-level in SureForms, never as a block.
374 if ( 0 === strpos( $type, 'captcha_' ) || 'captcha' === $type ) {
375 return '';
376 }
377
378 /**
379 * Filter the WPForms-field-type → template-method map.
380 *
381 * Pro subscribers (Migrator_WPForms) overlay extra entries for fields
382 * Free can't render on its own (date-time, file-upload, signature,
383 * repeater, …). The filter is the same one CF7 uses — discriminate by
384 * the second parameter `$key`.
385 *
386 * @since 2.11.0
387 *
388 * @param array<string,string> $map WPForms field type → method name.
389 * @param string $key Migrator source key (`wpforms`).
390 */
391 $map = (array) apply_filters( 'srfm_migrator_tag_to_template_map', $this->default_field_map(), $this->key );
392
393 // Pre-build the SureForms block args so the dispatch_template + filter
394 // path both receive the same shape (label, required, choices, …).
395 $args = $this->build_block_args( $field );
396 $method = $map[ $type ] ?? '';
397
398 if ( '' === $method ) {
399 // No mapping for this type — give subscribers a chance, otherwise
400 // flag it as unsupported.
401 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $type, $args, $this->key );
402 if ( '' === $markup ) {
403 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
404 return '';
405 }
406 return $this->capture_field_metadata( $field, $args, $markup, $type );
407 }
408
409 $markup = $this->dispatch_template( $method, $args );
410 if ( '' === $markup ) {
411 // Subscriber-only emitter (e.g. Pro `date_picker`).
412 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key );
413 }
414 if ( '' === $markup ) {
415 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
416 return '';
417 }
418
419 return $this->capture_field_metadata( $field, $args, $markup, $method );
420 }
421
422 /**
423 * Build SureForms block args from a WPForms field array. Common keys are
424 * mapped 1:1; type-specific keys (choices, min/max, format, …) are added
425 * conditionally.
426 *
427 * @since 2.11.0
428 *
429 * @param array<string,mixed> $field WPForms field.
430 * @return array<string,mixed>
431 */
432 private function build_block_args( array $field ) {
433 $type = $this->str_arg( $field, 'type' );
434 $label = $this->str_arg( $field, 'label' );
435 $slug_seed = '' !== $label ? $label : $type;
436 $slug = $this->reserve_slug( $slug_seed );
437
438 $args = [
439 'label' => $label,
440 'placeholder' => $this->str_arg( $field, 'placeholder' ),
441 'default_value' => $this->str_arg( $field, 'default_value' ),
442 'required' => ! empty( $field['required'] ),
443 'help' => $this->str_arg( $field, 'description' ),
444 'slug' => $slug,
445 ];
446
447 switch ( $type ) {
448 case 'textarea':
449 $args['rows'] = $this->size_to_rows( $this->str_arg( $field, 'size' ) );
450 $args['max_length'] = $this->resolve_limit( $field );
451 break;
452 case 'text':
453 $args['max_length'] = $this->resolve_limit( $field );
454 break;
455 case 'number':
456 case 'number-slider':
457 // Preserve decimals — a WPForms slider step of 0.5 must not be
458 // truncated to 0. `+ 0` yields an int for whole numbers, float otherwise.
459 if ( isset( $field['min'] ) && is_numeric( $field['min'] ) ) {
460 $args['min'] = $field['min'] + 0;
461 }
462 if ( isset( $field['max'] ) && is_numeric( $field['max'] ) ) {
463 $args['max'] = $field['max'] + 0;
464 }
465 if ( isset( $field['step'] ) && is_numeric( $field['step'] ) ) {
466 $args['step'] = $field['step'] + 0;
467 }
468 if ( '' !== $this->str_arg( $field, 'default_value' ) ) {
469 $args['default_value'] = $this->str_arg( $field, 'default_value' );
470 }
471 break;
472 case 'select':
473 case 'radio':
474 case 'checkbox':
475 $options = $this->translate_choices( $field );
476 $args['options'] = $options['options'];
477 $args['preselected'] = $options['preselected'];
478 $args['_choice_id_map'] = $options['id_map']; // Internal — capture_field_metadata reads + strips it.
479 $args['multiple'] = 'checkbox' === $type
480 ? empty( $field['disclaimer_format'] ) // Disclaimer = single-checkbox semantics.
481 : ! empty( $field['multiple'] );
482 break;
483 case 'email':
484 // WPForms' "Enable Email Confirmation" (confirmation=1) is a
485 // second input on the same field; srfm/email models that
486 // natively via isConfirmEmail, so enable the option on the one
487 // block instead of dropping it or emitting a duplicate field.
488 if ( ! empty( $field['confirmation'] ) ) {
489 $args['confirm_email'] = true;
490 }
491 break;
492 case 'date-time':
493 $args['format'] = $this->str_arg( $field, 'format', 'date' );
494 $args['date_format'] = $this->str_arg( $field, 'date_format', 'mm/dd/yyyy' );
495 $args['time_format'] = $this->str_arg( $field, 'time_format' );
496 break;
497 case 'rating':
498 $args['icon'] = $this->str_arg( $field, 'icon', 'star' );
499 $args['scale'] = isset( $field['scale'] ) && is_numeric( $field['scale'] ) ? (int) $field['scale'] : 5;
500 break;
501 case 'net_promoter_score':
502 $args['low_label'] = $this->str_arg( $field, 'nps_low_label' );
503 $args['high_label'] = $this->str_arg( $field, 'nps_high_label' );
504 break;
505 case 'signature':
506 $args['ink_color'] = $this->str_arg( $field, 'ink_color' );
507 break;
508 case 'html':
509 $args['content'] = $this->str_arg( $field, 'code' );
510 break;
511 case 'content':
512 $args['content'] = $this->str_arg( $field, 'content' );
513 break;
514 case 'divider':
515 // label + help are already in $args; nothing extra to carry.
516 break;
517 case 'file-upload':
518 $exts = $this->str_arg( $field, 'extensions' );
519 if ( '' !== $exts ) {
520 $args['allowed_formats'] = array_values(
521 array_filter(
522 array_map( 'trim', explode( ',', $exts ) ),
523 static function ( $v ) {
524 return '' !== $v;
525 }
526 )
527 );
528 }
529 if ( isset( $field['max_size'] ) && is_numeric( $field['max_size'] ) ) {
530 $args['file_size_limit'] = (int) $field['max_size'];
531 }
532 if ( isset( $field['max_file_number'] ) && is_numeric( $field['max_file_number'] ) ) {
533 $args['max_files'] = (int) $field['max_file_number'];
534 }
535 $args['multiple'] = isset( $field['max_file_number'] ) && is_numeric( $field['max_file_number'] ) && (int) $field['max_file_number'] > 1;
536 break;
537 case 'hidden':
538 case 'internal-information':
539 $args['default_value'] = $this->str_arg( $field, 'default_value' );
540 if ( '' === $args['default_value'] ) {
541 $args['default_value'] = $this->str_arg( $field, 'code' );
542 }
543 break;
544 case 'phone':
545 // WPForms phone format `smart` / `us` / `international` — SureForms
546 // phone has its own intl/US toggle; we carry the raw value through
547 // and let the Pro emitter (or default) interpret.
548 $args['format'] = $this->str_arg( $field, 'format', 'smart' );
549 break;
550 case 'address':
551 $args['format'] = $this->str_arg( $field, 'format', 'us' );
552 break;
553 case 'pagebreak':
554 // Title precedes the page-break block heading; map it onto label.
555 $title = $this->str_arg( $field, 'title' );
556 if ( '' !== $title ) {
557 $args['label'] = $title;
558 }
559 break;
560 }
561
562 return $args;
563 }
564
565 /**
566 * Translate WPForms' Name composite (`format=simple|first-last|first-middle-last`)
567 * into 1–3 `srfm/input` blocks. SureForms has no dedicated Name block.
568 *
569 * @since 2.11.0
570 *
571 * @param array<string,mixed> $field Name field.
572 * @return string
573 */
574 private function translate_name_field( array $field ) {
575 $format = $this->str_arg( $field, 'format', 'first-last' );
576 $base = $this->str_arg( $field, 'label', 'Name' );
577 $req = ! empty( $field['required'] );
578
579 if ( 'simple' === $format ) {
580 $markup = Block_Templates::input(
581 [
582 'label' => $base,
583 'placeholder' => $this->str_arg( $field, 'simple_placeholder' ),
584 'default_value' => $this->str_arg( $field, 'simple_default' ),
585 'required' => $req,
586 'slug' => $this->reserve_slug( $base ),
587 ]
588 );
589 // Register field id → first sub-block id so a Name field can be a
590 // conditional-logic source/target (capture_field_metadata reads the
591 // first block_id from the markup and any rules off the field).
592 return $this->capture_field_metadata( $field, [], $markup, 'name' );
593 }
594
595 $parts = 'first-middle-last' === $format
596 ? [ 'first', 'middle', 'last' ]
597 : [ 'first', 'last' ];
598
599 $markup = '';
600 foreach ( $parts as $part ) {
601 $markup .= Block_Templates::input(
602 [
603 'label' => $base . ' (' . ucfirst( $part ) . ')',
604 'placeholder' => $this->str_arg( $field, $part . '_placeholder' ),
605 'default_value' => $this->str_arg( $field, $part . '_default' ),
606 'required' => $req,
607 'slug' => $this->reserve_slug( $base . '-' . $part ),
608 ]
609 );
610 }
611 // Map the composite field to its first sub-block id so CL rules that
612 // target the Name field resolve to the first input.
613 return $this->capture_field_metadata( $field, [], $markup, 'name' );
614 }
615
616 /**
617 * Translate WPForms' Layout container — emit a `core/columns` block with
618 * width presets from the WPForms `preset` (50-50, 33-33-33, …) and
619 * recurse the nested children into each column.
620 *
621 * @since 2.11.0
622 *
623 * @param array<string,mixed> $field Layout field.
624 * @param int $depth Current nesting depth.
625 * @return string
626 */
627 private function translate_layout_field( array $field, $depth = 0 ) {
628 $columns = isset( $field['columns'] ) && is_array( $field['columns'] ) ? $field['columns'] : [];
629 if ( empty( $columns ) ) {
630 return '';
631 }
632 $out = "<!-- wp:columns -->\n<div class=\"wp-block-columns\">";
633 foreach ( $columns as $column ) {
634 if ( ! is_array( $column ) ) {
635 continue;
636 }
637 $children = isset( $column['fields'] ) && is_array( $column['fields'] ) ? $column['fields'] : [];
638 $inner = '';
639 foreach ( $children as $child ) {
640 if ( is_array( $child ) ) {
641 $inner .= $this->translate_field( $child, $depth + 1 );
642 }
643 }
644 $out .= "\n<!-- wp:column -->\n<div class=\"wp-block-column\">{$inner}</div>\n<!-- /wp:column -->";
645 }
646 $out .= "\n</div>\n<!-- /wp:columns -->\n";
647 return $out;
648 }
649
650 /**
651 * Translate WPForms' Repeater container — recurse into each column's
652 * child fields, assemble their markup, then hand it to the Pro
653 * `repeater_container` emitter as `children` so it can wrap with
654 * Gutenberg innerBlocks markers.
655 *
656 * When Pro is not active, the dispatch falls through to the
657 * `srfm_migrator_block_template` filter and (since no subscriber
658 * answers) the field is flagged as unsupported — the children are
659 * still tracked so the warning is meaningful.
660 *
661 * @since 2.11.0
662 *
663 * @param array<string,mixed> $field WPForms repeater field.
664 * @param int $depth Current nesting depth.
665 * @return string
666 */
667 private function translate_repeater_field( array $field, $depth = 0 ) {
668 $columns = isset( $field['columns'] ) && is_array( $field['columns'] ) ? $field['columns'] : [];
669 $children = '';
670 foreach ( $columns as $column ) {
671 if ( ! is_array( $column ) ) {
672 continue;
673 }
674 $child_fields = isset( $column['fields'] ) && is_array( $column['fields'] ) ? $column['fields'] : [];
675 foreach ( $child_fields as $child ) {
676 if ( is_array( $child ) ) {
677 $children .= $this->translate_field( $child, $depth + 1 );
678 }
679 }
680 }
681
682 $args = [
683 'label' => $this->str_arg( $field, 'label', 'Repeater Field' ),
684 'help' => $this->str_arg( $field, 'description' ),
685 'min_rows' => isset( $field['min_rows'] ) && is_numeric( $field['min_rows'] ) ? (int) $field['min_rows'] : null,
686 'max_rows' => isset( $field['max_rows'] ) && is_numeric( $field['max_rows'] ) ? (int) $field['max_rows'] : null,
687 'slug' => $this->reserve_slug( $this->str_arg( $field, 'label', 'repeater' ) ),
688 'children' => $children,
689 ];
690
691 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', 'repeater_container', $args, $this->key );
692 if ( '' === $markup ) {
693 $this->note_unsupported( $this->str_arg( $field, 'label', 'Repeater' ) );
694 return $children; // Fall back to inlining the children at top level so data isn't lost.
695 }
696 return $markup;
697 }
698
699 /**
700 * Translate WPForms' 1-indexed choices array into SureForms options +
701 * `preselected` defaults + an id-map (WPForms choice id → SureForms
702 * option index). The id-map is consumed by `translate_conditional_logic`
703 * to rewrite rule values.
704 *
705 * @since 2.11.0
706 *
707 * @param array<string,mixed> $field WPForms field.
708 * @return array{options: array<int,array<string,string>>, preselected: array<int,int>, id_map: array<string,string>}
709 */
710 private function translate_choices( array $field ) {
711 $choices = isset( $field['choices'] ) && is_array( $field['choices'] ) ? $field['choices'] : [];
712 $show_values = ! empty( $field['show_values'] );
713 $options = [];
714 $preselected = [];
715 $id_map = [];
716 $i = 0;
717
718 foreach ( $choices as $cid => $choice ) {
719 if ( ! is_array( $choice ) ) {
720 continue;
721 }
722 $label = $this->str_arg( $choice, 'label' );
723 $value = $show_values && ! empty( $choice['value'] ) ? (string) $choice['value'] : $label;
724 $entry = [ 'label' => $value ];
725 $options[] = $entry;
726 // Map the WPForms choice KEY to the SureForms option label. WPForms
727 // conditional rules reference a choice by its key; SureForms' CL
728 // engine compares against the option label (= $value here), so this
729 // lets convert_rule() rewrite the rule value to one that matches.
730 $id_map[ (string) $cid ] = $value;
731 // SureForms' dropdown / multi-choice render preselected entries by
732 // matching the option *index*, not the label — see
733 // inc/fields/dropdown-markup.php:162. Push the integer index here.
734 if ( ! empty( $choice['default'] ) ) {
735 $preselected[] = $i;
736 }
737 ++$i;
738 }
739
740 if ( empty( $options ) ) {
741 $options = [ [ 'label' => 'Option 1' ] ];
742 }
743
744 return [
745 'options' => $options,
746 'preselected' => $preselected,
747 'id_map' => $id_map,
748 ];
749 }
750
751 /**
752 * Resolve the `limit_count` for a text/textarea field — only the
753 * `characters` mode maps cleanly; `words` mode is noted as a partial
754 * loss and converted to a generous character heuristic (count × 7).
755 *
756 * @since 2.11.0
757 *
758 * @param array<string,mixed> $field WPForms field.
759 * @return int|null
760 */
761 private function resolve_limit( array $field ) {
762 if ( empty( $field['limit_enabled'] ) ) {
763 return null;
764 }
765 $count = isset( $field['limit_count'] ) && is_numeric( $field['limit_count'] ) ? (int) $field['limit_count'] : 0;
766 if ( $count <= 0 ) {
767 return null;
768 }
769 $mode = $this->str_arg( $field, 'limit_mode', 'characters' );
770 if ( 'words' === $mode ) {
771 $this->note_unsupported( $this->str_arg( $field, 'label' ) . ' (' . __( 'word limit converted to character heuristic', 'sureforms' ) . ')' );
772 return $count * 7;
773 }
774 return $count;
775 }
776
777 /**
778 * Map WPForms field `size` → SureForms textarea row count.
779 *
780 * @since 2.11.0
781 *
782 * @param string $size 'small', 'medium', 'large'.
783 * @return int
784 */
785 private function size_to_rows( $size ) {
786 switch ( $size ) {
787 case 'small':
788 return 3;
789 case 'large':
790 return 8;
791 case 'medium':
792 default:
793 return 5;
794 }
795 }
796
797 /**
798 * After a field's block markup is built, extract its block_id from the
799 * markup and stash the WPForms-id → block_id mapping. Also stages the
800 * field's conditional logic for the get_form_metas payload.
801 *
802 * @since 2.11.0
803 *
804 * @param array<string,mixed> $field Source field.
805 * @param array<string,mixed> $args Final block args (used to resolve choice id-map).
806 * @param string $markup Assembled block markup.
807 * @param string $type_key WPForms type or method name (used as block-type bucket).
808 * @return string Markup (unchanged).
809 */
810 private function capture_field_metadata( array $field, array $args, $markup, $type_key ) {
811 $field_id = $this->str_arg( $field, 'id' );
812 if ( '' !== $field_id && preg_match( '/"block_id":"([a-f0-9]{8})"/', $markup, $m ) ) {
813 $this->field_id_to_block_id[ $field_id ] = $m[1];
814 $this->field_id_to_block_type[ $field_id ] = $this->block_type_bucket( $type_key );
815 // Remember a choice field's [ key => option label ] map so a rule that
816 // uses this field as a CL source can have its value re-keyed.
817 if ( ! empty( $args['_choice_id_map'] ) && is_array( $args['_choice_id_map'] ) ) {
818 $this->field_id_to_choices[ $field_id ] = $args['_choice_id_map'];
819 }
820 }
821
822 if ( ! empty( $field['conditional_logic'] ) && ! empty( $field['conditionals'] ) && is_array( $field['conditionals'] ) ) {
823 $this->conditional_logic[] = [
824 'target_field_id' => $field_id,
825 'action' => $this->str_arg( $field, 'conditional_type', 'show' ),
826 'rules' => $field['conditionals'],
827 ];
828 }
829
830 return $markup;
831 }
832
833 /**
834 * Coarse bucket for SureForms' CL editor — `default` for text-like fields,
835 * `number` for numeric, `list` for choice fields.
836 *
837 * @since 2.11.0
838 *
839 * @param string $type WPForms type or template-method name.
840 * @return string
841 */
842 private function block_type_bucket( $type ) {
843 if ( in_array( $type, [ 'number', 'number-slider', 'slider' ], true ) ) {
844 return 'number';
845 }
846 if ( in_array( $type, [ 'select', 'radio', 'checkbox', 'multi_choice', 'dropdown' ], true ) ) {
847 return 'list';
848 }
849 return 'default';
850 }
851
852 /**
853 * Build the `_srfm_conditional_logic` post-meta payload from the
854 * accumulated rules. Source-side field ids are rewritten to
855 * SureForms block ids; unsupported operators are dropped with a
856 * single warning per rule.
857 *
858 * @since 2.11.0
859 *
860 * @return array<int,array<string,mixed>>
861 */
862 private function assemble_conditional_logic_meta() {
863 $out = [];
864 foreach ( $this->conditional_logic as $entry ) {
865 $target_field_id = $this->str_arg( $entry, 'target_field_id' );
866 $target_block_id = $this->field_id_to_block_id[ $target_field_id ] ?? '';
867 if ( '' === $target_block_id ) {
868 continue;
869 }
870 $rules = isset( $entry['rules'] ) && is_array( $entry['rules'] ) ? $entry['rules'] : [];
871 $logic = [];
872 foreach ( $rules as $group ) {
873 if ( ! is_array( $group ) ) {
874 continue;
875 }
876 $converted_group = [];
877 foreach ( $group as $rule ) {
878 if ( ! is_array( $rule ) ) {
879 continue;
880 }
881 $converted = $this->convert_rule( $rule );
882 if ( null !== $converted ) {
883 $converted_group[] = $converted;
884 }
885 }
886 if ( ! empty( $converted_group ) ) {
887 $logic[] = $converted_group;
888 }
889 }
890 if ( empty( $logic ) ) {
891 continue;
892 }
893 $action = $this->str_arg( $entry, 'action', 'show' );
894 $out[] = [
895 $target_block_id => [
896 'action' => '' !== $action ? $action : 'show',
897 'logic' => $logic,
898 ],
899 ];
900 }
901 return $out;
902 }
903
904 /**
905 * Convert one WPForms conditional-logic rule into the SureForms shape.
906 *
907 * @since 2.11.0
908 *
909 * @param array<string,mixed> $rule WPForms rule (`{ field, operator, value }`).
910 * @return array<string,string>|null Null when the rule references an
911 * unknown source field or uses an
912 * unsupported operator.
913 */
914 private function convert_rule( array $rule ) {
915 $src_field_id = $this->str_arg( $rule, 'field', '' );
916 $source_block = $this->field_id_to_block_id[ $src_field_id ] ?? '';
917 if ( '' === $source_block ) {
918 return null;
919 }
920 $op = $this->str_arg( $rule, 'operator', '==' );
921 if ( ! isset( self::OPERATOR_MAP[ $op ] ) ) {
922 return null;
923 }
924 $operator = self::OPERATOR_MAP[ $op ];
925 $bucket = $this->field_id_to_block_type[ $src_field_id ] ?? 'default';
926 // Reconcile the operator with the field's bucket — SureForms' CL editor
927 // only evaluates a restricted operator set per bucket, so a list/number
928 // field with a text-style operator is down-bucketed to `default` (or the
929 // rule is dropped when no bucket supports it).
930 $bucket = $this->resolve_cl_bucket( $operator, $bucket );
931 if ( '' === $bucket ) {
932 return null;
933 }
934
935 // WPForms stores a choice source's rule value as the choice KEY (e.g.
936 // "2"); SureForms' CL engine compares against the option label, so
937 // re-key it through the source field's [ key => label ] map. Text/number
938 // sources have no choice map and pass through unchanged.
939 $value = $this->str_arg( $rule, 'value', '' );
940 $choice_map = $this->field_id_to_choices[ $src_field_id ] ?? [];
941 if ( ! empty( $choice_map ) && array_key_exists( $value, $choice_map ) ) {
942 $value = (string) $choice_map[ $value ];
943 }
944
945 return [
946 'field' => $source_block,
947 'operator' => $operator,
948 'value' => $value,
949 'type' => $bucket,
950 ];
951 }
952
953 /**
954 * Translate WPForms' first email notification (the only one SureForms
955 * supports) into the `_srfm_email_notification` shape.
956 *
957 * @since 2.11.0
958 *
959 * @param array<string,mixed> $settings WPForms settings array.
960 * @return array<int,array<string,mixed>>
961 */
962 private function translate_email_notifications( array $settings ) {
963 $notifications = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : [];
964 if ( empty( $notifications ) ) {
965 return [];
966 }
967 // SureForms supports a single notification; surface the dropped extras.
968 if ( count( $notifications ) > 1 ) {
969 $this->note_unsupported( __( 'Additional email notifications (only the first was imported)', 'sureforms' ) );
970 }
971 $first = reset( $notifications );
972 if ( ! is_array( $first ) ) {
973 return [];
974 }
975 $to_email = $this->str_arg( $first, 'email' );
976 if ( '' === $to_email ) {
977 $admin = get_option( 'admin_email' );
978 $to_email = is_string( $admin ) ? $admin : '';
979 }
980
981 return [
982 [
983 'status' => true,
984 'name' => $this->str_arg( $first, 'notification_name', __( 'Admin Notification', 'sureforms' ) ),
985 'email_to' => $to_email,
986 'subject' => $this->str_arg( $first, 'subject', __( 'New form submission', 'sureforms' ) ),
987 'email_reply_to' => $this->str_arg( $first, 'replyto', '{admin_email}' ),
988 'email_body' => $this->str_arg( $first, 'message', '{all_fields}' ),
989 ],
990 ];
991 }
992
993 /**
994 * Translate WPForms' first confirmation entry into the SureForms
995 * `_srfm_form_confirmation` shape. Falls back to the canonical default
996 * confirmation when WPForms didn't customize it.
997 *
998 * @since 2.11.0
999 *
1000 * @param array<string,mixed> $settings WPForms settings array.
1001 * @return array<int,array<string,mixed>>
1002 */
1003 private function translate_confirmation( array $settings ) {
1004 $confirmations = isset( $settings['confirmations'] ) && is_array( $settings['confirmations'] ) ? $settings['confirmations'] : [];
1005 // SureForms supports a single confirmation; surface the dropped extras.
1006 if ( count( $confirmations ) > 1 ) {
1007 $this->note_unsupported( __( 'Additional confirmations (only the first was imported)', 'sureforms' ) );
1008 }
1009 $first = is_array( reset( $confirmations ) ) ? reset( $confirmations ) : [];
1010 $type = $this->str_arg( $first, 'type', 'message' );
1011
1012 $entry = [
1013 'confirmation_type' => 'same page',
1014 'message' => $this->default_confirmation_message(),
1015 'page_url' => '',
1016 ];
1017
1018 if ( 'message' === $type && ! empty( $first['message'] ) ) {
1019 $entry['message'] = wp_kses_post( (string) $first['message'] );
1020 }
1021 if ( 'redirect' === $type && ! empty( $first['redirect'] ) ) {
1022 $entry['confirmation_type'] = 'different page';
1023 $entry['page_url'] = esc_url_raw( $this->str_arg( $first, 'redirect' ) );
1024 }
1025
1026 return [ $entry ];
1027 }
1028
1029 /**
1030 * Coerce a mixed array entry to a string. PHPStan level 9 rejects a
1031 * direct `(string) $mixed` cast; this helper centralises the
1032 * `is_string` / `is_scalar` guards so the per-field translators above
1033 * stay readable.
1034 *
1035 * @since 2.11.0
1036 *
1037 * @param array<string,mixed> $arr Source array.
1038 * @param string $key Key to read.
1039 * @param string $default Default when missing or non-scalar.
1040 * @return string
1041 */
1042 private function str_arg( array $arr, $key, $default = '' ) {
1043 if ( ! isset( $arr[ $key ] ) ) {
1044 return $default;
1045 }
1046 $value = $arr[ $key ];
1047 if ( is_string( $value ) ) {
1048 return $value;
1049 }
1050 if ( is_scalar( $value ) ) {
1051 return (string) $value;
1052 }
1053 return $default;
1054 }
1055 }
1056