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 / gravity-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
gravity-importer.php
1086 lines
1 <?php
2 /**
3 * Gravity Forms importer — translates the JSON form definition stored in
4 * `wp_gf_form_meta.display_meta` into SureForms block markup. Falls back to
5 * the legacy `wp_rg_form*` tables for installs predating Gravity Forms 2.3.
6 *
7 * Gravity Forms uses no CPT — forms live in custom DB tables. Field
8 * definitions are stored as a JSON-encoded (or legacy PHP-serialized)
9 * blob alongside the form row. Each field is a flat associative array
10 * with `type`, `label`, `isRequired`, `placeholder`, `defaultValue`, plus
11 * type-specific keys; composite fields (Name, Address, Email-with-confirm,
12 * Time, Checkbox, Consent) carry sub-inputs via an `inputs[]` array with
13 * dotted IDs (`parent.1`, `parent.2`, …).
14 *
15 * Conditional logic is a per-field `conditionalLogic` block with
16 * `actionType`, `logicType`, and an array of `rules[]` referencing target
17 * field IDs (string form, can include dotted sub-IDs for sub-inputs).
18 *
19 * @package sureforms
20 * @since 2.11.0
21 */
22
23 namespace SRFM\Inc\Migrator\Importers;
24
25 use SRFM\Inc\Migrator\Base_Migrator;
26 use SRFM\Inc\Migrator\Block_Templates;
27
28 if ( ! defined( 'ABSPATH' ) ) {
29 exit;
30 }
31
32 /**
33 * Gravity_Importer
34 *
35 * @since 2.11.0
36 */
37 class Gravity_Importer extends Base_Migrator {
38 /**
39 * Gravity Forms operator → SureForms operator slug. Sourced from
40 * Pro's `conditional-logic-options.json`; aliases (`greater_than` for
41 * `>`, `less_than` for `<`) collapse to the symbol form during port.
42 */
43 private const OPERATOR_MAP = [
44 'is' => '==',
45 'isnot' => '!=',
46 '>' => '>',
47 'greater_than' => '>',
48 '<' => '<',
49 'less_than' => '<',
50 'contains' => 'includes',
51 'starts_with' => 'startWith',
52 'ends_with' => 'endWith',
53 ];
54
55 /**
56 * Bucket-specific operator maps for date / time sources. SureForms'
57 * datepicker / timepicker block types expose their own operator set
58 * (`datePickerIs`, `isBefore`, `isAfter`, …) — the generic OPERATOR_MAP
59 * (`==`, `>`, …) is invalid for them, so date/time rules translate
60 * through these instead. Operators with no equivalent (e.g. `isnot` on a
61 * date — there is no `!=` for datepicker) are absent and the rule is
62 * dropped by the validity gate in `convert_rule()`.
63 */
64 private const DATE_OPERATOR_MAP = [
65 'is' => 'datePickerIs',
66 '>' => 'isAfter',
67 'greater_than' => 'isAfter',
68 '<' => 'isBefore',
69 'less_than' => 'isBefore',
70 ];
71
72 private const TIME_OPERATOR_MAP = [
73 'is' => 'timePickerIs',
74 '>' => 'isAfter',
75 'greater_than' => 'isAfter',
76 '<' => 'isBefore',
77 'less_than' => 'isBefore',
78 ];
79
80 /**
81 * Accumulator: conditional-logic targets discovered during emission.
82 * Each entry: { source_field_id, action, rules: [...] }.
83 *
84 * @var array<int,array<string,mixed>>
85 */
86 private $conditional_logic = [];
87
88 /**
89 * Map: source Gravity Forms field id (or dotted sub-id like `1.3`) →
90 * SureForms block_id assembled during translation. Used by
91 * `assemble_conditional_logic_meta()` to rewrite rule targets.
92 *
93 * @var array<string,string>
94 */
95 private $field_id_to_block_id = [];
96
97 /**
98 * Per-field block-type bucket for the SureForms CL editor.
99 *
100 * @var array<string,string>
101 */
102 private $field_id_to_block_type = [];
103
104 /**
105 * Form-level button text, captured while parsing.
106 *
107 * @var string
108 */
109 private $submit_label = '';
110
111 /**
112 * Form-level confirmations + notifications collected during parsing.
113 *
114 * @var array<string,mixed>
115 */
116 private $form_settings = [];
117
118 /**
119 * Set source identifiers.
120 *
121 * @since 2.11.0
122 */
123 public function __construct() {
124 $this->key = 'gravity';
125 $this->title = __( 'Gravity Forms', 'sureforms' );
126 }
127
128 /**
129 * Whether Gravity Forms (any version since 2.0) is currently active.
130 *
131 * @since 2.11.0
132 *
133 * @return bool
134 */
135 public function exist() {
136 return class_exists( 'GFForms' ) || class_exists( 'GFFormsModel' ) || defined( 'GF_MIN_WP_VERSION' );
137 }
138
139 /**
140 * Map of Gravity field-type → `Block_Templates` method for the field
141 * types Free can render without Pro. Pro overlays the rest via the
142 * `srfm_migrator_tag_to_template_map` filter.
143 *
144 * @since 2.11.0
145 *
146 * @return array<string,string>
147 */
148 public function default_field_map() {
149 return [
150 'text' => 'input',
151 'textarea' => 'textarea',
152 'email' => 'email',
153 'number' => 'number',
154 'select' => 'dropdown',
155 'radio' => 'multi_choice',
156 'checkbox' => 'multi_choice',
157 'website' => 'url',
158 'phone' => 'phone',
159 'consent' => 'gdpr',
160 ];
161 }
162
163 /**
164 * Fetch all Gravity forms from the custom tables, gating on the
165 * legacy / modern table-name split (pre-2.3 vs 2.3+).
166 *
167 * @since 2.11.0
168 *
169 * @return array<int,array<string,mixed>>
170 */
171 protected function get_source_forms() {
172 if ( ! $this->exist() ) {
173 return [];
174 }
175 global $wpdb;
176 [ $form_table, $meta_table ] = $this->resolve_table_names();
177 // Table names are derived from $wpdb->prefix + a hard-coded literal —
178 // no user input — so the placeholder-vs-prepare warning is informational
179 // only. Both `$wpdb->prepare` placeholders (%s/%i) would over-escape an
180 // identifier here.
181 $query = sprintf(
182 'SELECT f.id, f.title, m.display_meta, m.confirmations, m.notifications FROM %1$s f LEFT JOIN %2$s m ON m.form_id = f.id WHERE f.is_trash = 0 ORDER BY f.id ASC',
183 esc_sql( $form_table ),
184 esc_sql( $meta_table )
185 );
186 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
187 $rows = $wpdb->get_results( $query, ARRAY_A );
188 if ( ! is_array( $rows ) ) {
189 return [];
190 }
191 $out = [];
192 foreach ( $rows as $row ) {
193 $out[] = [
194 'id' => (int) $row['id'],
195 'name' => (string) $row['title'],
196 'display_meta' => (string) $row['display_meta'],
197 'confirmations' => (string) ( $row['confirmations'] ?? '' ),
198 'notifications' => (string) ( $row['notifications'] ?? '' ),
199 ];
200 }
201 return $out;
202 }
203
204 /**
205 * Return the Gravity form id from a source descriptor.
206 *
207 * @since 2.11.0
208 *
209 * @param array<string,mixed> $form Source descriptor.
210 * @return int
211 */
212 protected function get_source_form_id( array $form ) {
213 return isset( $form['id'] ) && is_numeric( $form['id'] ) ? (int) $form['id'] : 0;
214 }
215
216 /**
217 * Return the form title for a source descriptor.
218 *
219 * @since 2.11.0
220 *
221 * @param array<string,mixed> $form Source descriptor.
222 * @return string
223 */
224 protected function get_source_form_name( array $form ) {
225 $name = $this->str_arg( $form, 'name' );
226 return '' !== $name ? $name : __( '(untitled Gravity form)', 'sureforms' );
227 }
228
229 /**
230 * Parse `display_meta` and emit SureForms block markup for the form.
231 *
232 * @since 2.11.0
233 *
234 * @param array<string,mixed> $form Source descriptor.
235 * @return string
236 */
237 protected function build_form_content( array $form ) {
238 $this->used_slugs = [];
239 $this->conditional_logic = [];
240 $this->field_id_to_block_id = [];
241 $this->field_id_to_block_type = [];
242 $this->submit_label = '';
243 $this->form_settings = [];
244
245 $display_meta = $this->parse_display_meta( $form );
246 if ( empty( $display_meta ) ) {
247 return '';
248 }
249
250 /**
251 * Filter parsed Gravity Forms `display_meta` before iteration.
252 *
253 * @since 2.11.0
254 *
255 * @param array<string,mixed> $display_meta Decoded form definition.
256 * @param string $key Migrator source key (`gravity`).
257 * @param array<string,mixed> $form Source descriptor.
258 */
259 $display_meta = (array) apply_filters( 'srfm_migrator_preprocess_template', $display_meta, $this->key, $form );
260
261 // Capture form-level state for get_form_metas().
262 $this->form_settings = $display_meta;
263 $button = isset( $display_meta['button'] ) && is_array( $display_meta['button'] ) ? $display_meta['button'] : [];
264 $this->submit_label = $this->str_arg( $button, 'text' );
265
266 $fields = isset( $display_meta['fields'] ) && is_array( $display_meta['fields'] ) ? $display_meta['fields'] : [];
267 $markup = '';
268 foreach ( $fields as $field ) {
269 if ( ! is_array( $field ) ) {
270 continue;
271 }
272 $markup .= $this->translate_field( $field );
273 }
274 return $markup;
275 }
276
277 /**
278 * Build SureForms post-meta payload — submit text, confirmations,
279 * notifications, and the assembled conditional-logic blob.
280 *
281 * @since 2.11.0
282 *
283 * @param array<string,mixed> $form Source descriptor.
284 * @return array<string,mixed>
285 */
286 protected function get_form_metas( array $form ) {
287 unset( $form );
288 $metas = [
289 '_srfm_submit_button_text' => '' !== $this->submit_label ? $this->submit_label : __( 'Submit', 'sureforms' ),
290 ];
291
292 $confirmation = $this->translate_confirmation( $this->form_settings );
293 if ( ! empty( $confirmation ) ) {
294 $metas['_srfm_form_confirmation'] = $confirmation;
295 }
296
297 $email = $this->translate_email_notifications( $this->form_settings );
298 if ( ! empty( $email ) ) {
299 $metas['_srfm_email_notification'] = $email;
300 }
301
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 * Resolve the form + meta table names. Gravity Forms ≥ 2.3 uses
312 * `gf_form*`; earlier installs use `rg_form*`. We detect the schema
313 * version via the `gf_database_version` option.
314 *
315 * @since 2.11.0
316 *
317 * @return array{0:string,1:string} `[form_table, meta_table]`.
318 */
319 private function resolve_table_names() {
320 global $wpdb;
321 $option_value = get_option( 'gf_database_version', '2.3' );
322 $version = is_string( $option_value ) ? $option_value : '2.3';
323 if ( version_compare( $version, '2.3-dev-1', '<' ) ) {
324 return [ $wpdb->prefix . 'rg_form', $wpdb->prefix . 'rg_form_meta' ];
325 }
326 return [ $wpdb->prefix . 'gf_form', $wpdb->prefix . 'gf_form_meta' ];
327 }
328
329 /**
330 * Decode `display_meta` — tries `unserialize()` first for legacy
331 * PHP-serialized rows, then falls back to `json_decode()`. Mirrors
332 * Gravity's own `GFFormsModel::unserialize()`.
333 *
334 * @since 2.11.0
335 *
336 * @param array<string,mixed> $form Source descriptor.
337 * @return array<string,mixed>
338 */
339 private function parse_display_meta( array $form ) {
340 $raw = $this->str_arg( $form, 'display_meta' );
341 if ( '' === $raw ) {
342 return [];
343 }
344 if ( is_serialized( $raw ) ) {
345 // `unserialize()` is the only path for legacy Gravity rows
346 // stored before they switched to JSON. We've already gated with
347 // `is_serialized()`, so the input is well-formed; if it ever
348 // isn't, an `[ 'allowed_classes' => false ]` second arg keeps
349 // it from instantiating unknown class names.
350 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
351 $decoded = unserialize( $raw, [ 'allowed_classes' => false ] );
352 return is_array( $decoded ) ? $decoded : [];
353 }
354 $decoded = json_decode( $raw, true );
355 return is_array( $decoded ) ? $decoded : [];
356 }
357
358 /**
359 * Translate one Gravity field array into SureForms block markup.
360 *
361 * @since 2.11.0
362 *
363 * @param array<string,mixed> $field Gravity field array.
364 * @return string
365 */
366 private function translate_field( array $field ) {
367 $type = $this->str_arg( $field, 'type' );
368 if ( '' === $type ) {
369 return '';
370 }
371
372 if ( 'name' === $type ) {
373 return $this->translate_name_field( $field );
374 }
375 if ( 'address' === $type ) {
376 return $this->translate_address_field( $field );
377 }
378 if ( 'section' === $type ) {
379 // Section break — handled via the html_block filter (Pro) or skipped.
380 return $this->dispatch_via_filter( 'divider', $this->build_block_args( $field ), $field );
381 }
382 if ( 'html' === $type ) {
383 return $this->dispatch_via_filter( 'html_block', $this->build_block_args( $field ), $field );
384 }
385 if ( 'page' === $type ) {
386 $args = $this->build_block_args( $field );
387 $next = $this->str_arg( isset( $field['nextButton'] ) && is_array( $field['nextButton'] ) ? $field['nextButton'] : [], 'text', 'Next' );
388 $prev = $this->str_arg( isset( $field['previousButton'] ) && is_array( $field['previousButton'] ) ? $field['previousButton'] : [], 'text', 'Back' );
389 $args += [
390 'next_label' => $next,
391 'prev_label' => $prev,
392 ];
393 return $this->dispatch_via_filter( 'page_break', $args, $field );
394 }
395 if ( 'captcha' === $type ) {
396 return ''; // form-level CAPTCHA; no block.
397 }
398 if ( in_array( $type, $this->hard_unsupported_types(), true ) ) {
399 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
400 return '';
401 }
402
403 /**
404 * Filter the Gravity-field-type → template-method map.
405 *
406 * @since 2.11.0
407 *
408 * @param array<string,string> $map Gravity field type → method name.
409 * @param string $key Migrator source key (`gravity`).
410 */
411 $map = (array) apply_filters( 'srfm_migrator_tag_to_template_map', $this->default_field_map(), $this->key );
412
413 $args = $this->build_block_args( $field );
414 $method = $map[ $type ] ?? '';
415
416 if ( '' === $method ) {
417 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $type, $args, $this->key );
418 if ( '' === $markup ) {
419 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
420 return '';
421 }
422 return $this->capture_field_metadata( $field, $args, $markup, $type );
423 }
424
425 // Gravity's "confirm email" is a second input on the same field;
426 // srfm/email models that natively via isConfirmEmail, so enable the
427 // option on the single block rather than emitting a duplicate email
428 // field. The GF confirm sub-input label (inputs[1]) carries over.
429 if ( 'email' === $type && ! empty( $field['emailConfirmEnabled'] ) ) {
430 $args['confirm_email'] = true;
431 $inputs = isset( $field['inputs'] ) && is_array( $field['inputs'] ) ? $field['inputs'] : [];
432 if ( isset( $inputs[1] ) && is_array( $inputs[1] ) ) {
433 $confirm_label = $this->str_arg( $inputs[1], 'label' );
434 if ( '' !== $confirm_label ) {
435 $args['confirm_label'] = $confirm_label;
436 }
437 }
438 }
439
440 $markup = $this->dispatch_template( $method, $args );
441 if ( '' === $markup ) {
442 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key );
443 }
444 if ( '' === $markup ) {
445 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
446 return '';
447 }
448
449 return $this->capture_field_metadata( $field, $args, $markup, $method );
450 }
451
452 /**
453 * Build a SureForms block-args array from a Gravity field array.
454 *
455 * @since 2.11.0
456 *
457 * @param array<string,mixed> $field Gravity field.
458 * @return array<string,mixed>
459 */
460 private function build_block_args( array $field ) {
461 $type = $this->str_arg( $field, 'type' );
462 $label = $this->str_arg( $field, 'label' );
463 $slug_seed = '' !== $label ? $label : $type;
464 $slug = $this->reserve_slug( $slug_seed );
465
466 $args = [
467 'label' => $label,
468 'placeholder' => $this->str_arg( $field, 'placeholder' ),
469 'default_value' => $this->str_arg( $field, 'defaultValue' ),
470 'required' => ! empty( $field['isRequired'] ),
471 'help' => $this->str_arg( $field, 'description' ),
472 'error_message' => $this->str_arg( $field, 'errorMessage' ),
473 'slug' => $slug,
474 ];
475
476 switch ( $type ) {
477 case 'textarea':
478 if ( isset( $field['maxLength'] ) && is_numeric( $field['maxLength'] ) ) {
479 $args['max_length'] = (int) $field['maxLength'];
480 }
481 break;
482 case 'text':
483 if ( isset( $field['maxLength'] ) && is_numeric( $field['maxLength'] ) ) {
484 $args['max_length'] = (int) $field['maxLength'];
485 }
486 break;
487 case 'number':
488 if ( isset( $field['rangeMin'] ) && is_numeric( $field['rangeMin'] ) ) {
489 $args['min'] = (int) $field['rangeMin'];
490 }
491 if ( isset( $field['rangeMax'] ) && is_numeric( $field['rangeMax'] ) ) {
492 $args['max'] = (int) $field['rangeMax'];
493 }
494 break;
495 case 'select':
496 case 'multiselect':
497 case 'radio':
498 case 'checkbox':
499 $options = $this->translate_choices( $field );
500 $args['options'] = $options['options'];
501 $args['preselected'] = $options['preselected'];
502 $args['multiple'] = 'multiselect' === $type || 'checkbox' === $type;
503 break;
504 case 'date':
505 $args['date_format'] = $this->normalize_date_format( $this->str_arg( $field, 'dateFormat', 'mdy' ) );
506 break;
507 case 'time':
508 $args['format'] = 'time';
509 $args['time_format'] = $this->str_arg( $field, 'timeFormat', '12' );
510 break;
511 case 'phone':
512 $args['format'] = $this->str_arg( $field, 'phoneFormat', 'standard' );
513 break;
514 case 'fileupload':
515 $exts = $this->str_arg( $field, 'allowedExtensions' );
516 if ( '' !== $exts ) {
517 $args['allowed_formats'] = array_values(
518 array_filter(
519 array_map( 'trim', explode( ',', $exts ) ),
520 static function ( $v ) {
521 return '' !== $v;
522 }
523 )
524 );
525 }
526 if ( isset( $field['maxFileSize'] ) && is_numeric( $field['maxFileSize'] ) ) {
527 $args['file_size_limit'] = (int) $field['maxFileSize'];
528 }
529 if ( isset( $field['maxFiles'] ) && is_numeric( $field['maxFiles'] ) ) {
530 $args['max_files'] = (int) $field['maxFiles'];
531 }
532 $args['multiple'] = ! empty( $field['multipleFiles'] );
533 break;
534 case 'hidden':
535 $args['default_value'] = $this->str_arg( $field, 'defaultValue' );
536 break;
537 case 'consent':
538 $args['label'] = $this->str_arg( $field, 'checkboxLabel', $args['label'] );
539 break;
540 case 'html':
541 $args['content'] = $this->str_arg( $field, 'content' );
542 break;
543 case 'section':
544 $args['help'] = $this->str_arg( $field, 'description' );
545 break;
546 }
547 return $args;
548 }
549
550 /**
551 * Translate Gravity's Name composite. Sub-input visibility is per-
552 * `inputs[].isHidden`; we emit one `srfm/input` per visible sub.
553 *
554 * @since 2.11.0
555 *
556 * @param array<string,mixed> $field Name field.
557 * @return string
558 */
559 private function translate_name_field( array $field ) {
560 $base = $this->str_arg( $field, 'label', 'Name' );
561 $req = ! empty( $field['isRequired'] );
562 $field_id = $this->str_arg( $field, 'id' );
563 $inputs = isset( $field['inputs'] ) && is_array( $field['inputs'] ) ? $field['inputs'] : [];
564 $markup = '';
565 $first = '';
566 foreach ( $inputs as $sub ) {
567 if ( ! is_array( $sub ) || ! empty( $sub['isHidden'] ) ) {
568 continue;
569 }
570 $sub_label = $this->str_arg( $sub, 'label' );
571 $sub_markup = Block_Templates::input(
572 [
573 'label' => $base . ( '' !== $sub_label ? ' (' . $sub_label . ')' : '' ),
574 'required' => $req,
575 'slug' => $this->reserve_slug( $base . '-' . $sub_label ),
576 ]
577 );
578 $markup .= $sub_markup;
579 // Register each visible sub-input's dotted id (e.g. "1.3") so CL
580 // rules that reference a specific name part resolve to its block.
581 $sub_block = $this->extract_block_id( $sub_markup );
582 if ( '' === $sub_block ) {
583 continue;
584 }
585 if ( '' === $first ) {
586 $first = $sub_block;
587 }
588 $sub_id = $this->str_arg( $sub, 'id' );
589 if ( '' !== $sub_id ) {
590 $this->field_id_to_block_id[ $sub_id ] = $sub_block;
591 $this->field_id_to_block_type[ $sub_id ] = 'default';
592 }
593 }
594 if ( '' === $markup ) {
595 // `nameFormat=simple` or no inputs[] — emit one input.
596 $markup = Block_Templates::input( $this->build_block_args( $field ) );
597 $first = $this->extract_block_id( $markup );
598 }
599 // Register the whole-field id → first sub-block so the Name field can
600 // act as a CL source/target by its top-level id, then capture its own
601 // CL rules (translate_name_field bypasses capture_field_metadata).
602 if ( '' !== $field_id && '' !== $first ) {
603 $this->field_id_to_block_id[ $field_id ] = $first;
604 $this->field_id_to_block_type[ $field_id ] = 'default';
605 }
606 $this->record_conditional_logic( $field );
607 return $markup;
608 }
609
610 /**
611 * Translate Gravity's Address composite. Routes to the Pro
612 * `srfm/address` emitter via the `address` template-method.
613 *
614 * @since 2.11.0
615 *
616 * @param array<string,mixed> $field Address field.
617 * @return string
618 */
619 private function translate_address_field( array $field ) {
620 $args = $this->build_block_args( $field );
621 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', 'address', $args, $this->key );
622 if ( '' === $markup ) {
623 $this->note_unsupported( $this->str_arg( $field, 'label', 'Address' ) );
624 return '';
625 }
626 return $this->capture_field_metadata( $field, $args, $markup, 'address' );
627 }
628
629 /**
630 * Translate Gravity's `choices[]` (`{text,value,isSelected,price}`)
631 * into SureForms options + a preselected-index list. The option title is
632 * the choice value (when `enableChoiceValue`) else its text — the same
633 * string Gravity CL rules reference, so `convert_rule()` matches against
634 * it directly without a re-key map.
635 *
636 * @since 2.11.0
637 *
638 * @param array<string,mixed> $field Source field.
639 * @return array<string,mixed>
640 */
641 private function translate_choices( array $field ) {
642 $raw = isset( $field['choices'] ) && is_array( $field['choices'] ) ? $field['choices'] : [];
643 $use_values = ! empty( $field['enableChoiceValue'] );
644 $options = [];
645 $preselected = [];
646 $i = 0;
647 foreach ( $raw as $choice ) {
648 if ( ! is_array( $choice ) ) {
649 continue;
650 }
651 $text = $this->str_arg( $choice, 'text' );
652 $value = $use_values && '' !== $this->str_arg( $choice, 'value' )
653 ? $this->str_arg( $choice, 'value' )
654 : $text;
655 $options[] = [ 'label' => $value ];
656 if ( ! empty( $choice['isSelected'] ) ) {
657 $preselected[] = $i;
658 }
659 ++$i;
660 }
661 if ( empty( $options ) ) {
662 $options = [ [ 'label' => 'Option 1' ] ];
663 }
664 return [
665 'options' => $options,
666 'preselected' => $preselected,
667 ];
668 }
669
670 /**
671 * Dispatch a single template-method through the
672 * `srfm_migrator_block_template` filter, flag unsupported if no
673 * subscriber answers.
674 *
675 * @since 2.11.0
676 *
677 * @param string $method Template method name.
678 * @param array<string,mixed> $args Block args.
679 * @param array<string,mixed> $field Source field (for unsupported label).
680 * @return string
681 */
682 private function dispatch_via_filter( $method, array $args, array $field ) {
683 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key );
684 if ( '' === $markup ) {
685 $this->note_unsupported( $this->str_arg( $field, 'label', $method ) );
686 return '';
687 }
688 return $this->capture_field_metadata( $field, $args, $markup, $method );
689 }
690
691 /**
692 * After emitting a block, capture its block_id + CL rules for later
693 * meta-assembly.
694 *
695 * @since 2.11.0
696 *
697 * @param array<string,mixed> $field Source field.
698 * @param array<string,mixed> $args Final block args.
699 * @param string $markup Assembled block markup.
700 * @param string $type_key WPForms/method key for block-type bucket.
701 * @return string
702 */
703 private function capture_field_metadata( array $field, array $args, $markup, $type_key ) {
704 unset( $args );
705 $field_id = $this->str_arg( $field, 'id' );
706 $block_id = $this->extract_block_id( $markup );
707 if ( '' !== $field_id && '' !== $block_id ) {
708 $this->field_id_to_block_id[ $field_id ] = $block_id;
709 $this->field_id_to_block_type[ $field_id ] = $this->block_type_bucket( $type_key );
710 }
711 $this->record_conditional_logic( $field );
712 return $markup;
713 }
714
715 /**
716 * Extract the first `block_id` from a block markup string, or '' if none.
717 *
718 * @since 2.11.0
719 *
720 * @param string $markup Serialized block markup.
721 * @return string
722 */
723 private function extract_block_id( $markup ) {
724 return preg_match( '/"block_id":"([a-f0-9]{8})"/', (string) $markup, $m ) ? $m[1] : '';
725 }
726
727 /**
728 * Record a field's `conditionalLogic` block (if any) for later assembly.
729 * Split out of `capture_field_metadata()` so composite emitters (Name)
730 * that register their own sub-input ids can still capture CL rules.
731 *
732 * @since 2.11.0
733 *
734 * @param array<string,mixed> $field Source field.
735 * @return void
736 */
737 private function record_conditional_logic( array $field ) {
738 if ( empty( $field['conditionalLogic'] ) || ! is_array( $field['conditionalLogic'] ) ) {
739 return;
740 }
741 $cl = $field['conditionalLogic'];
742 $this->conditional_logic[] = [
743 'target_field_id' => $this->str_arg( $field, 'id' ),
744 'action' => isset( $cl['actionType'] ) ? (string) $cl['actionType'] : 'show',
745 'logic_type' => isset( $cl['logicType'] ) ? (string) $cl['logicType'] : 'all',
746 'rules' => isset( $cl['rules'] ) && is_array( $cl['rules'] ) ? $cl['rules'] : [],
747 ];
748 }
749
750 /**
751 * Resolve a Gravity field id (whole or dotted sub-input like `1.3`) to the
752 * SureForms block_id captured during translation. Gravity CL rules
753 * frequently reference a sub-input id — a Name part, an Address line, a
754 * single Checkbox choice. When the exact id isn't registered, fall back to
755 * the parent field id (truncate at the first `.`) so the rule maps to the
756 * composite block instead of being silently dropped.
757 *
758 * @since 2.11.0
759 *
760 * @param string $field_id Gravity field id (e.g. `1` or `1.3`).
761 * @return string SureForms block_id, or '' if neither resolves.
762 */
763 private function resolve_block_id( $field_id ) {
764 if ( isset( $this->field_id_to_block_id[ $field_id ] ) ) {
765 return $this->field_id_to_block_id[ $field_id ];
766 }
767 $parent = $this->parent_field_id( $field_id );
768 return '' !== $parent && isset( $this->field_id_to_block_id[ $parent ] )
769 ? $this->field_id_to_block_id[ $parent ]
770 : '';
771 }
772
773 /**
774 * Resolve a Gravity field id to its CL block-type bucket, with the same
775 * dotted-sub-input → parent fallback as `resolve_block_id()`.
776 *
777 * @since 2.11.0
778 *
779 * @param string $field_id Gravity field id.
780 * @return string Block-type bucket (defaults to `default`).
781 */
782 private function resolve_block_type( $field_id ) {
783 if ( isset( $this->field_id_to_block_type[ $field_id ] ) ) {
784 return $this->field_id_to_block_type[ $field_id ];
785 }
786 $parent = $this->parent_field_id( $field_id );
787 return '' !== $parent && isset( $this->field_id_to_block_type[ $parent ] )
788 ? $this->field_id_to_block_type[ $parent ]
789 : 'default';
790 }
791
792 /**
793 * Return the parent field id of a dotted sub-input id (`1.3` → `1`), or ''
794 * when the id has no dot.
795 *
796 * @since 2.11.0
797 *
798 * @param string $field_id Gravity field id.
799 * @return string
800 */
801 private function parent_field_id( $field_id ) {
802 $dot = strpos( (string) $field_id, '.' );
803 return false === $dot ? '' : substr( (string) $field_id, 0, $dot );
804 }
805
806 /**
807 * Coarse block-type bucket for SureForms' CL editor.
808 *
809 * @since 2.11.0
810 *
811 * @param string $type Gravity Forms type or template-method name.
812 * @return string
813 */
814 private function block_type_bucket( $type ) {
815 if ( in_array( $type, [ 'number' ], true ) ) {
816 return 'number';
817 }
818 if ( in_array( $type, [ 'select', 'multiselect', 'radio', 'checkbox', 'multi_choice', 'dropdown' ], true ) ) {
819 return 'list';
820 }
821 // Pro makes date/time importable (#1258); their CL block types expose a
822 // dedicated operator set, so bucket them accordingly instead of letting
823 // them fall to `default` (which can't evaluate date/time operators).
824 if ( in_array( $type, [ 'date', 'date_picker' ], true ) ) {
825 return 'datepicker';
826 }
827 if ( in_array( $type, [ 'time', 'time_picker' ], true ) ) {
828 return 'timepicker';
829 }
830 return 'default';
831 }
832
833 /**
834 * Build the `_srfm_conditional_logic` payload. `logicType=all` is the
835 * SureForms AND group; `logicType=any` becomes a SureForms group of
836 * one-rule subgroups (mirrors WPForms' rule shape).
837 *
838 * @since 2.11.0
839 *
840 * @return array<int,array<string,mixed>>
841 */
842 private function assemble_conditional_logic_meta() {
843 $out = [];
844 foreach ( $this->conditional_logic as $entry ) {
845 $target_field_id = $this->str_arg( $entry, 'target_field_id' );
846 $target_block_id = $this->resolve_block_id( $target_field_id );
847 if ( '' === $target_block_id ) {
848 continue;
849 }
850 $rules = isset( $entry['rules'] ) && is_array( $entry['rules'] ) ? $entry['rules'] : [];
851 $converted_rules = [];
852 foreach ( $rules as $rule ) {
853 if ( ! is_array( $rule ) ) {
854 continue;
855 }
856 $converted = $this->convert_rule( $rule );
857 if ( null !== $converted ) {
858 $converted_rules[] = $converted;
859 }
860 }
861 if ( empty( $converted_rules ) ) {
862 continue;
863 }
864 $logic = 'any' === $entry['logic_type']
865 ? array_map( static fn( $r ) => [ $r ], $converted_rules ) // each rule its own OR-group.
866 : [ $converted_rules ]; // one AND-group.
867 $out[] = [
868 $target_block_id => [
869 'action' => 'hide' === $entry['action'] ? 'hide' : 'show',
870 'logic' => $logic,
871 ],
872 ];
873 }
874 return $out;
875 }
876
877 /**
878 * Convert one Gravity CL rule into the SureForms rule shape.
879 *
880 * @since 2.11.0
881 *
882 * @param array<string,mixed> $rule Gravity rule (`{fieldId, operator, value}`).
883 * @return array<string,string>|null
884 */
885 private function convert_rule( array $rule ) {
886 $src = $this->str_arg( $rule, 'fieldId' );
887 $op = $this->str_arg( $rule, 'operator', 'is' );
888 $block = $this->resolve_block_id( $src );
889 if ( '' === $block ) {
890 return null;
891 }
892 $bucket = $this->resolve_block_type( $src );
893 $operator = $this->map_operator( $op, $bucket );
894 if ( null === $operator ) {
895 // Source operator has no SureForms equivalent (e.g. `isnot` on a
896 // date) — drop the rule.
897 return null;
898 }
899 // Reconcile the operator against the bucket via the shared Base_Migrator
900 // allowlist: down-buckets a text-style operator to `default`, or drops
901 // the rule when no bucket supports it.
902 $bucket = $this->resolve_cl_bucket( $operator, $bucket );
903 if ( '' === $bucket ) {
904 return null;
905 }
906 // Gravity stores the rule value as the choice `value`; we emit list
907 // options keyed by that same value (translate_choices), so the raw
908 // value matches the SureForms option title — pass it through as-is.
909 return [
910 'field' => $block,
911 'operator' => $operator,
912 'value' => $this->str_arg( $rule, 'value' ),
913 'type' => $bucket,
914 ];
915 }
916
917 /**
918 * Map a Gravity operator to the SureForms operator slug, or null when the
919 * source operator has no equivalent. Date/time buckets use their dedicated
920 * operator maps; all others go through OPERATOR_MAP. Validity against the
921 * bucket's allowed set is reconciled by `resolve_cl_bucket()` in the caller.
922 *
923 * @since 2.11.0
924 *
925 * @param string $gf_operator Gravity operator (e.g. `is`, `contains`).
926 * @param string $bucket Resolved block-type bucket.
927 * @return string|null
928 */
929 private function map_operator( $gf_operator, $bucket ) {
930 if ( 'datepicker' === $bucket ) {
931 return self::DATE_OPERATOR_MAP[ $gf_operator ] ?? null;
932 }
933 if ( 'timepicker' === $bucket ) {
934 return self::TIME_OPERATOR_MAP[ $gf_operator ] ?? null;
935 }
936 return self::OPERATOR_MAP[ $gf_operator ] ?? null;
937 }
938
939 /**
940 * Translate Gravity's notifications JSON column into SureForms'
941 * `_srfm_email_notification` shape — first notification wins.
942 *
943 * @since 2.11.0
944 *
945 * @param array<string,mixed> $settings Captured form_settings.
946 * @return array<int,array<string,mixed>>
947 */
948 private function translate_email_notifications( array $settings ) {
949 $notifs = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : [];
950 if ( empty( $notifs ) ) {
951 return [];
952 }
953 $first = reset( $notifs );
954 if ( ! is_array( $first ) ) {
955 return [];
956 }
957 $to = $this->str_arg( $first, 'to' );
958 if ( '' === $to ) {
959 $admin = get_option( 'admin_email' );
960 $to = is_string( $admin ) ? $admin : '';
961 }
962 return [
963 [
964 'status' => true,
965 'name' => $this->str_arg( $first, 'name', __( 'Admin Notification', 'sureforms' ) ),
966 'email_to' => $to,
967 'subject' => $this->str_arg( $first, 'subject', __( 'New form submission', 'sureforms' ) ),
968 'email_reply_to' => $this->str_arg( $first, 'replyTo', '{admin_email}' ),
969 'email_body' => $this->str_arg( $first, 'message', '{all_fields}' ),
970 ],
971 ];
972 }
973
974 /**
975 * Translate Gravity's confirmations JSON column — first wins.
976 *
977 * @since 2.11.0
978 *
979 * @param array<string,mixed> $settings Captured form_settings.
980 * @return array<int,array<string,mixed>>
981 */
982 private function translate_confirmation( array $settings ) {
983 $confs = isset( $settings['confirmations'] ) && is_array( $settings['confirmations'] ) ? $settings['confirmations'] : [];
984 $first = is_array( reset( $confs ) ) ? reset( $confs ) : [];
985 $type = $this->str_arg( $first, 'type', 'message' );
986 $entry = [
987 'confirmation_type' => 'same page',
988 'message' => $this->default_confirmation_message(),
989 'page_url' => '',
990 ];
991 if ( 'message' === $type && ! empty( $first['message'] ) ) {
992 $entry['message'] = wp_kses_post( $this->str_arg( $first, 'message' ) );
993 }
994 if ( 'redirect' === $type && ! empty( $first['url'] ) ) {
995 $entry['confirmation_type'] = 'different page';
996 $entry['page_url'] = esc_url_raw( $this->str_arg( $first, 'url' ) );
997 }
998 return [ $entry ];
999 }
1000
1001 /**
1002 * Field types Gravity stores but SureForms has no peer for.
1003 *
1004 * @since 2.11.0
1005 *
1006 * @return array<int,string>
1007 */
1008 private function hard_unsupported_types() {
1009 return [
1010 'creditcard',
1011 'product',
1012 'singleproduct',
1013 'hiddenproduct',
1014 'option',
1015 'quantity',
1016 'shipping',
1017 'singleshipping',
1018 'price',
1019 'total',
1020 'donation',
1021 'post_title',
1022 'post_content',
1023 'post_excerpt',
1024 'post_tags',
1025 'post_category',
1026 'post_custom_field',
1027 'post_image',
1028 'calculation',
1029 'survey',
1030 'quiz',
1031 'poll',
1032 'chainedselect',
1033 ];
1034 }
1035
1036 /**
1037 * Map a Gravity Forms `dateFormat` slug onto the format string the
1038 * SureForms date-picker block expects (it ships a fixed enum of
1039 * `mm/dd/yyyy`, `dd/mm/yyyy`, `yyyy-mm-dd` etc.).
1040 *
1041 * Gravity formats: `mdy` / `dmy` / `dmy_dash` / `dmy_dot` /
1042 * `ymd_slash` / `ymd_dash` / `ymd_dot`.
1043 *
1044 * @since 2.11.0
1045 *
1046 * @param string $gf_format Gravity Forms date format slug.
1047 * @return string SureForms date format string.
1048 */
1049 private function normalize_date_format( $gf_format ) {
1050 $map = [
1051 'mdy' => 'mm/dd/yyyy',
1052 'dmy' => 'dd/mm/yyyy',
1053 'dmy_dash' => 'dd-mm-yyyy',
1054 'dmy_dot' => 'dd.mm.yyyy',
1055 'ymd_slash' => 'yyyy/mm/dd',
1056 'ymd_dash' => 'yyyy-mm-dd',
1057 'ymd_dot' => 'yyyy.mm.dd',
1058 ];
1059 return $map[ $gf_format ] ?? 'mm/dd/yyyy';
1060 }
1061
1062 /**
1063 * Coerce a mixed array entry to string. PHPStan level 9 friendly.
1064 *
1065 * @since 2.11.0
1066 *
1067 * @param array<string,mixed> $arr Source array.
1068 * @param string $key Key.
1069 * @param string $default Default.
1070 * @return string
1071 */
1072 private function str_arg( array $arr, $key, $default = '' ) {
1073 if ( ! isset( $arr[ $key ] ) ) {
1074 return $default;
1075 }
1076 $value = $arr[ $key ];
1077 if ( is_string( $value ) ) {
1078 return $value;
1079 }
1080 if ( is_scalar( $value ) ) {
1081 return (string) $value;
1082 }
1083 return $default;
1084 }
1085 }
1086