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 / ninja-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
ninja-importer.php
1248 lines
1 <?php
2 /**
3 * Ninja Forms importer — translates the form definitions stored across
4 * Ninja's `nf3_forms` + `nf3_fields` + `nf3_field_meta` custom tables
5 * (one row per setting key, with `maybe_unserialize` on each value) into
6 * SureForms block markup.
7 *
8 * Field settings are NOT stored as a single blob — each setting key lives
9 * as its own row in `nf3_field_meta`, so we have to assemble settings via
10 * a JOIN-then-collapse pass per field.
11 *
12 * Conditional Logic is a paid Ninja add-on (`NF_ConditionalLogic` class)
13 * — when present, rules live as a form-level `conditions` setting in
14 * `nf3_form_meta`. Each rule targets fields by their `key` (slug), not
15 * by id; we keep a slug→block_id map during emission and rewrite on the
16 * way out.
17 *
18 * @package sureforms
19 * @since 2.11.0
20 */
21
22 namespace SRFM\Inc\Migrator\Importers;
23
24 use SRFM\Inc\Migrator\Base_Migrator;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Ninja_Importer
32 *
33 * @since 2.11.0
34 */
35 class Ninja_Importer extends Base_Migrator {
36 /**
37 * Ninja Forms conditional-logic comparator → SureForms operator slug.
38 * Mirrors the add-on's runtime; we ship the seven Pro-supported
39 * operators and drop the rest with a warning.
40 */
41 private const OPERATOR_MAP = [
42 'equal' => '==',
43 '==' => '==',
44 'not_equal' => '!=',
45 '!=' => '!=',
46 'greater_than' => '>',
47 '>' => '>',
48 'less_than' => '<',
49 '<' => '<',
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 expose their own operator set). Operators with
58 * no equivalent are absent and the rule is dropped by the validity gate.
59 */
60 private const DATE_OPERATOR_MAP = [
61 'equal' => 'datePickerIs',
62 '==' => 'datePickerIs',
63 'greater_than' => 'isAfter',
64 '>' => 'isAfter',
65 'less_than' => 'isBefore',
66 '<' => 'isBefore',
67 ];
68
69 private const TIME_OPERATOR_MAP = [
70 'equal' => 'timePickerIs',
71 '==' => 'timePickerIs',
72 'greater_than' => 'isAfter',
73 '>' => 'isAfter',
74 'less_than' => 'isBefore',
75 '<' => 'isBefore',
76 ];
77
78 /**
79 * Map: source Ninja Forms field key (slug) → SureForms block_id.
80 *
81 * @var array<string,string>
82 */
83 private $field_key_to_block_id = [];
84
85 /**
86 * Per-field block-type bucket for the SureForms CL editor.
87 *
88 * @var array<string,string>
89 */
90 private $field_key_to_block_type = [];
91
92 /**
93 * Form-level submit-button text discovered during parsing.
94 *
95 * @var string
96 */
97 private $submit_label = '';
98
99 /**
100 * Form-level conditions (paid add-on output) lifted from form_meta.
101 *
102 * @var array<int,array<string,mixed>>
103 */
104 private $form_conditions = [];
105
106 /**
107 * Form-level meta captured for get_form_metas() — title settings,
108 * actions (notifications), etc.
109 *
110 * @var array<string,mixed>
111 */
112 private $form_meta = [];
113
114 /**
115 * Form id currently being processed.
116 *
117 * @var int
118 */
119 private $current_form_id = 0;
120
121 /**
122 * Per-form memo of fetch_actions() — notifications and confirmations both
123 * read it, so cache to avoid double-querying nf3_objects/meta per form.
124 *
125 * @var array<int,array<string,mixed>>|null
126 */
127 private $actions_cache = null;
128
129 /**
130 * Set source identifiers.
131 *
132 * @since 2.11.0
133 */
134 public function __construct() {
135 $this->key = 'ninja';
136 $this->title = __( 'Ninja Forms', 'sureforms' );
137 }
138
139 /**
140 * Whether Ninja Forms is currently active.
141 *
142 * @since 2.11.0
143 *
144 * @return bool
145 */
146 public function exist() {
147 return class_exists( 'Ninja_Forms' ) || defined( 'NF_PLUGIN_VERSION' );
148 }
149
150 /**
151 * Map of Ninja Forms type → `Block_Templates` method for Free-emitable
152 * types. Pro overlays the rest via `srfm_migrator_tag_to_template_map`.
153 *
154 * @since 2.11.0
155 *
156 * @return array<string,string>
157 */
158 public function default_field_map() {
159 return [
160 'textbox' => 'input',
161 'firstname' => 'input',
162 'lastname' => 'input',
163 'address' => 'input',
164 'address2' => 'input',
165 'city' => 'input',
166 'zip' => 'input',
167 'phone' => 'phone',
168 'email' => 'email',
169 'textarea' => 'textarea',
170 'number' => 'number',
171 'listselect' => 'dropdown',
172 'listmultiselect' => 'dropdown',
173 'listradio' => 'multi_choice',
174 'listcheckbox' => 'multi_choice',
175 'liststate' => 'dropdown',
176 'listcountry' => 'dropdown',
177 'checkbox' => 'checkbox',
178 'terms' => 'gdpr',
179 ];
180 }
181
182 /**
183 * Fetch all Ninja forms from the custom tables.
184 *
185 * @since 2.11.0
186 *
187 * @return array<int,array<string,mixed>>
188 */
189 protected function get_source_forms() {
190 if ( ! $this->exist() ) {
191 return [];
192 }
193 global $wpdb;
194 $forms_table = $wpdb->prefix . 'nf3_forms';
195 $query = sprintf(
196 'SELECT id, title FROM %s ORDER BY id ASC',
197 esc_sql( $forms_table )
198 );
199 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
200 $rows = $wpdb->get_results( $query, ARRAY_A );
201 if ( ! is_array( $rows ) ) {
202 return [];
203 }
204 $out = [];
205 foreach ( $rows as $row ) {
206 $out[] = [
207 'id' => (int) $row['id'],
208 'name' => (string) $row['title'],
209 ];
210 }
211 return $out;
212 }
213
214 /**
215 * Return the Ninja form id from a source descriptor.
216 *
217 * @since 2.11.0
218 *
219 * @param array<string,mixed> $form Source descriptor.
220 * @return int
221 */
222 protected function get_source_form_id( array $form ) {
223 return isset( $form['id'] ) && is_numeric( $form['id'] ) ? (int) $form['id'] : 0;
224 }
225
226 /**
227 * Return the form title for a source descriptor.
228 *
229 * @since 2.11.0
230 *
231 * @param array<string,mixed> $form Source descriptor.
232 * @return string
233 */
234 protected function get_source_form_name( array $form ) {
235 $name = $this->str_arg( $form, 'name' );
236 return '' !== $name ? $name : __( '(untitled Ninja form)', 'sureforms' );
237 }
238
239 /**
240 * Build SureForms block markup for a Ninja form. Queries `nf3_fields`
241 * and `nf3_field_meta` to assemble each field's settings, then
242 * dispatches via the shared `srfm_migrator_*` filter pipeline.
243 *
244 * @since 2.11.0
245 *
246 * @param array<string,mixed> $form Source descriptor.
247 * @return string
248 */
249 protected function build_form_content( array $form ) {
250 $this->used_slugs = [];
251 $this->field_key_to_block_id = [];
252 $this->field_key_to_block_type = [];
253 $this->submit_label = '';
254 $this->form_conditions = [];
255 $this->form_meta = [];
256 $this->actions_cache = null;
257 $this->current_form_id = $this->get_source_form_id( $form );
258
259 $fields = $this->fetch_fields( $this->current_form_id );
260 if ( empty( $fields ) ) {
261 return '';
262 }
263 $this->form_meta = $this->fetch_form_meta( $this->current_form_id );
264
265 // Extract paid-add-on conditional logic blob, if present.
266 if ( isset( $this->form_meta['conditions'] ) && is_array( $this->form_meta['conditions'] ) ) {
267 $this->form_conditions = $this->form_meta['conditions'];
268 }
269
270 /**
271 * Filter the Ninja field list before iteration.
272 *
273 * @since 2.11.0
274 *
275 * @param array<int,array<string,mixed>> $fields Assembled Ninja fields.
276 * @param string $key Migrator source key (`ninja`).
277 * @param array<string,mixed> $form Source descriptor.
278 */
279 $fields = (array) apply_filters( 'srfm_migrator_preprocess_template', $fields, $this->key, $form );
280
281 $markup = '';
282 foreach ( $fields as $field ) {
283 if ( ! is_array( $field ) ) {
284 continue;
285 }
286 $type = $this->str_arg( $field, 'type' );
287 if ( in_array( $type, [ 'submit', 'spam', 'timedsubmit', 'recaptcha_v3', 'recaptcha', 'hcaptcha', 'turnstile' ], true ) ) {
288 if ( 'submit' === $type ) {
289 $this->submit_label = $this->str_arg( $field, 'label', 'Submit' );
290 }
291 continue; // Submit + captcha + spam → form-level, not block-level.
292 }
293 $markup .= $this->translate_field( $field );
294 }
295 return $markup;
296 }
297
298 /**
299 * Build SureForms post-meta payload.
300 *
301 * @since 2.11.0
302 *
303 * @param array<string,mixed> $form Source descriptor.
304 * @return array<string,mixed>
305 */
306 protected function get_form_metas( array $form ) {
307 unset( $form );
308 $metas = [
309 '_srfm_submit_button_text' => '' !== $this->submit_label ? $this->submit_label : __( 'Submit', 'sureforms' ),
310 ];
311
312 $confirmation = $this->translate_confirmation_from_actions();
313 if ( ! empty( $confirmation ) ) {
314 $metas['_srfm_form_confirmation'] = $confirmation;
315 }
316
317 $email = $this->translate_email_notifications_from_actions();
318 if ( ! empty( $email ) ) {
319 $metas['_srfm_email_notification'] = $email;
320 }
321
322 $cl_meta = $this->assemble_conditional_logic_meta();
323 if ( ! empty( $cl_meta ) ) {
324 $metas['_srfm_conditional_logic'] = $cl_meta;
325 }
326
327 return $metas;
328 }
329
330 /**
331 * Query `nf3_fields` + `nf3_field_meta` and collapse the meta rows
332 * into a flat associative array per field. Mirrors what Ninja's
333 * `NF_Abstracts_Model::__construct` does on read.
334 *
335 * @since 2.11.0
336 *
337 * @param int $form_id Ninja form id.
338 * @return array<int,array<string,mixed>>
339 */
340 protected function fetch_fields( $form_id ) {
341 global $wpdb;
342 $fields_table = $wpdb->prefix . 'nf3_fields';
343 $field_meta_table = $wpdb->prefix . 'nf3_field_meta';
344
345 $fields_query = sprintf(
346 'SELECT id, `label`, `key`, `type`, `order` FROM %s WHERE parent_id = %d ORDER BY `order` ASC',
347 esc_sql( $fields_table ),
348 (int) $form_id
349 );
350 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
351 $rows = $wpdb->get_results( $fields_query, ARRAY_A );
352 if ( ! is_array( $rows ) || empty( $rows ) ) {
353 return [];
354 }
355
356 $ids = array_map( static fn( $r ) => (int) $r['id'], $rows );
357 // Fetch all meta rows for these field ids in one query. Each id is
358 // cast to int above, so the IN-list is safe to interpolate directly
359 // — `prepare()` doesn't support variadic IN lists cleanly.
360 $ids_sql = implode( ',', $ids );
361 $meta_query = sprintf(
362 'SELECT parent_id, COALESCE(NULLIF(meta_key, \'\'), `key`) AS k, COALESCE(NULLIF(meta_value, \'\'), `value`) AS v FROM %s WHERE parent_id IN (%s)',
363 esc_sql( $field_meta_table ),
364 $ids_sql
365 );
366 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
367 $meta_rows = $wpdb->get_results( $meta_query, ARRAY_A );
368
369 $meta_by_field = [];
370 if ( is_array( $meta_rows ) ) {
371 foreach ( $meta_rows as $m ) {
372 $pid = (int) $m['parent_id'];
373 $k = (string) $m['k'];
374 $v = $m['v'];
375 $v = $this->safe_unserialize( $v );
376 $meta_by_field[ $pid ][ $k ] = $v;
377 }
378 }
379
380 $out = [];
381 foreach ( $rows as $row ) {
382 $id = (int) $row['id'];
383 $field = [
384 'id' => $id,
385 'label' => (string) $row['label'],
386 'key' => (string) $row['key'],
387 'type' => (string) $row['type'],
388 'order' => (int) $row['order'],
389 ];
390 $out[] = array_merge( $field, $meta_by_field[ $id ] ?? [] );
391 }
392 return $out;
393 }
394
395 /**
396 * Query `nf3_form_meta` and collapse into a flat assoc array.
397 *
398 * @since 2.11.0
399 *
400 * @param int $form_id Ninja form id.
401 * @return array<string,mixed>
402 */
403 protected function fetch_form_meta( $form_id ) {
404 global $wpdb;
405 $table = $wpdb->prefix . 'nf3_form_meta';
406 $query = sprintf(
407 'SELECT COALESCE(NULLIF(meta_key, \'\'), `key`) AS k, COALESCE(NULLIF(meta_value, \'\'), `value`) AS v FROM %s WHERE parent_id = %d',
408 esc_sql( $table ),
409 (int) $form_id
410 );
411 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
412 $rows = $wpdb->get_results( $query, ARRAY_A );
413 $out = [];
414 if ( is_array( $rows ) ) {
415 foreach ( $rows as $row ) {
416 $k = (string) $row['k'];
417 $v = $row['v'];
418 $v = $this->safe_unserialize( $v );
419 $out[ $k ] = $v;
420 }
421 }
422 return $out;
423 }
424
425 /**
426 * Fetch the form's actions (success message, email, redirect, …) via
427 * the `nf3_objects` + `nf3_object_meta` + `nf3_relationships` tables.
428 *
429 * @since 2.11.0
430 *
431 * @param int $form_id Ninja form id.
432 * @return array<int,array<string,mixed>>
433 */
434 protected function fetch_actions( $form_id ) {
435 if ( null !== $this->actions_cache ) {
436 return $this->actions_cache;
437 }
438 global $wpdb;
439 $objects_table = $wpdb->prefix . 'nf3_objects';
440 $rels_table = $wpdb->prefix . 'nf3_relationships';
441 $meta_table = $wpdb->prefix . 'nf3_object_meta';
442
443 // Table names are derived from $wpdb->prefix + a hard-coded literal;
444 // child_type is the hard-coded string 'action'; only $form_id needs
445 // prepared-statement protection.
446 $query = sprintf(
447 "SELECT o.id, o.type, o.title AS label FROM %1\$s o JOIN %2\$s r ON r.child_id = o.id WHERE r.parent_id = %3\$d AND r.child_type = 'action'",
448 esc_sql( $objects_table ),
449 esc_sql( $rels_table ),
450 (int) $form_id
451 );
452 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
453 $rows = $wpdb->get_results( $query, ARRAY_A );
454 if ( ! is_array( $rows ) || empty( $rows ) ) {
455 $this->actions_cache = [];
456 return $this->actions_cache;
457 }
458
459 $ids = array_map( static fn( $r ) => (int) $r['id'], $rows );
460 // Each id is cast to int above; safe to interpolate the IN-list
461 // directly.
462 $ids_sql = implode( ',', $ids );
463 $meta_query = sprintf(
464 'SELECT parent_id, COALESCE(NULLIF(meta_key, \'\'), `key`) AS k, COALESCE(NULLIF(meta_value, \'\'), `value`) AS v FROM %s WHERE parent_id IN (%s)',
465 esc_sql( $meta_table ),
466 $ids_sql
467 );
468 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
469 $meta_rows = $wpdb->get_results( $meta_query, ARRAY_A );
470 $by_id = [];
471 if ( is_array( $meta_rows ) ) {
472 foreach ( $meta_rows as $m ) {
473 $pid = (int) $m['parent_id'];
474 $k = (string) $m['k'];
475 $v = $m['v'];
476 $v = $this->safe_unserialize( $v );
477 $by_id[ $pid ][ $k ] = $v;
478 }
479 }
480
481 $out = [];
482 foreach ( $rows as $r ) {
483 $id = (int) $r['id'];
484 $out[] = [
485 'id' => $id,
486 'type' => (string) $r['type'],
487 'label' => (string) $r['label'],
488 'settings' => $by_id[ $id ] ?? [],
489 ];
490 }
491 $this->actions_cache = $out;
492 return $this->actions_cache;
493 }
494
495 /**
496 * Translate a single Ninja field (with meta merged in) into block
497 * markup.
498 *
499 * @since 2.11.0
500 *
501 * @param array<string,mixed> $field Ninja field with merged meta.
502 * @return string
503 */
504 private function translate_field( array $field ) {
505 $type = $this->str_arg( $field, 'type' );
506 if ( '' === $type ) {
507 return '';
508 }
509 if ( in_array( $type, $this->hard_unsupported_types(), true ) ) {
510 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
511 return '';
512 }
513 if ( 'hr' === $type ) {
514 return $this->dispatch_via_filter( 'divider', $this->build_block_args( $field ), $field );
515 }
516 if ( 'note' === $type || 'html' === $type ) {
517 return $this->dispatch_via_filter( 'html_block', $this->build_block_args( $field ), $field );
518 }
519 if ( 'date' === $type ) {
520 return $this->translate_date_field( $field );
521 }
522 if ( 'file_upload' === $type ) {
523 return $this->dispatch_via_filter( 'upload', $this->build_block_args( $field ), $field );
524 }
525 if ( 'starrating' === $type ) {
526 return $this->dispatch_via_filter( 'rating', $this->build_block_args( $field ), $field );
527 }
528 if ( 'hidden' === $type ) {
529 return $this->dispatch_via_filter( 'hidden_field', $this->build_block_args( $field ), $field );
530 }
531 if ( 'password' === $type ) {
532 return $this->dispatch_via_filter( 'password_input', $this->build_block_args( $field ), $field );
533 }
534 if ( 'signature' === $type ) {
535 return $this->dispatch_via_filter( 'signature', $this->build_block_args( $field ), $field );
536 }
537
538 /**
539 * Filter the Ninja-type → template-method map.
540 *
541 * @since 2.11.0
542 *
543 * @param array<string,string> $map Type → method.
544 * @param string $key Migrator source key (`ninja`).
545 */
546 $map = (array) apply_filters( 'srfm_migrator_tag_to_template_map', $this->default_field_map(), $this->key );
547 $args = $this->build_block_args( $field );
548 $method = $map[ $type ] ?? '';
549
550 if ( '' === $method ) {
551 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $type, $args, $this->key );
552 if ( '' === $markup ) {
553 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
554 return '';
555 }
556 return $this->capture_field_metadata( $field, $args, $markup, $type );
557 }
558
559 $markup = $this->dispatch_template( $method, $args );
560 if ( '' === $markup ) {
561 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key );
562 }
563 if ( '' === $markup ) {
564 $this->note_unsupported( $this->str_arg( $field, 'label', $type ) );
565 return '';
566 }
567 return $this->capture_field_metadata( $field, $args, $markup, $method );
568 }
569
570 /**
571 * Build SureForms block args from a Ninja field with merged meta.
572 *
573 * @since 2.11.0
574 *
575 * @param array<string,mixed> $field Ninja field.
576 * @return array<string,mixed>
577 */
578 private function build_block_args( array $field ) {
579 $type = $this->str_arg( $field, 'type' );
580 $label = $this->str_arg( $field, 'label' );
581 $slug_seed = '' !== $label ? $label : $type;
582 $slug = $this->reserve_slug( $slug_seed );
583
584 $args = [
585 'label' => $label,
586 'placeholder' => $this->str_arg( $field, 'placeholder' ),
587 'default_value' => $this->str_arg( $field, 'default' ),
588 'required' => ! empty( $field['required'] ),
589 'help' => $this->str_arg( $field, 'help_text', $this->str_arg( $field, 'desc_text' ) ),
590 'slug' => $slug,
591 ];
592
593 switch ( $type ) {
594 case 'textbox':
595 case 'firstname':
596 case 'lastname':
597 if ( isset( $field['input_limit'] ) && is_numeric( $field['input_limit'] ) && (int) $field['input_limit'] > 0 ) {
598 $args['max_length'] = (int) $field['input_limit'];
599 }
600 break;
601 case 'textarea':
602 if ( isset( $field['input_limit'] ) && is_numeric( $field['input_limit'] ) && (int) $field['input_limit'] > 0 ) {
603 $args['max_length'] = (int) $field['input_limit'];
604 }
605 break;
606 case 'number':
607 $num = isset( $field['number'] ) && is_array( $field['number'] ) ? $field['number'] : [];
608 if ( isset( $num['num_min'] ) && is_numeric( $num['num_min'] ) ) {
609 $args['min'] = (int) $num['num_min'];
610 }
611 if ( isset( $num['num_max'] ) && is_numeric( $num['num_max'] ) ) {
612 $args['max'] = (int) $num['num_max'];
613 }
614 break;
615 case 'listselect':
616 case 'listmultiselect':
617 case 'listradio':
618 case 'listcheckbox':
619 $options = $this->translate_options( $field );
620 $args['options'] = $options['options'];
621 $args['preselected'] = $options['preselected'];
622 $args['multiple'] = in_array( $type, [ 'listmultiselect', 'listcheckbox' ], true );
623 break;
624 case 'listcountry':
625 case 'liststate':
626 // Ninja auto-populates Country / State lists at render time, so the
627 // stored field has no `options[]` — translate_options() would yield
628 // only the "Option 1" placeholder. Seed the canonical list instead.
629 $options = 'listcountry' === $type ? $this->country_options() : $this->state_options();
630 $args['options'] = $options;
631 $args['preselected'] = [];
632 $args['multiple'] = false;
633 break;
634 case 'checkbox':
635 $args['checked'] = $this->checkbox_is_checked( $field );
636 break;
637 case 'date':
638 // Ninja's Date/Time field carries a `date_mode` setting whose
639 // value is one of `date` / `time` / `date and time`. Map that to
640 // SureForms' date-picker `format` enum (`date`|`time`|`date-time`)
641 // so a time-only or date+time field isn't flattened to date-only.
642 // `date_time_picker` emits a time-picker for `time` and a
643 // date-picker for `date`/`date-time`; we additionally emit a
644 // companion time block for `date-time` (see translate_field()),
645 // because SureForms' date-picker has no on-field time component.
646 $args['format'] = $this->date_format_from_mode( $field );
647 $args['date_format'] = $this->str_arg( $field, 'date_format', 'm/d/Y' );
648 break;
649 case 'file_upload':
650 $exts = isset( $field['upload_types'] ) && is_array( $field['upload_types'] ) ? $field['upload_types'] : [];
651 if ( ! empty( $exts ) ) {
652 $args['allowed_formats'] = array_values( array_filter( $exts, 'is_string' ) );
653 }
654 if ( isset( $field['max_filesize'] ) && is_numeric( $field['max_filesize'] ) ) {
655 $args['file_size_limit'] = (int) $field['max_filesize'];
656 }
657 if ( isset( $field['max_files'] ) && is_numeric( $field['max_files'] ) ) {
658 $args['max_files'] = (int) $field['max_files'];
659 $args['multiple'] = (int) $field['max_files'] > 1;
660 }
661 break;
662 case 'starrating':
663 if ( isset( $field['number_of_stars'] ) && is_numeric( $field['number_of_stars'] ) ) {
664 $args['icon'] = 'star';
665 }
666 break;
667 case 'hidden':
668 $args['default_value'] = $this->str_arg( $field, 'default' );
669 break;
670 case 'html':
671 case 'note':
672 $args['content'] = $this->str_arg( $field, 'default' );
673 break;
674 }
675
676 return $args;
677 }
678
679 /**
680 * Translate Ninja's `options[]` (assoc rows with label/value/selected)
681 * into SureForms options + a preselected-INDEX list. The option title is
682 * the Ninja label (falling back to value) — the same string Ninja CL
683 * rules compare against — so `convert_rule()` matches it directly without
684 * a re-key map.
685 *
686 * @since 2.11.0
687 *
688 * @param array<string,mixed> $field Ninja field.
689 * @return array<string,mixed>
690 */
691 private function translate_options( array $field ) {
692 $raw = isset( $field['options'] ) && is_array( $field['options'] ) ? $field['options'] : [];
693 $options = [];
694 $preselected = [];
695 $i = 0;
696 foreach ( $raw as $opt ) {
697 if ( ! is_array( $opt ) ) {
698 continue;
699 }
700 // Ninja stores the visible text in `label` and the submitted
701 // value in `value` — surface the label as the SureForms option
702 // title, falling back to `value` when the editor left label empty.
703 $label = $this->str_arg( $opt, 'label' );
704 $display = '' !== $label ? $label : $this->str_arg( $opt, 'value' );
705 $options[] = [ 'label' => $display ];
706 if ( ! empty( $opt['selected'] ) ) {
707 $preselected[] = $i;
708 }
709 ++$i;
710 }
711 if ( empty( $options ) ) {
712 $options = [ [ 'label' => 'Option 1' ] ];
713 }
714 return [
715 'options' => $options,
716 'preselected' => $preselected,
717 ];
718 }
719
720 /**
721 * Translate a Ninja Date/Time field. Ninja's single field can be a date
722 * picker, a time picker, or both (its `date_mode` setting). SureForms'
723 * date-picker block has no on-field time component, so a `date and time`
724 * Ninja field is emitted as TWO blocks — a date-picker plus a companion
725 * time-picker — rather than being flattened to date-only.
726 *
727 * @since 2.11.0
728 *
729 * @param array<string,mixed> $field Ninja date field with merged meta.
730 * @return string
731 */
732 private function translate_date_field( array $field ) {
733 $args = $this->build_block_args( $field );
734 $format = $this->str_arg( $args, 'format', 'date' );
735
736 if ( 'date-time' !== $format ) {
737 // Pure date or pure time → one date_time_picker block; the Pro
738 // emitter routes on args['format'] ('date'|'time').
739 return $this->dispatch_via_filter( 'date_time_picker', $args, $field );
740 }
741
742 // Date + time → emit a date block plus a companion time block so the
743 // time component survives. Only the date block is registered for
744 // conditional logic (via dispatch_via_filter → capture_field_metadata):
745 // the Ninja field has a single key, so it anchors CL to the primary
746 // date block. The companion time block is supplementary and emitted
747 // directly without re-registering the same key (which would clobber it).
748 $date_args = $args;
749 $date_args['format'] = 'date';
750 $markup = $this->dispatch_via_filter( 'date_time_picker', $date_args, $field );
751
752 $time_args = $args;
753 $time_args['format'] = 'time';
754 $time_label = '' !== $this->str_arg( $args, 'label' )
755 ? $this->str_arg( $args, 'label' ) . ' ' . __( '(Time)', 'sureforms' )
756 : __( 'Time', 'sureforms' );
757 $time_args['label'] = $time_label;
758 $time_args['slug'] = $this->reserve_slug( $time_label );
759 return $markup . (string) apply_filters( 'srfm_migrator_block_template', '', 'date_time_picker', $time_args, $this->key );
760 }
761
762 /**
763 * Resolve the SureForms date-picker `format` enum (`date`|`time`|`date-time`)
764 * from a Ninja date field's `date_mode` setting. Ninja stores one of
765 * `date` / `time` / `date and time`; older builds may use `datetime`.
766 *
767 * @since 2.11.0
768 *
769 * @param array<string,mixed> $field Ninja date field.
770 * @return string `date`, `time`, or `date-time`.
771 */
772 private function date_format_from_mode( array $field ) {
773 $mode = strtolower( trim( $this->str_arg( $field, 'date_mode', 'date' ) ) );
774 if ( 'time' === $mode ) {
775 return 'time';
776 }
777 if ( in_array( $mode, [ 'date and time', 'datetime', 'date-time', 'date_and_time' ], true ) ) {
778 return 'date-time';
779 }
780 return 'date';
781 }
782
783 /**
784 * Resolve whether a Ninja single-checkbox field defaults to checked.
785 *
786 * Ninja stores the default in the `default_value` setting as the string
787 * `checked` / `unchecked` (the generic `default` key can also carry it).
788 * Treat `checked` (case-insensitive) or a truthy `1`/`true`/`yes` as on.
789 *
790 * @since 2.11.0
791 *
792 * @param array<string,mixed> $field Ninja checkbox field.
793 * @return bool
794 */
795 private function checkbox_is_checked( array $field ) {
796 $raw = strtolower( trim( $this->str_arg( $field, 'default_value', $this->str_arg( $field, 'default' ) ) ) );
797 if ( in_array( $raw, [ 'checked', '1', 'true', 'yes' ], true ) ) {
798 return true;
799 }
800 // Ninja checkboxes can define a custom "checked" value; a default that
801 // equals it (and isn't blank) means the box starts checked.
802 $checked_value = strtolower( trim( $this->str_arg( $field, 'checked_value' ) ) );
803 return '' !== $checked_value && $raw === $checked_value;
804 }
805
806 /**
807 * Build SureForms dropdown options for a Ninja `listcountry` field from the
808 * bundled, server-readable country list at `inc/fields/countries.json`
809 * (Ninja auto-populates its country list at render, so the source field
810 * carries no options of its own).
811 *
812 * @since 2.11.0
813 *
814 * @return array<int,array<string,string>>
815 */
816 private function country_options() {
817 $path = SRFM_DIR . 'inc/fields/countries.json';
818 // Local bundled catalogue — wp_remote_get() is for remote URLs only.
819 $raw = is_readable( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
820 $list = is_string( $raw ) ? json_decode( $raw, true ) : null;
821 $out = [];
822 if ( is_array( $list ) ) {
823 foreach ( $list as $entry ) {
824 if ( is_array( $entry ) ) {
825 $name = $this->str_arg( $entry, 'name' );
826 if ( '' !== $name ) {
827 $out[] = [ 'label' => $name ];
828 }
829 }
830 }
831 }
832 return ! empty( $out ) ? $out : [ [ 'label' => 'Option 1' ] ];
833 }
834
835 /**
836 * Built-in US state list for a Ninja `liststate` field. Ninja's State
837 * dropdown auto-populates the 50 US states + DC at render time, so the
838 * stored field carries no options to read; this mirrors that catalogue.
839 *
840 * @since 2.11.0
841 *
842 * @return array<int,array<string,string>>
843 */
844 private function state_options() {
845 $states = [
846 'Alabama',
847 'Alaska',
848 'Arizona',
849 'Arkansas',
850 'California',
851 'Colorado',
852 'Connecticut',
853 'Delaware',
854 'District of Columbia',
855 'Florida',
856 'Georgia',
857 'Hawaii',
858 'Idaho',
859 'Illinois',
860 'Indiana',
861 'Iowa',
862 'Kansas',
863 'Kentucky',
864 'Louisiana',
865 'Maine',
866 'Maryland',
867 'Massachusetts',
868 'Michigan',
869 'Minnesota',
870 'Mississippi',
871 'Missouri',
872 'Montana',
873 'Nebraska',
874 'Nevada',
875 'New Hampshire',
876 'New Jersey',
877 'New Mexico',
878 'New York',
879 'North Carolina',
880 'North Dakota',
881 'Ohio',
882 'Oklahoma',
883 'Oregon',
884 'Pennsylvania',
885 'Rhode Island',
886 'South Carolina',
887 'South Dakota',
888 'Tennessee',
889 'Texas',
890 'Utah',
891 'Vermont',
892 'Virginia',
893 'Washington',
894 'West Virginia',
895 'Wisconsin',
896 'Wyoming',
897 ];
898 $out = [];
899 foreach ( $states as $state ) {
900 $out[] = [ 'label' => $state ];
901 }
902 return $out;
903 }
904
905 /**
906 * Dispatch a method through `srfm_migrator_block_template`, flag
907 * unsupported if no subscriber answers.
908 *
909 * @since 2.11.0
910 *
911 * @param string $method Template method name.
912 * @param array<string,mixed> $args Block args.
913 * @param array<string,mixed> $field Source field (for unsupported label).
914 * @return string
915 */
916 private function dispatch_via_filter( $method, array $args, array $field ) {
917 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key );
918 if ( '' === $markup ) {
919 $this->note_unsupported( $this->str_arg( $field, 'label', $method ) );
920 return '';
921 }
922 return $this->capture_field_metadata( $field, $args, $markup, $method );
923 }
924
925 /**
926 * After emitting a block, capture its block_id under the source
927 * field's `key` (slug) so conditional logic can rewrite targets.
928 *
929 * @since 2.11.0
930 *
931 * @param array<string,mixed> $field Source field.
932 * @param array<string,mixed> $args Block args.
933 * @param string $markup Assembled markup.
934 * @param string $type_key Method / type for the type bucket.
935 * @return string
936 */
937 private function capture_field_metadata( array $field, array $args, $markup, $type_key ) {
938 unset( $args );
939 $key = $this->str_arg( $field, 'key' );
940 if ( '' !== $key && preg_match( '/"block_id":"([a-f0-9]{8})"/', $markup, $m ) ) {
941 $this->field_key_to_block_id[ $key ] = $m[1];
942 $this->field_key_to_block_type[ $key ] = $this->block_type_bucket( $type_key );
943 }
944 return $markup;
945 }
946
947 /**
948 * Block-type bucket for the SureForms CL editor.
949 *
950 * @since 2.11.0
951 *
952 * @param string $type Source type or method name.
953 * @return string
954 */
955 private function block_type_bucket( $type ) {
956 if ( in_array( $type, [ 'number' ], true ) ) {
957 return 'number';
958 }
959 if ( in_array( $type, [ 'listselect', 'listmultiselect', 'listradio', 'listcheckbox', 'multi_choice', 'dropdown' ], true ) ) {
960 return 'list';
961 }
962 if ( in_array( $type, [ 'date', 'date_picker' ], true ) ) {
963 return 'datepicker';
964 }
965 if ( in_array( $type, [ 'time', 'time_picker' ], true ) ) {
966 return 'timepicker';
967 }
968 return 'default';
969 }
970
971 /**
972 * Map a Ninja comparator to the SureForms operator slug, or null when the
973 * comparator has no equivalent. Date/time buckets use their dedicated maps;
974 * all others go through OPERATOR_MAP. Validity against the bucket's allowed
975 * set is reconciled by `resolve_cl_bucket()` in the caller.
976 *
977 * @since 2.11.0
978 *
979 * @param string $comparator Ninja comparator (e.g. `equal`, `contains`).
980 * @param string $bucket Resolved block-type bucket.
981 * @return string|null
982 */
983 private function map_operator( $comparator, $bucket ) {
984 if ( 'datepicker' === $bucket ) {
985 return self::DATE_OPERATOR_MAP[ $comparator ] ?? null;
986 }
987 if ( 'timepicker' === $bucket ) {
988 return self::TIME_OPERATOR_MAP[ $comparator ] ?? null;
989 }
990 return self::OPERATOR_MAP[ $comparator ] ?? null;
991 }
992
993 /**
994 * Unserialize a Ninja meta value without instantiating objects.
995 *
996 * `maybe_unserialize()` delegates to bare `unserialize()` with no class
997 * allow-list, so a serialized object in `nf3_*_meta` would be woken
998 * (`__wakeup`/`__destruct` gadget surface). Gate on `is_serialized()` and
999 * forbid classes — matches the Gravity importer's hardening. Non-string
1000 * and non-serialized values pass through unchanged.
1001 *
1002 * @since 2.11.0
1003 *
1004 * @param mixed $value Raw meta value.
1005 * @return mixed
1006 */
1007 private function safe_unserialize( $value ) {
1008 if ( ! is_string( $value ) || ! is_serialized( $value ) ) {
1009 return $value;
1010 }
1011 // allowed_classes => false forbids object instantiation (no gadget surface).
1012 return unserialize( $value, [ 'allowed_classes' => false ] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
1013 }
1014
1015 /**
1016 * Assemble the `_srfm_conditional_logic` post-meta from Ninja's
1017 * form-level `conditions` array (paid add-on only).
1018 *
1019 * @since 2.11.0
1020 *
1021 * @return array<int,array<string,mixed>>
1022 */
1023 private function assemble_conditional_logic_meta() {
1024 if ( empty( $this->form_conditions ) ) {
1025 return [];
1026 }
1027 $out = [];
1028 foreach ( $this->form_conditions as $condition ) {
1029 if ( ! is_array( $condition ) ) {
1030 continue;
1031 }
1032 $when = isset( $condition['when'] ) && is_array( $condition['when'] ) ? $condition['when'] : [];
1033 $connector = $this->str_arg( $condition, 'connector', 'and' );
1034 $then = isset( $condition['then'] ) && is_array( $condition['then'] ) ? $condition['then'] : [];
1035
1036 // Convert the `when` rules.
1037 $rules = [];
1038 foreach ( $when as $w ) {
1039 if ( ! is_array( $w ) ) {
1040 continue;
1041 }
1042 $converted = $this->convert_rule( $w );
1043 if ( null !== $converted ) {
1044 $rules[] = $converted;
1045 }
1046 }
1047 if ( empty( $rules ) ) {
1048 continue;
1049 }
1050 // `or` connector → each rule its own subgroup. `and` → one group.
1051 $logic = 'or' === strtolower( $connector )
1052 ? array_map( static fn( $r ) => [ $r ], $rules )
1053 : [ $rules ];
1054
1055 // Attach to each target named in `then`.
1056 foreach ( $then as $act ) {
1057 if ( ! is_array( $act ) ) {
1058 continue;
1059 }
1060 $target_key = $this->str_arg( $act, 'key' );
1061 $trigger = $this->str_arg( $act, 'trigger', 'show' );
1062 $target_block = $this->field_key_to_block_id[ $target_key ] ?? '';
1063 if ( '' === $target_block || ! in_array( $trigger, [ 'show', 'hide' ], true ) ) {
1064 continue;
1065 }
1066 $out[] = [
1067 $target_block => [
1068 'action' => $trigger,
1069 'logic' => $logic,
1070 ],
1071 ];
1072 }
1073 }
1074 return $out;
1075 }
1076
1077 /**
1078 * Convert one Ninja conditional rule into the SureForms shape.
1079 *
1080 * @since 2.11.0
1081 *
1082 * @param array<string,mixed> $rule Ninja `when` rule.
1083 * @return array<string,string>|null
1084 */
1085 private function convert_rule( array $rule ) {
1086 $src = $this->str_arg( $rule, 'key' );
1087 $cmp = $this->str_arg( $rule, 'comparator', 'equal' );
1088 $block = $this->field_key_to_block_id[ $src ] ?? '';
1089 if ( '' === $block ) {
1090 return null;
1091 }
1092 $bucket = $this->field_key_to_block_type[ $src ] ?? 'default';
1093 $operator = $this->map_operator( $cmp, $bucket );
1094 if ( null === $operator ) {
1095 // Comparator has no SureForms equivalent — drop the rule.
1096 return null;
1097 }
1098 // Reconcile the operator against the bucket via the shared Base_Migrator
1099 // allowlist: down-buckets a text-style operator to `default`, or drops
1100 // the rule when no bucket supports it.
1101 $bucket = $this->resolve_cl_bucket( $operator, $bucket );
1102 if ( '' === $bucket ) {
1103 return null;
1104 }
1105 // The option title we emit is the Ninja option label (translate_options),
1106 // which is the same string Ninja CL rules compare against — pass the
1107 // value through directly.
1108 return [
1109 'field' => $block,
1110 'operator' => $operator,
1111 'value' => $this->str_arg( $rule, 'value' ),
1112 'type' => $bucket,
1113 ];
1114 }
1115
1116 /**
1117 * Translate Ninja `email` actions (in `wp_nf3_objects` joined via
1118 * `nf3_relationships`) into SureForms email notification meta.
1119 *
1120 * For simplicity we read the same data through the WordPress option
1121 * cache that Ninja exposes via its REST API; if unavailable, fall
1122 * back to scanning form_meta for an `email_*` key prefix.
1123 *
1124 * @since 2.11.0
1125 *
1126 * @return array<int,array<string,mixed>>
1127 */
1128 private function translate_email_notifications_from_actions() {
1129 $actions = $this->fetch_actions( $this->current_form_id );
1130 $notifications = [];
1131 $id = 1;
1132 foreach ( $actions as $action ) {
1133 if ( 'email' !== ( $action['type'] ?? '' ) ) {
1134 continue;
1135 }
1136 $settings = isset( $action['settings'] ) && is_array( $action['settings'] ) ? $action['settings'] : [];
1137 $to = $this->str_arg( $settings, 'to' );
1138 if ( '' === $to ) {
1139 $admin = get_option( 'admin_email' );
1140 $to = is_string( $admin ) ? $admin : '{admin_email}';
1141 }
1142 $from_name = $this->str_arg( $settings, 'from_name', '{site_title}' );
1143 $from_email = $this->str_arg( $settings, 'from_address', '{admin_email}' );
1144 // SureForms reads the full notification shape (id/from_*/cc/bcc) at
1145 // both render (form-submit.php) and editor (form-metadata.php) time;
1146 // mirror Migrator_CF7's canonical keys so the editor doesn't fall back
1147 // to the global-default notification.
1148 $notifications[] = [
1149 'id' => $id,
1150 'status' => true,
1151 'is_raw_format' => false,
1152 'name' => $this->str_arg( $action, 'label', __( 'Admin Notification Email', 'sureforms' ) ),
1153 'email_to' => $to,
1154 'email_reply_to' => $this->str_arg( $settings, 'reply_to', '{admin_email}' ),
1155 'from_name' => '' !== $from_name ? $from_name : '{site_title}',
1156 'from_email' => '' !== $from_email ? $from_email : '{admin_email}',
1157 'email_cc' => $this->str_arg( $settings, 'cc' ),
1158 'email_bcc' => $this->str_arg( $settings, 'bcc' ),
1159 'subject' => $this->str_arg( $settings, 'email_subject', __( 'New form submission', 'sureforms' ) ),
1160 'email_body' => $this->str_arg( $settings, 'email_message', '{all_data}' ),
1161 ];
1162 ++$id;
1163 }
1164 return $notifications;
1165 }
1166
1167 /**
1168 * Translate Ninja `successmessage` / `redirect` actions into the
1169 * SureForms confirmation shape.
1170 *
1171 * @since 2.11.0
1172 *
1173 * @return array<int,array<string,mixed>>
1174 */
1175 private function translate_confirmation_from_actions() {
1176 $actions = $this->fetch_actions( $this->current_form_id );
1177 $entry = [
1178 'confirmation_type' => 'same page',
1179 'message' => $this->default_confirmation_message(),
1180 'page_url' => '',
1181 ];
1182 foreach ( $actions as $action ) {
1183 $type = $this->str_arg( $action, 'type' );
1184 $settings = isset( $action['settings'] ) && is_array( $action['settings'] ) ? $action['settings'] : [];
1185 if ( 'successmessage' === $type && ! empty( $settings['success_msg'] ) ) {
1186 $entry['message'] = wp_kses_post( $this->str_arg( $settings, 'success_msg' ) );
1187 return [ $entry ];
1188 }
1189 if ( 'redirect' === $type && ! empty( $settings['redirect_url'] ) ) {
1190 $entry['confirmation_type'] = 'different page';
1191 $entry['page_url'] = esc_url_raw( $this->str_arg( $settings, 'redirect_url' ) );
1192 return [ $entry ];
1193 }
1194 }
1195 return [ $entry ];
1196 }
1197
1198 /**
1199 * Source types Ninja Forms ships but SureForms has no peer for.
1200 *
1201 * @since 2.11.0
1202 *
1203 * @return array<int,string>
1204 */
1205 private function hard_unsupported_types() {
1206 return [
1207 'creditcard',
1208 'creditcardnumber',
1209 'creditcardcvc',
1210 'creditcardexpiration',
1211 'creditcardfullname',
1212 'creditcardzip',
1213 'total',
1214 'product',
1215 'quantity',
1216 'shipping',
1217 'tax',
1218 'listmodifier',
1219 'stripeshipping',
1220 'unknown',
1221 ];
1222 }
1223
1224 /**
1225 * Coerce a mixed array entry to string.
1226 *
1227 * @since 2.11.0
1228 *
1229 * @param array<string,mixed> $arr Source array.
1230 * @param string $key Key.
1231 * @param string $default Default.
1232 * @return string
1233 */
1234 private function str_arg( array $arr, $key, $default = '' ) {
1235 if ( ! isset( $arr[ $key ] ) ) {
1236 return $default;
1237 }
1238 $value = $arr[ $key ];
1239 if ( is_string( $value ) ) {
1240 return $value;
1241 }
1242 if ( is_scalar( $value ) ) {
1243 return (string) $value;
1244 }
1245 return $default;
1246 }
1247 }
1248