Migrator. Real detection + real, field-mapped import for Fluent * Forms, Contact Form 7, WPForms, and Ninja Forms — each mapping was built * by reading that plugin's actual source code (table/post schema, field * type slugs) rather than guessing, and the WPForms/Ninja Forms/CF7 paths * were verified against a real installed copy of each plugin. Gravity * Forms is real detection only — it's a commercial plugin with no free * download to verify a field-mapping against, so rather than guess at its * schema blind, that one stays honestly "Coming Soon". Every import is * best-effort: an unrecognized field type is never dropped silently, it * lands as a plain Simple Text field with its original label preserved, * so no submitted-data-shape is lost even when the visual field type * doesn't have a ChargeForms equivalent. */ class Migrator { // Use the public WordPress.org plugin-directory icon for a source when one // exists. The image is optional and the Dashicon remains the reliable // fallback when a site is offline or the source has no directory listing. const SOURCES = [ 'fluentforms' => [ 'label' => 'Fluent Forms', 'icon' => 'feedback', 'logo' => 'https://ps.w.org/fluentform/assets/icon-256x256.png', 'buildable' => true ], 'cf7' => [ 'label' => 'Contact Form 7', 'icon' => 'email-alt', 'logo' => 'https://ps.w.org/contact-form-7/assets/icon-256x256.png', 'buildable' => true ], 'wpforms' => [ 'label' => 'WPForms', 'icon' => 'forms', 'logo' => 'https://ps.w.org/wpforms-lite/assets/icon-256x256.png', 'buildable' => true ], 'ninjaforms' => [ 'label' => 'Ninja Forms', 'icon' => 'admin-generic', 'logo' => 'https://ps.w.org/ninja-forms/assets/icon-256x256.png', 'buildable' => true ], 'gravityforms' => [ 'label' => 'Gravity Forms', 'icon' => 'admin-generic', 'logo' => '', 'buildable' => false ], ]; /** One row per known source: label, icon, whether real import is built, and how many forms were detected (0 = plugin not active / no forms). */ public static function detect_sources() { $out = []; foreach ( self::SOURCES as $key => $meta ) { $out[ $key ] = array_merge( $meta, [ 'key' => $key, 'count' => self::count_for( $key ) ] ); } return $out; } private static function count_for( $key ) { switch ( $key ) { case 'fluentforms': return count( self::fluentforms_forms() ); case 'cf7': return count( self::cf7_forms() ); case 'wpforms': return count( self::wpforms_forms() ); case 'ninjaforms': return count( self::ninjaforms_forms() ); case 'gravityforms': return self::table_exists( 'gf_form' ) ? (int) $GLOBALS['wpdb']->get_var( "SELECT COUNT(*) FROM {$GLOBALS['wpdb']->prefix}gf_form" ) : 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared default: return 0; } } private static function table_exists( $table ) { global $wpdb; $full = $wpdb->prefix . $table; return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $full ) ) === $full; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** One call site per buildable source instead of a growing if/else chain in the admin UI. */ public static function forms_for( $source ) { switch ( $source ) { case 'fluentforms': return self::fluentforms_forms(); case 'cf7': return self::cf7_forms(); case 'wpforms': return self::wpforms_forms(); case 'ninjaforms': return self::ninjaforms_forms(); default: return []; } } /** @return array|\WP_Error */ public static function import_from( $source, $source_id ) { switch ( $source ) { case 'fluentforms': return self::fluentforms_import( $source_id ); case 'cf7': return self::cf7_import( $source_id ); case 'wpforms': return self::wpforms_import( $source_id ); case 'ninjaforms': return self::ninjaforms_import( $source_id ); default: return new \WP_Error( 'chargeforms_migrator', __( 'Unknown migration source.', 'chargeforms' ) ); } } /* ----------------------------------------------------------------- * Fluent Forms * --------------------------------------------------------------- */ private static function fluentforms_table() { global $wpdb; return $wpdb->prefix . 'fluentform_forms'; } public static function fluentforms_forms() { global $wpdb; if ( ! self::table_exists( 'fluentform_forms' ) ) { return []; } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, title FROM %i ORDER BY id DESC', self::fluentforms_table() ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. return $rows ?: []; } /** @return array|\WP_Error The new ChargeForms form, or an error if the source form couldn't be read. */ public static function fluentforms_import( $source_id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT title, form_fields FROM %i WHERE id = %d', self::fluentforms_table(), $source_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. if ( ! $row ) { return new \WP_Error( 'chargeforms_migrator', __( 'That Fluent Forms form could not be found.', 'chargeforms' ) ); } $decoded = json_decode( $row['form_fields'], true ); $raw = is_array( $decoded ) ? ( $decoded['fields'] ?? [] ) : []; $fields = self::flatten_fluentforms_fields( $raw ); return Form::create( [ /* translators: %s: original form title */ 'title' => sprintf( __( '%s (from Fluent Forms)', 'chargeforms' ), $row['title'] ), 'fields' => $fields, ] ); } /** Fluent Forms nests row/column layout fields ('container' elements holding their own 'columns' -> 'fields') — ChargeForms's field list is flat, so this walks the tree and keeps only real input fields, in document order. */ private static function flatten_fluentforms_fields( array $raw, &$out = [] ) { foreach ( $raw as $ff_field ) { if ( ! is_array( $ff_field ) ) { continue; } $element = $ff_field['element'] ?? ''; if ( 'container' === $element && ! empty( $ff_field['columns'] ) && is_array( $ff_field['columns'] ) ) { foreach ( $ff_field['columns'] as $column ) { if ( ! empty( $column['fields'] ) && is_array( $column['fields'] ) ) { self::flatten_fluentforms_fields( $column['fields'], $out ); } } continue; } $mapped = self::map_fluentforms_field( $ff_field ); if ( $mapped ) { $out[] = $mapped; } } return $out; } private static function map_fluentforms_field( array $ff_field ) { $element = $ff_field['element'] ?? ''; $attrs = $ff_field['attributes'] ?? []; $label = $ff_field['settings']['label'] ?? ( $ff_field['settings']['admin_field_label'] ?? __( 'Untitled Field', 'chargeforms' ) ); $type_map = [ 'input_text' => 'text', 'input_email' => 'email', 'input_name' => 'name', 'input_number' => 'number', 'phone' => 'phone', 'input_url' => 'url', 'input_password' => 'password', 'textarea' => 'textarea', 'select' => 'select', 'input_radio' => 'radio', 'input_checkbox' => 'checkbox', 'date_time' => 'date', 'input_hidden' => 'hidden', 'input_file' => 'file', 'terms_and_condition' => 'terms', 'gdpr_agreement' => 'gdpr', 'recaptcha' => 'recaptcha', 'hcaptcha' => 'hcaptcha', 'custom_html' => 'html', 'section_break' => 'section_break', 'form_step' => 'page_break', 'ratings' => 'ratings', 'net_promoter_score' => 'nps', 'select_country' => 'country', 'range_slider' => 'range_slider', 'address' => 'address', ]; if ( ! isset( $type_map[ $element ] ) ) { // Unknown element — don't drop the field silently, degrade to a // plain text field so the label/position survives the import // and the site owner can see it needs manual attention. $chargeforms_type = 'text'; } else { $chargeforms_type = $type_map[ $element ]; } $field = [ 'id' => 'f_' . substr( md5( uniqid( '', true ) ), 0, 8 ), 'type' => $chargeforms_type, 'label' => wp_strip_all_tags( (string) $label ), 'required' => ! empty( $ff_field['settings']['validation_rules']['required']['value'] ), ]; if ( in_array( $chargeforms_type, [ 'select', 'radio', 'checkbox' ], true ) ) { $choices = $ff_field['settings']['advanced_options'] ?? []; $options = []; foreach ( $choices as $choice ) { if ( isset( $choice['label'] ) ) { $options[] = wp_strip_all_tags( (string) $choice['label'] ); } } $field['options'] = $options ?: [ __( 'Option 1', 'chargeforms' ), __( 'Option 2', 'chargeforms' ) ]; } if ( ! empty( $attrs['placeholder'] ) ) { $field['placeholder'] = wp_strip_all_tags( (string) $attrs['placeholder'] ); } if ( 'html' === $chargeforms_type ) { $field['content'] = wp_kses_post( $ff_field['settings']['html_codes'] ?? '' ); } return $field; } /* ----------------------------------------------------------------- * Contact Form 7 * --------------------------------------------------------------- */ public static function cf7_forms() { if ( ! post_type_exists( 'wpcf7_contact_form' ) ) { return []; } $posts = get_posts( [ 'post_type' => 'wpcf7_contact_form', 'posts_per_page' => -1, 'post_status' => 'any', ] ); return array_map( fn( $p ) => [ 'id' => $p->ID, 'title' => $p->post_title ], $posts ); } public static function cf7_import( $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'wpcf7_contact_form' !== $post->post_type ) { return new \WP_Error( 'chargeforms_migrator', __( 'That Contact Form 7 form could not be found.', 'chargeforms' ) ); } // CF7 stores its field definitions as its own shortcode-like tags in // a serialized post meta ('_form'), one tag per field, e.g. // [text* your-name placeholder "Full name"]. $form_content = get_post_meta( $post_id, '_form', true ); $fields = self::parse_cf7_tags( (string) $form_content ); return Form::create( [ /* translators: %s: original form title */ 'title' => sprintf( __( '%s (from Contact Form 7)', 'chargeforms' ), $post->post_title ), 'fields' => $fields, ] ); } private static function parse_cf7_tags( $content ) { $fields = []; if ( ! preg_match_all( '/\[[\s]*([a-zA-Z][a-zA-Z0-9_-]*)(\*?)[\s]+([^\]]*)\]/', $content, $matches, PREG_SET_ORDER ) ) { return $fields; } $type_map = [ 'text' => 'text', 'email' => 'email', 'url' => 'url', 'tel' => 'phone', 'number' => 'number', 'range' => 'number', 'date' => 'date', 'textarea' => 'textarea', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'radio', 'acceptance' => 'terms', 'file' => 'file', 'quiz' => 'text', 'hidden' => 'hidden', ]; foreach ( $matches as $match ) { $base_type = strtolower( $match[1] ); $required = '*' === $match[2]; $rest = trim( $match[3] ); if ( in_array( $base_type, [ 'submit', 'captchac', 'captchar' ], true ) ) { continue; // The submit button and legacy CAPTCHA tags aren't fields to import. } if ( ! isset( $type_map[ $base_type ] ) ) { continue; // Unrecognized CF7 tag type (a third-party CF7 add-on's own tag) — skip rather than guess. } // Tokenize: quoted strings stay together, everything else // splits on whitespace — this is CF7's own tag grammar. preg_match_all( '/"[^"]*"|\S+/', $rest, $token_matches ); $tokens = $token_matches[0]; $name = ''; $options = []; foreach ( $tokens as $token ) { $is_quoted = '"' === substr( $token, 0, 1 ); $is_option = false !== strpos( $token, ':' ) && ! $is_quoted; if ( $is_quoted ) { $options[] = trim( $token, '"' ); } elseif ( ! $is_option && '' === $name ) { $name = $token; } } $field = [ 'id' => 'f_' . substr( md5( uniqid( '', true ) ), 0, 8 ), 'type' => $type_map[ $base_type ], 'label' => $name ? ucwords( str_replace( [ '-', '_' ], ' ', $name ) ) : __( 'Untitled Field', 'chargeforms' ), 'required' => $required, ]; if ( in_array( $type_map[ $base_type ], [ 'select', 'radio', 'checkbox' ], true ) ) { $field['options'] = $options ?: [ __( 'Option 1', 'chargeforms' ), __( 'Option 2', 'chargeforms' ) ]; } elseif ( $options ) { // For text-like tags CF7's quoted token is either a // "placeholder" keyword's value or a bare default — since // the "placeholder" keyword itself is a separate bare // token immediately before it, use that to disambiguate. $has_placeholder_keyword = in_array( 'placeholder', array_map( 'strtolower', $tokens ), true ); if ( $has_placeholder_keyword ) { $field['placeholder'] = $options[0]; } else { $field['default'] = $options[0]; } } $fields[] = $field; } return $fields; } /* ----------------------------------------------------------------- * WPForms — forms are a 'wpforms' custom post type, the field list is * plain JSON in post_content (wpforms_encode()/wpforms_decode() are * just json_encode()/json_decode() wrappers), confirmed by reading * wpforms-lite/includes/functions/forms.php and the field classes * under includes/fields/ directly. * --------------------------------------------------------------- */ public static function wpforms_forms() { if ( ! post_type_exists( 'wpforms' ) ) { return []; } $posts = get_posts( [ 'post_type' => 'wpforms', 'posts_per_page' => -1, 'post_status' => 'any', ] ); return array_map( fn( $p ) => [ 'id' => $p->ID, 'title' => $p->post_title ], $posts ); } public static function wpforms_import( $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'wpforms' !== $post->post_type ) { return new \WP_Error( 'chargeforms_migrator', __( 'That WPForms form could not be found.', 'chargeforms' ) ); } $decoded = json_decode( wp_unslash( $post->post_content ), true ); $raw = is_array( $decoded ) ? ( $decoded['fields'] ?? [] ) : []; $fields = []; foreach ( $raw as $wpf_field ) { $mapped = self::map_wpforms_field( $wpf_field ); if ( $mapped ) { $fields[] = $mapped; } } return Form::create( [ /* translators: %s: original form title */ 'title' => sprintf( __( '%s (from WPForms)', 'chargeforms' ), $post->post_title ), 'fields' => $fields, ] ); } private static function map_wpforms_field( array $wpf_field ) { $type_map = [ 'text' => 'text', 'textarea' => 'textarea', 'name' => 'name', 'email' => 'email', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'radio', 'number' => 'number', 'number-slider' => 'range_slider', 'phone' => 'phone', 'url' => 'url', 'password' => 'password', 'hidden' => 'hidden', 'html' => 'html', 'content' => 'html', 'divider' => 'section_break', 'pagebreak' => 'page_break', 'date-time' => 'date', 'address' => 'address', 'gdpr-checkbox' => 'gdpr', 'rating' => 'ratings', 'file-upload' => 'file', ]; $wp_type = $wpf_field['type'] ?? ''; $chargeforms_type = $type_map[ $wp_type ] ?? 'text'; $field = [ 'id' => 'f_' . substr( md5( uniqid( '', true ) ), 0, 8 ), 'type' => $chargeforms_type, 'label' => wp_strip_all_tags( (string) ( $wpf_field['label'] ?? __( 'Untitled Field', 'chargeforms' ) ) ), 'required' => ! empty( $wpf_field['required'] ), ]; if ( in_array( $chargeforms_type, [ 'select', 'radio', 'checkbox' ], true ) && ! empty( $wpf_field['choices'] ) && is_array( $wpf_field['choices'] ) ) { $options = []; foreach ( $wpf_field['choices'] as $choice ) { $label = is_array( $choice['label'] ?? null ) ? ( $choice['label']['text'] ?? '' ) : ( $choice['label'] ?? '' ); if ( '' !== $label ) { $options[] = wp_strip_all_tags( (string) $label ); } } $field['options'] = $options ?: [ __( 'Option 1', 'chargeforms' ), __( 'Option 2', 'chargeforms' ) ]; } if ( ! empty( $wpf_field['placeholder'] ) ) { $field['placeholder'] = wp_strip_all_tags( (string) $wpf_field['placeholder'] ); } if ( 'html' === $chargeforms_type && ! empty( $wpf_field['code'] ) ) { $field['content'] = wp_kses_post( $wpf_field['code'] ); } return $field; } /* ----------------------------------------------------------------- * Ninja Forms — its own custom tables: nf3_forms (id, title), nf3_fields * (id, label, type, parent_id = form id, required, default_value), and * nf3_field_meta (parent_id = field id, key, value — a WordPress * maybe_serialize()'d value, used for e.g. 'options' and 'placeholder'). * Confirmed directly from includes/Database/Migrations/{Fields, * FieldMeta,Forms}.php and the field-type slugs in includes/Fields/*.php. * --------------------------------------------------------------- */ private static function ninjaforms_forms_table() { global $wpdb; return $wpdb->prefix . 'nf3_forms'; } private static function ninjaforms_fields_table() { global $wpdb; return $wpdb->prefix . 'nf3_fields'; } private static function ninjaforms_field_meta_table() { global $wpdb; return $wpdb->prefix . 'nf3_field_meta'; } public static function ninjaforms_forms() { global $wpdb; if ( ! self::table_exists( 'nf3_forms' ) ) { return []; } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, title FROM %i ORDER BY id DESC', self::ninjaforms_forms_table() ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. return $rows ?: []; } public static function ninjaforms_import( $source_id ) { global $wpdb; $form = $wpdb->get_row( $wpdb->prepare( 'SELECT id, title FROM %i WHERE id = %d', self::ninjaforms_forms_table(), $source_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. if ( ! $form ) { return new \WP_Error( 'chargeforms_migrator', __( 'That Ninja Forms form could not be found.', 'chargeforms' ) ); } $nf_fields = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE parent_id = %d ORDER BY `order` ASC, id ASC', self::ninjaforms_fields_table(), $source_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. // Ninja Forms' submit button is its own row in nf3_fields — its // actual runtime type is 'submit' (verified against a real // installed copy's DB rows; Button.php's declared 'button' type // turned out to be a different, non-default field variant), which // otherwise silently turned "Submit" into a text field. $skip_types = [ 'submit', 'button' ]; $fields = []; foreach ( $nf_fields ?: [] as $nf_field ) { if ( in_array( $nf_field['type'] ?? '', $skip_types, true ) ) { continue; } $mapped = self::map_ninjaforms_field( $nf_field ); if ( $mapped ) { $fields[] = $mapped; } } return Form::create( [ /* translators: %s: original form title */ 'title' => sprintf( __( '%s (from Ninja Forms)', 'chargeforms' ), $form['title'] ), 'fields' => $fields, ] ); } private static function ninjaforms_field_meta( $field_id, $key ) { global $wpdb; $value = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration read of another plugin's table. 'SELECT value FROM %i WHERE parent_id = %d AND `key` = %s', self::ninjaforms_field_meta_table(), $field_id, $key ) ); return null === $value ? null : maybe_unserialize( $value ); } private static function map_ninjaforms_field( array $nf_field ) { $type_map = [ 'textbox' => 'text', 'textarea' => 'textarea', 'firstname' => 'name', 'lastname' => 'name', 'email' => 'email', 'listselect' => 'select', 'listcheckbox' => 'checkbox', 'listradio' => 'radio', 'listmultiselect' => 'multi_select', 'number' => 'number', 'phone' => 'phone', 'hidden' => 'hidden', 'html' => 'html', 'date' => 'date', 'password' => 'password', 'address' => 'address', 'listcountry' => 'country', ]; $nf_type = $nf_field['type'] ?? ''; $chargeforms_type = $type_map[ $nf_type ] ?? 'text'; $field = [ 'id' => 'f_' . substr( md5( uniqid( '', true ) ), 0, 8 ), 'type' => $chargeforms_type, 'label' => wp_strip_all_tags( (string) ( $nf_field['label'] ?? __( 'Untitled Field', 'chargeforms' ) ) ), 'required' => ! empty( $nf_field['required'] ), ]; if ( in_array( $chargeforms_type, [ 'select', 'radio', 'checkbox', 'multi_select' ], true ) ) { $raw_options = self::ninjaforms_field_meta( (int) $nf_field['id'], 'options' ); $options = []; if ( is_array( $raw_options ) ) { foreach ( $raw_options as $option ) { $label = is_array( $option ) ? ( $option['label'] ?? '' ) : ''; if ( '' !== $label ) { $options[] = wp_strip_all_tags( (string) $label ); } } } $field['options'] = $options ?: [ __( 'Option 1', 'chargeforms' ), __( 'Option 2', 'chargeforms' ) ]; } if ( ! empty( $nf_field['default_value'] ) ) { $field['default'] = wp_strip_all_tags( (string) $nf_field['default_value'] ); } $placeholder = self::ninjaforms_field_meta( (int) $nf_field['id'], 'placeholder' ); if ( $placeholder && is_string( $placeholder ) ) { $field['placeholder'] = wp_strip_all_tags( $placeholder ); } return $field; } }