diff --git a/allterrain-forms.php b/allterrain-forms.php index 40a01ad..2cda2f8 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -102,6 +102,35 @@ */ define( 'ATF_META_CONTEXT', '_atf_context' ); +/** + * Where an imported form came from: `{ importer, source }`. + * + * Recorded so the migration can be finished later. Importing the form is the + * urgent half and the half somebody does immediately; bringing the stored + * submissions across is the half they think of a week afterwards, by which time + * nothing else remembers which source form became which AllTerrain form. + */ +define( 'ATF_META_IMPORT_SOURCE', '_atf_import_source' ); + +/** + * Source field name => new field id, for an imported form. + * + * The importers already build this map to rewrite mail tags into merge tags; + * keeping it is what lets stored submissions be read afterwards, since every + * source keys its saved values by its own field names and nothing else can + * translate them. + */ +define( 'ATF_META_IMPORT_MAP', '_atf_import_map' ); + +/** + * The source record an imported entry came from: `{ importer, source }`. + * + * Makes importing entries repeatable: a second run skips what it already + * brought across instead of duplicating it, which matters because the natural + * response to a migration that looks incomplete is to run it again. + */ +define( 'ATF_META_ENTRY_SOURCE', '_atf_entry_source' ); + /** Design tokens for a user-created theme. */ define( 'ATF_META_TOKENS', '_atf_tokens' ); diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index a92393b..49e20bd 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -785,8 +785,8 @@ apply_filters( 'atf_importers', array $importers ); ``` The registry behind **Forms → Import**. Each entry is keyed by importer id and -holds four things: a `label`, and three callables — `available()` (does the -source plugin's data exist on this site), `forms()` (source id => title, for +holds four required things: a `label`, and three callables — `available()` (does +the source plugin's data exist on this site), `forms()` (source id => title, for the picker), and `import( $source_id )` (returns the new form id or a `WP_Error`). The built-in importers register through this same filter, exactly as a third-party source would. @@ -807,6 +807,69 @@ add_filter( 'atf_importers', function ( $importers ) { } ); ``` +#### Bringing the stored submissions too + +An importer may add two more callables. They are optional, and they come as a +pair: an importer that can count submissions but not import them would put a +number on screen with no button behind it, so offering only one of the two drops +both. All three built-in importers implement the pair — Contact Form 7 reads +the messages Flamingo kept, Gravity Forms reads its `gf_entry` tables, and +WPForms reads the entries table WPForms Pro writes (Lite stores nothing, so a +Lite site is accurately offered nothing). + +```php +'entries' => fn( $source_id, $form_id = 0 ) => 412, +'import_entries' => 'my_forms_import_entries', // ( $source_id, $form_id, $limit ) +``` + +- **`entries( $source_id, $form_id = 0 )`** — how many stored submissions the + source holds for that form. Given a `$form_id`, report what is *still waiting* + rather than the total, so the offer disappears when the migration is finished. +- **`import_entries( $source_id, $form_id, $limit )`** — brings at most `$limit` + of them across and returns `{ imported, skipped, done, remaining }`, or a + `WP_Error`. Called repeatedly until `done`; a form that has been live for years + is the normal case, not the exceptional one. + +Store each one with **`atf_import_entry( $form_id, $args )`**, which handles the +parts every source gets wrong the same way: + +```php +atf_import_entry( $form_id, array( + 'values' => array( 'your-name' => 'Elena Ruiz' ), // keyed by SOURCE field name + 'importer' => 'my-forms', + 'record' => '4182', // the source's id for this submission + 'submitted_at' => 1741080900, // unix; the entry keeps this date, not today's + 'spam' => false, + 'ip' => '203.0.113.7', + 'user_agent' => 'Mozilla/5.0 …', +) ); +``` + +Three things it guarantees, each of which is a way migrations normally go wrong: + +- **Values are sanitised but deliberately not validated.** Validation enforces + *today's* rules — required fields, choice whitelists, bounds — against answers + given years ago under a form that may since have lost the option somebody + picked. Anything unsafe is stripped; anything merely unexpected is kept, because + a migration that dropped it would be deleting the history it was asked to move. +- **The entry keeps its original date.** A history that all arrives today is not + a history. +- **Running it twice imports nothing twice.** Each entry records + `{ importer, record }`, and a second pass skips what the first brought across — + which matters, because the natural response to a migration that looks incomplete + is to run it again. + +Reading the values requires knowing which source field became which new field. +`atf_create_imported_form()` takes that map as its fifth argument and keeps it on +the form; pass it when you import, or the submissions are unreadable afterwards. +`atf_form_import_source()` and `atf_form_import_map()` read both back. + +> **Watch the post statuses.** Both sides of this migration hide records behind +> `exclude_from_search` — Flamingo's spam status on the way in, every AllTerrain +> entry status on the way out. A query using `post_status => 'any'` means "every +> status *not* excluded from search", so it silently omits all of them. Name the +> statuses explicitly. + ### `atf_imported_schema` — Filter — *Experimental* ```php @@ -845,6 +908,17 @@ Fires after a form is imported. The source plugin's data is never modified, so importing is safe to repeat — hook here if you need to record the mapping or redirect shortcodes. +### `atf_entries_imported` — Action — *Experimental* + +```php +do_action( 'atf_entries_imported', int $form_id, array $result, array $source ); +``` + +Fires after each pass of importing stored submissions. `$result` is +`{ imported, skipped, done, remaining }`; `$source` is `{ importer, source }`. +Fires once per pass, not once per migration, so a form imported in chunks fires +it several times — check `$result['done']` for the last one. + --- ## Themes diff --git a/includes/importer-cf7.php b/includes/importer-cf7.php index b88de1c..fa50b75 100644 --- a/includes/importer-cf7.php +++ b/includes/importer-cf7.php @@ -29,10 +29,12 @@ */ function atf_register_cf7_importer( $importers ) { $importers['contact-form-7'] = array( - 'label' => __( 'Contact Form 7', 'allterrain-forms' ), - 'available' => 'atf_cf7_available', - 'forms' => 'atf_cf7_forms', - 'import' => 'atf_cf7_import', + 'label' => __( 'Contact Form 7', 'allterrain-forms' ), + 'available' => 'atf_cf7_available', + 'forms' => 'atf_cf7_forms', + 'import' => 'atf_cf7_import', + 'entries' => 'atf_cf7_entry_count', + 'import_entries' => 'atf_cf7_import_entries', ); return $importers; @@ -98,14 +100,259 @@ function atf_cf7_import( $source_id ) { return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); } + $template = (string) get_post_meta( $post->ID, '_form', true ); + $schema = atf_cf7_convert( - (string) get_post_meta( $post->ID, '_form', true ), + $template, (array) get_post_meta( $post->ID, '_mail', true ), (array) get_post_meta( $post->ID, '_mail_2', true ), (array) get_post_meta( $post->ID, '_messages', true ) ); - return atf_create_imported_form( $post->post_title, $schema, 'contact-form-7', (string) $post->ID ); + // Parsed a second time for its name => id map alone. The template is a few + // hundred bytes and this happens once per form ever imported; thread the map + // out of atf_cf7_convert() and every caller of it has to care about it. + $parsed = atf_cf7_parse_template( $template ); + + return atf_create_imported_form( + $post->post_title, + $schema, + 'contact-form-7', + (string) $post->ID, + $parsed['map'] + ); +} + +/** + * The Flamingo channel term id a CF7 form's messages are filed under. + * + * CF7 caches it in the form's own `_flamingo` meta on the first submission; when + * that is absent the term is looked up by slug, which is what CF7 named it after. + * + * @param WP_Post $form The CF7 form post. + * @return int Term id, or 0 when the form has no channel. + */ +function atf_cf7_channel_id( $form ) { + $meta = get_post_meta( $form->ID, '_flamingo', true ); + + if ( is_array( $meta ) && ! empty( $meta['channel'] ) ) { + return (int) $meta['channel']; + } + + $term = get_term_by( 'slug', $form->post_name, 'flamingo_inbound_channel' ); + + return $term && ! is_wp_error( $term ) ? (int) $term->term_id : 0; +} + +/** + * The post statuses a stored message worth importing can be in. + * + * Trash is excluded: a trashed message was thrown away on purpose, and a + * migration that resurrected it would undo a decision somebody made. + * + * Note that `'any'` cannot be used against these records. Flamingo registers its + * spam status with `exclude_from_search`, and `'any'` means "every status not + * excluded from search" — so `'any'` silently omits every spam message. + * + * @return string[] + */ +function atf_cf7_message_statuses() { + return array( 'publish', 'flamingo-spam' ); +} + +/** + * How many stored submissions a CF7 form has that have not been imported yet. + * + * Read straight from the posts table rather than through Flamingo, for the same + * reason the forms are: the moment somebody migrates is the moment the old + * plugins are being switched off, and a deactivated plugin registers no post + * type for `WP_Query` to find. + * + * @param string $source_id The CF7 post id. + * @param int $form_id The AllTerrain form the messages would land on. + * @return int + */ +function atf_cf7_entry_count( $source_id, $form_id = 0 ) { + global $wpdb; + + $form = get_post( absint( $source_id ) ); + + if ( ! $form || 'wpcf7_contact_form' !== $form->post_type ) { + return 0; + } + + $channel_id = atf_cf7_channel_id( $form ); + + if ( ! $channel_id ) { + return 0; + } + + $statuses = atf_cf7_message_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a generated list of %s. + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id + WHERE p.post_type = 'flamingo_inbound' + AND p.post_status IN ( {$placeholders} ) + AND tt.taxonomy = 'flamingo_inbound_channel' + AND tt.term_id = %d", + array_merge( $statuses, array( $channel_id ) ) + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( ! $form_id ) { + return $total; + } + + return max( 0, $total - count( atf_imported_entry_keys( (int) $form_id ) ) ); +} + +/** + * Brings a slice of a CF7 form's stored submissions across as entries. + * + * Oldest first, so an interrupted migration leaves a contiguous history rather + * than a scatter, and so a second pass resumes where the first stopped. + * + * @param string $source_id The CF7 post id. + * @param int $form_id The AllTerrain form to import onto. + * @param int $limit How many to attempt in this pass. + * @return array|WP_Error { imported, skipped, done, remaining }. + */ +function atf_cf7_import_entries( $source_id, $form_id, $limit = 100 ) { + global $wpdb; + + $form = get_post( absint( $source_id ) ); + + if ( ! $form || 'wpcf7_contact_form' !== $form->post_type ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + $channel_id = atf_cf7_channel_id( $form ); + + if ( ! $channel_id ) { + return array( + 'imported' => 0, + 'skipped' => 0, + 'done' => true, + 'remaining' => 0, + ); + } + + $seen = atf_imported_entry_keys( (int) $form_id ); + $statuses = atf_cf7_message_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); + + // Fetched a page at a time, and the page is larger than the limit because + // records already imported are skipped without costing a slot -- otherwise a + // second pass would spend its whole budget rediscovering the first pass. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a generated list of %s. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT p.ID, p.post_status, p.post_date_gmt FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id + WHERE p.post_type = 'flamingo_inbound' + AND p.post_status IN ( {$placeholders} ) + AND tt.taxonomy = 'flamingo_inbound_channel' + AND tt.term_id = %d + ORDER BY p.post_date_gmt ASC, p.ID ASC + LIMIT %d", + array_merge( $statuses, array( $channel_id, count( $seen ) + (int) $limit ) ) + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $imported = 0; + $skipped = 0; + + foreach ( (array) $rows as $row ) { + if ( $imported >= (int) $limit ) { + break; + } + + if ( isset( $seen[ atf_entry_source_key( 'contact-form-7', $row->ID ) ] ) ) { + ++$skipped; + continue; + } + + $result = atf_import_entry( + (int) $form_id, + array( + 'values' => atf_cf7_message_values( (int) $row->ID ), + 'importer' => 'contact-form-7', + 'record' => (string) $row->ID, + 'submitted_at' => (int) strtotime( $row->post_date_gmt . ' UTC' ), + 'spam' => 'flamingo-spam' === $row->post_status, + 'ip' => atf_cf7_message_meta( (int) $row->ID, 'remote_ip' ), + 'user_agent' => atf_cf7_message_meta( (int) $row->ID, 'user_agent' ), + ) + ); + + if ( is_wp_error( $result ) ) { + // A single unreadable record must not strand the rest of the + // migration, but an error that would repeat on every one of them -- + // a missing schema or field map -- is worth stopping for. + if ( in_array( $result->get_error_code(), array( 'atf_no_schema', 'atf_no_import_map' ), true ) ) { + return $result; + } + + ++$skipped; + continue; + } + + ++$imported; + } + + $remaining = atf_cf7_entry_count( $source_id, (int) $form_id ); + + return array( + 'imported' => $imported, + 'skipped' => $skipped, + 'done' => 0 === $remaining, + 'remaining' => $remaining, + ); +} + +/** + * The submitted values of one stored message, keyed by CF7 field name. + * + * Read from the per-field `_field_` rows rather than the `_fields` array, + * because Flamingo nulls every value inside `_fields` as it writes the per-field + * rows — the array survives as a list of names with nothing in it. + * + * @param int $message_id The `flamingo_inbound` post id. + * @return array Field name => value. + */ +function atf_cf7_message_values( $message_id ) { + $values = array(); + + foreach ( get_post_meta( $message_id ) as $key => $raw ) { + if ( 0 !== strpos( $key, '_field_' ) ) { + continue; + } + + $values[ substr( $key, 7 ) ] = maybe_unserialize( $raw[0] ); + } + + return $values; +} + +/** + * One of the special mail tags CF7 stores alongside a message. + * + * @param int $message_id The `flamingo_inbound` post id. + * @param string $key The tag name, e.g. `remote_ip`. + * @return string + */ +function atf_cf7_message_meta( $message_id, $key ) { + $meta = get_post_meta( $message_id, '_meta', true ); + + return is_array( $meta ) && isset( $meta[ $key ] ) ? (string) $meta[ $key ] : ''; } /** diff --git a/includes/importer-gravityforms.php b/includes/importer-gravityforms.php index 32a0570..67dc17d 100644 --- a/includes/importer-gravityforms.php +++ b/includes/importer-gravityforms.php @@ -28,10 +28,12 @@ */ function atf_register_gravityforms_importer( $importers ) { $importers['gravityforms'] = array( - 'label' => __( 'Gravity Forms', 'allterrain-forms' ), - 'available' => 'atf_gf_available', - 'forms' => 'atf_gf_forms', - 'import' => 'atf_gf_import', + 'label' => __( 'Gravity Forms', 'allterrain-forms' ), + 'available' => 'atf_gf_available', + 'forms' => 'atf_gf_forms', + 'import' => 'atf_gf_import', + 'entries' => 'atf_gf_entry_count', + 'import_entries' => 'atf_gf_import_entries', ); return $importers; @@ -128,7 +130,7 @@ function atf_gf_import( $source_id ) { $title = '' !== (string) $row->title ? (string) $row->title : ( isset( $display['title'] ) ? (string) $display['title'] : '' ); - return atf_create_imported_form( $title, $schema, 'gravityforms', (string) $source_id ); + return atf_create_imported_form( $title, $schema, 'gravityforms', (string) $source_id, atf_gf_map( $display ) ); } /** @@ -670,3 +672,468 @@ static function ( $found ) use ( $map ) { $text ); } + +/** + * The Gravity field id => new field id map for one form, recomputed. + * + * The same two passes `atf_gf_convert()` runs, minus building the schema — + * minting has to happen for every field before dropping any, or the ids would + * shift and stop matching the ones the conversion minted. Recomputed rather + * than threaded out of the converter for the same reason the CF7 importer + * re-parses its template: the source is a few kilobytes read once per form + * ever imported, and every caller of the converter stays oblivious. + * + * @since 0.3.0 + * + * @param array $display The decoded `display_meta`. + * @return array Gravity field id => new field id. + */ +function atf_gf_map( $display ) { + $source_fields = isset( $display['fields'] ) && is_array( $display['fields'] ) ? $display['fields'] : array(); + + $map = array(); + $next = 1; + + foreach ( $source_fields as $source ) { + if ( is_array( $source ) && isset( $source['id'] ) ) { + $map[ (string) $source['id'] ] = 'f' . $next; + ++$next; + } + } + + foreach ( $source_fields as $source ) { + if ( ! is_array( $source ) || ! isset( $source['id'] ) ) { + continue; + } + + if ( ! atf_gf_field( $source, $map[ (string) $source['id'] ], $map ) ) { + unset( $map[ (string) $source['id'] ] ); + } + } + + return $map; +} + +/** + * Whether Gravity Forms' entry tables exist on this site. + * + * Checked separately from `atf_gf_available()` because the two can differ: a + * site restored from a partial backup, or one that ran a very old Gravity + * Forms, can hold the form tables without the entry tables. + * + * @since 0.3.0 + * + * @return bool + */ +function atf_gf_entries_available() { + global $wpdb; + + $table = $wpdb->prefix . 'gf_entry'; + + return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- There is no API for another plugin's table, and a SHOW TABLES result is not worth caching. +} + +/** + * The statuses a Gravity entry worth importing can be in. + * + * Trash is excluded here as everywhere in this migration: a trashed entry was + * thrown away on purpose, and resurrecting it would undo a decision somebody + * made. + * + * @since 0.3.0 + * + * @return string[] + */ +function atf_gf_entry_statuses() { + return array( 'active', 'spam' ); +} + +/** + * How many stored Gravity entries a form has that have not been imported yet. + * + * @since 0.3.0 + * + * @param string $source_id The Gravity form id. + * @param int $form_id The AllTerrain form the entries would land on. + * @return int + */ +function atf_gf_entry_count( $source_id, $form_id = 0 ) { + global $wpdb; + + $source_id = absint( $source_id ); + + if ( ! $source_id || ! atf_gf_entries_available() ) { + return 0; + } + + $statuses = atf_gf_entry_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Another plugin's table; $placeholders is a generated list of %s. + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}gf_entry WHERE form_id = %d AND status IN ( {$placeholders} )", + array_merge( array( $source_id ), $statuses ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( ! $form_id ) { + return $total; + } + + return max( 0, $total - count( atf_imported_entry_keys( (int) $form_id ) ) ); +} + +/** + * Brings a slice of a Gravity form's stored entries across. + * + * Oldest first, so an interrupted migration leaves a contiguous history and a + * second pass resumes where the first stopped. + * + * @since 0.3.0 + * + * @param string $source_id The Gravity form id. + * @param int $form_id The AllTerrain form to import onto. + * @param int $limit How many to attempt in this pass. + * @return array|WP_Error { imported, skipped, done, remaining }. + */ +function atf_gf_import_entries( $source_id, $form_id, $limit = 100 ) { + global $wpdb; + + $source_id = absint( $source_id ); + $form_id = (int) $form_id; + + if ( ! $source_id || ! atf_gf_available() ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + if ( ! atf_gf_entries_available() ) { + return array( + 'imported' => 0, + 'skipped' => 0, + 'done' => true, + 'remaining' => 0, + ); + } + + $fields = atf_gf_entry_fields( $form_id ); + + if ( is_wp_error( $fields ) ) { + return $fields; + } + + $seen = atf_imported_entry_keys( $form_id ); + $statuses = atf_gf_entry_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); + + // The page is larger than the limit because records already imported are + // skipped without costing a slot -- otherwise a second pass would spend its + // whole budget rediscovering the first pass. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Another plugin's table; $placeholders is a generated list of %s. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, status, date_created, ip, user_agent FROM {$wpdb->prefix}gf_entry + WHERE form_id = %d AND status IN ( {$placeholders} ) + ORDER BY date_created ASC, id ASC + LIMIT %d", + array_merge( array( $source_id ), $statuses, array( count( $seen ) + (int) $limit ) ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $imported = 0; + $skipped = 0; + + foreach ( (array) $rows as $row ) { + if ( $imported >= (int) $limit ) { + break; + } + + if ( isset( $seen[ atf_entry_source_key( 'gravityforms', $row->id ) ] ) ) { + ++$skipped; + continue; + } + + $result = atf_import_entry( + $form_id, + array( + 'values' => atf_gf_entry_values( (int) $row->id, $fields ), + 'importer' => 'gravityforms', + 'record' => (string) $row->id, + 'submitted_at' => (int) strtotime( $row->date_created . ' UTC' ), + 'spam' => 'spam' === $row->status, + 'ip' => (string) $row->ip, + 'user_agent' => (string) $row->user_agent, + ) + ); + + if ( is_wp_error( $result ) ) { + // A single unreadable record must not strand the rest of the + // migration, but an error that would repeat on every one of them + // is worth stopping for. + if ( in_array( $result->get_error_code(), array( 'atf_no_schema', 'atf_no_import_map' ), true ) ) { + return $result; + } + + ++$skipped; + continue; + } + + ++$imported; + } + + $remaining = atf_gf_entry_count( (string) $source_id, $form_id ); + + return array( + 'imported' => $imported, + 'skipped' => $skipped, + 'done' => 0 === $remaining, + 'remaining' => $remaining, + ); +} + +/** + * Gravity field id => the imported field it became, for one AllTerrain form. + * + * The entry reader needs the *target* field's type to know what shape to + * assemble — the same dotted meta keys hold a name's parts on one field and a + * checkbox group's picks on another, and only the destination can say which. + * + * @since 0.3.0 + * + * @param int $form_id The AllTerrain form. + * @return array|WP_Error Gravity field id => normalised field. + */ +function atf_gf_entry_fields( $form_id ) { + $schema = atf_get_form_schema( $form_id ); + + if ( ! $schema ) { + return new WP_Error( 'atf_no_schema', __( 'That form has no schema.', 'allterrain-forms' ) ); + } + + $map = atf_form_import_map( $form_id ); + + if ( ! $map ) { + return new WP_Error( + 'atf_no_import_map', + __( 'That form has no field map, so its stored submissions cannot be read. Import the form again to record one.', 'allterrain-forms' ) + ); + } + + $by_id = array(); + + foreach ( atf_input_fields( $schema ) as $field ) { + $by_id[ $field['id'] ] = $field; + } + + $fields = array(); + + foreach ( $map as $source_id => $field_id ) { + if ( isset( $by_id[ $field_id ] ) ) { + $fields[ (string) $source_id ] = $by_id[ $field_id ]; + } + } + + return $fields; +} + +/** + * The submitted values of one Gravity entry, keyed by Gravity field id. + * + * Gravity keys its entry meta by *input* id: `5` for a single-input field, and + * `5.3`, `5.6` for the inputs of a multi-input one — where the same dotted + * shape means name parts on a name field, address lines on an address field + * and one row per ticked box on a checkbox group. The target field's type is + * what disambiguates, which is why the map of converted fields rides along. + * + * @since 0.3.0 + * + * @param int $entry_id The Gravity entry id. + * @param array $fields Gravity field id => the imported field it became. + * @return array Gravity field id => value, shaped for the target field. + */ +function atf_gf_entry_values( $entry_id, $fields ) { + global $wpdb; + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Another plugin's table; read once per imported entry. + $wpdb->prepare( + "SELECT meta_key, meta_value FROM {$wpdb->prefix}gf_entry_meta WHERE entry_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the prefix is interpolated. + absint( $entry_id ) + ) + ); + + // One bucket per Gravity field: `single` for an undotted key, `subs` for + // the dotted ones, kept in input order so positional assembly is stable. + $buckets = array(); + + foreach ( (array) $rows as $row ) { + $key = (string) $row->meta_key; + $dot = strpos( $key, '.' ); + + if ( false === $dot ) { + $buckets[ $key ]['single'] = (string) $row->meta_value; + continue; + } + + $root = substr( $key, 0, $dot ); + + $buckets[ $root ]['subs'][ substr( $key, $dot + 1 ) ] = (string) $row->meta_value; + } + + $values = array(); + + foreach ( $fields as $source_id => $field ) { + if ( ! isset( $buckets[ $source_id ] ) ) { + continue; + } + + $value = atf_gf_entry_value( $buckets[ $source_id ], $field ); + + if ( null !== $value ) { + $values[ $source_id ] = $value; + } + } + + return $values; +} + +/** + * One Gravity entry value, assembled into the shape its new field stores. + * + * @since 0.3.0 + * + * @param array $bucket { single?: string, subs?: array }. + * @param array $field The imported field the value now belongs to. + * @return mixed The assembled value, or null when the field holds nothing here. + */ +function atf_gf_entry_value( $bucket, $field ) { + $single = isset( $bucket['single'] ) ? $bucket['single'] : ''; + $subs = isset( $bucket['subs'] ) ? $bucket['subs'] : array(); + + switch ( $field['type'] ) { + case 'name': + // Gravity numbers name inputs `{field}.{part}`, and the sub-numbers + // are stable across every install -- the same table + // `atf_gf_name_parts()` reads when the form is converted. + $parts = array( + '2' => 'prefix', + '3' => 'first', + '4' => 'middle', + '6' => 'last', + '8' => 'suffix', + ); + + return atf_gf_entry_parts( $subs, $parts ); + + case 'address': + $parts = array( + '1' => 'line1', + '2' => 'line2', + '3' => 'city', + '4' => 'region', + '5' => 'postcode', + '6' => 'country', + ); + + return atf_gf_entry_parts( $subs, $parts ); + + case 'checkboxes': + // One dotted row per ticked box, holding the choice's value. An + // unticked box usually writes nothing, but old exports have been + // seen to write empty strings -- either way, absent means unticked. + if ( $subs ) { + uksort( $subs, 'strnatcmp' ); + + return array_values( array_filter( $subs, 'strlen' ) ); + } + + return '' !== $single ? array( $single ) : null; + + case 'multiselect': + // Stored as a JSON list since Gravity 2.2, comma-joined before it. + if ( '' === $single ) { + return null; + } + + if ( '[' === $single[0] ) { + $decoded = json_decode( $single, true ); + + if ( is_array( $decoded ) ) { + return array_map( 'strval', $decoded ); + } + } + + return array_map( 'trim', explode( ',', $single ) ); + + case 'repeater': + // A Gravity List survives as a serialised array: strings for a + // single-column list, one array per row for a multi-column one. + // Rows map onto the repeater's sub-fields positionally, because the + // sub-fields were minted from the columns in order. + $list = maybe_unserialize( $single ); + + if ( ! is_array( $list ) ) { + return null; + } + + $sub_ids = array(); + + foreach ( isset( $field['fields'] ) && is_array( $field['fields'] ) ? $field['fields'] : array() as $sub ) { + if ( isset( $sub['id'] ) ) { + $sub_ids[] = (string) $sub['id']; + } + } + + if ( ! $sub_ids ) { + return null; + } + + $rows = array(); + + foreach ( $list as $row ) { + $row_values = is_array( $row ) ? array_values( $row ) : array( $row ); + $assembled = array(); + + foreach ( $sub_ids as $index => $sub_id ) { + $assembled[ $sub_id ] = isset( $row_values[ $index ] ) && is_scalar( $row_values[ $index ] ) + ? (string) $row_values[ $index ] + : ''; + } + + $rows[] = $assembled; + } + + return $rows; + + case 'file': + // Gravity stores upload URLs; entries here hold attachment ids. A + // URL on another plugin's disk is not an attachment, and inventing + // one would put a broken id in the entry -- the honest mapping is + // none, same as the sanitiser would enforce anyway. + return null; + } + + return '' !== $single ? $single : null; +} + +/** + * Dotted sub-values as a composite's parts. + * + * @since 0.3.0 + * + * @param array $subs Sub-number => stored value. + * @param array $parts Sub-number => part key. + * @return array|null Part key => value, or null when every part is empty. + */ +function atf_gf_entry_parts( $subs, $parts ) { + $value = array(); + + foreach ( $parts as $sub => $part ) { + if ( isset( $subs[ $sub ] ) && '' !== $subs[ $sub ] ) { + $value[ $part ] = $subs[ $sub ]; + } + } + + return $value ? $value : null; +} diff --git a/includes/importer-wpforms.php b/includes/importer-wpforms.php index 4e29e1c..c0a30b1 100644 --- a/includes/importer-wpforms.php +++ b/includes/importer-wpforms.php @@ -28,10 +28,12 @@ */ function atf_register_wpforms_importer( $importers ) { $importers['wpforms'] = array( - 'label' => __( 'WPForms', 'allterrain-forms' ), - 'available' => 'atf_wpforms_available', - 'forms' => 'atf_wpforms_forms', - 'import' => 'atf_wpforms_import', + 'label' => __( 'WPForms', 'allterrain-forms' ), + 'available' => 'atf_wpforms_available', + 'forms' => 'atf_wpforms_forms', + 'import' => 'atf_wpforms_import', + 'entries' => 'atf_wpforms_entry_count', + 'import_entries' => 'atf_wpforms_import_entries', ); return $importers; @@ -103,7 +105,7 @@ function atf_wpforms_import( $source_id ) { ? $post->post_title : ( isset( $data['settings']['form_title'] ) ? (string) $data['settings']['form_title'] : '' ); - return atf_create_imported_form( $title, $schema, 'wpforms', (string) $post->ID ); + return atf_create_imported_form( $title, $schema, 'wpforms', (string) $post->ID, atf_wpforms_map( $data ) ); } /** @@ -634,3 +636,389 @@ static function ( $found ) use ( $map ) { $text ); } + +/** + * The WPForms field id => new field id map for one form, recomputed. + * + * The same two passes `atf_wpforms_convert()` runs, minus building the schema: + * every field is minted an id first, then the ones with no equivalent are + * dropped — in that order, or the ids would shift and stop matching the ones + * the conversion minted. Recomputed rather than threaded out of the converter + * for the same reason the CF7 importer re-parses its template: the source is a + * few kilobytes read once per form ever imported. + * + * @since 0.3.0 + * + * @param array $data The decoded `post_content`. + * @return array WPForms field id => new field id. + */ +function atf_wpforms_map( $data ) { + $source_fields = isset( $data['fields'] ) && is_array( $data['fields'] ) ? $data['fields'] : array(); + + $map = array(); + $next = 1; + + foreach ( $source_fields as $source ) { + if ( is_array( $source ) && isset( $source['id'] ) ) { + $map[ (string) $source['id'] ] = 'f' . ( $next + count( $map ) ); + } + } + + foreach ( $source_fields as $source ) { + if ( ! is_array( $source ) || ! isset( $source['id'] ) ) { + continue; + } + + if ( ! atf_wpforms_field( $source, $map[ (string) $source['id'] ], $map ) ) { + unset( $map[ (string) $source['id'] ] ); + } + } + + return $map; +} + +/** + * Whether WPForms' entries table exists on this site. + * + * Only WPForms Pro stores entries at all — Lite mails them and keeps nothing — + * so on a Lite site this is simply false and the Import page never mentions + * entries, which is accurate rather than disappointing: there is nothing to + * bring. + * + * @since 0.3.0 + * + * @return bool + */ +function atf_wpforms_entries_available() { + global $wpdb; + + $table = $wpdb->prefix . 'wpforms_entries'; + + return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- There is no API for another plugin's table, and a SHOW TABLES result is not worth caching. +} + +/** + * The statuses a WPForms entry worth importing is NOT in. + * + * An exclusion list rather than a whitelist, because WPForms writes several + * respectable statuses — the empty string for an ordinary entry, and payment + * states like `completed` — and a whitelist would silently drop every paid + * order. What is excluded is deliberate: `trash` was thrown away on purpose, + * and `partial` / `abandoned` are half-typed forms nobody ever sent. + * + * @since 0.3.0 + * + * @return string[] + */ +function atf_wpforms_entry_excluded_statuses() { + return array( 'trash', 'partial', 'abandoned' ); +} + +/** + * How many stored WPForms entries a form has that have not been imported yet. + * + * @since 0.3.0 + * + * @param string $source_id The WPForms post id. + * @param int $form_id The AllTerrain form the entries would land on. + * @return int + */ +function atf_wpforms_entry_count( $source_id, $form_id = 0 ) { + global $wpdb; + + $source_id = absint( $source_id ); + + if ( ! $source_id || ! atf_wpforms_entries_available() ) { + return 0; + } + + $excluded = atf_wpforms_entry_excluded_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $excluded ), '%s' ) ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Another plugin's table; $placeholders is a generated list of %s. + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}wpforms_entries WHERE form_id = %d AND status NOT IN ( {$placeholders} )", + array_merge( array( $source_id ), $excluded ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( ! $form_id ) { + return $total; + } + + return max( 0, $total - count( atf_imported_entry_keys( (int) $form_id ) ) ); +} + +/** + * Brings a slice of a WPForms form's stored entries across. + * + * Oldest first, so an interrupted migration leaves a contiguous history and a + * second pass resumes where the first stopped. + * + * @since 0.3.0 + * + * @param string $source_id The WPForms post id. + * @param int $form_id The AllTerrain form to import onto. + * @param int $limit How many to attempt in this pass. + * @return array|WP_Error { imported, skipped, done, remaining }. + */ +function atf_wpforms_import_entries( $source_id, $form_id, $limit = 100 ) { + global $wpdb; + + $source_id = absint( $source_id ); + $form_id = (int) $form_id; + + if ( ! $source_id ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + if ( ! atf_wpforms_entries_available() ) { + return array( + 'imported' => 0, + 'skipped' => 0, + 'done' => true, + 'remaining' => 0, + ); + } + + $fields = atf_wpforms_entry_fields( $form_id ); + + if ( is_wp_error( $fields ) ) { + return $fields; + } + + $seen = atf_imported_entry_keys( $form_id ); + $excluded = atf_wpforms_entry_excluded_statuses(); + $placeholders = implode( ', ', array_fill( 0, count( $excluded ), '%s' ) ); + + // The page is larger than the limit because records already imported are + // skipped without costing a slot -- otherwise a second pass would spend its + // whole budget rediscovering the first pass. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Another plugin's table; $placeholders is a generated list of %s. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT entry_id, status, fields, date, ip_address, user_agent FROM {$wpdb->prefix}wpforms_entries + WHERE form_id = %d AND status NOT IN ( {$placeholders} ) + ORDER BY date ASC, entry_id ASC + LIMIT %d", + array_merge( array( $source_id ), $excluded, array( count( $seen ) + (int) $limit ) ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $imported = 0; + $skipped = 0; + + foreach ( (array) $rows as $row ) { + if ( $imported >= (int) $limit ) { + break; + } + + if ( isset( $seen[ atf_entry_source_key( 'wpforms', $row->entry_id ) ] ) ) { + ++$skipped; + continue; + } + + $result = atf_import_entry( + $form_id, + array( + 'values' => atf_wpforms_entry_values( (string) $row->fields, $fields ), + 'importer' => 'wpforms', + 'record' => (string) $row->entry_id, + 'submitted_at' => (int) strtotime( $row->date . ' UTC' ), + 'spam' => 'spam' === $row->status, + 'ip' => (string) $row->ip_address, + 'user_agent' => (string) $row->user_agent, + ) + ); + + if ( is_wp_error( $result ) ) { + // A single unreadable record must not strand the rest of the + // migration, but an error that would repeat on every one of them + // is worth stopping for. + if ( in_array( $result->get_error_code(), array( 'atf_no_schema', 'atf_no_import_map' ), true ) ) { + return $result; + } + + ++$skipped; + continue; + } + + ++$imported; + } + + $remaining = atf_wpforms_entry_count( (string) $source_id, $form_id ); + + return array( + 'imported' => $imported, + 'skipped' => $skipped, + 'done' => 0 === $remaining, + 'remaining' => $remaining, + ); +} + +/** + * WPForms field id => the imported field it became, for one AllTerrain form. + * + * The entry reader needs the *target* field's type to know what to read out of + * each stored field object — a name keeps its parts in sibling keys, a checkbox + * group newline-joins its picks into `value` — and only the destination type + * says which reading applies. + * + * @since 0.3.0 + * + * @param int $form_id The AllTerrain form. + * @return array|WP_Error WPForms field id => normalised field. + */ +function atf_wpforms_entry_fields( $form_id ) { + $schema = atf_get_form_schema( $form_id ); + + if ( ! $schema ) { + return new WP_Error( 'atf_no_schema', __( 'That form has no schema.', 'allterrain-forms' ) ); + } + + $map = atf_form_import_map( $form_id ); + + if ( ! $map ) { + return new WP_Error( + 'atf_no_import_map', + __( 'That form has no field map, so its stored submissions cannot be read. Import the form again to record one.', 'allterrain-forms' ) + ); + } + + $by_id = array(); + + foreach ( atf_input_fields( $schema ) as $field ) { + $by_id[ $field['id'] ] = $field; + } + + $fields = array(); + + foreach ( $map as $source_id => $field_id ) { + if ( isset( $by_id[ $field_id ] ) ) { + $fields[ (string) $source_id ] = $by_id[ $field_id ]; + } + } + + return $fields; +} + +/** + * The submitted values of one WPForms entry, keyed by WPForms field id. + * + * A WPForms entry's `fields` column is one JSON document: each answer is an + * object holding at least `value`, with composites keeping their parts in + * sibling keys — `first` / `last` on a name, `address1` / `city` / `postal` on + * an address — and choice groups newline-joining their picks into `value`. + * + * @since 0.3.0 + * + * @param string $stored The entry's `fields` column, still JSON. + * @param array $fields WPForms field id => the imported field it became. + * @return array WPForms field id => value, shaped for the target field. + */ +function atf_wpforms_entry_values( $stored, $fields ) { + $decoded = json_decode( (string) $stored, true ); + + if ( ! is_array( $decoded ) ) { + return array(); + } + + $values = array(); + + foreach ( $fields as $source_id => $field ) { + if ( ! isset( $decoded[ $source_id ] ) || ! is_array( $decoded[ $source_id ] ) ) { + continue; + } + + $value = atf_wpforms_entry_value( $decoded[ $source_id ], $field ); + + if ( null !== $value ) { + $values[ $source_id ] = $value; + } + } + + return $values; +} + +/** + * One WPForms entry value, assembled into the shape its new field stores. + * + * @since 0.3.0 + * + * @param array $answer The stored field object. + * @param array $field The imported field the value now belongs to. + * @return mixed The assembled value, or null when the field holds nothing here. + */ +function atf_wpforms_entry_value( $answer, $field ) { + switch ( $field['type'] ) { + case 'name': + $parts = array( + 'first' => 'first', + 'middle' => 'middle', + 'last' => 'last', + ); + + $value = array(); + + foreach ( $parts as $theirs => $ours ) { + if ( isset( $answer[ $theirs ] ) && is_scalar( $answer[ $theirs ] ) && '' !== (string) $answer[ $theirs ] ) { + $value[ $ours ] = (string) $answer[ $theirs ]; + } + } + + return $value ? $value : null; + + case 'address': + $parts = array( + 'address1' => 'line1', + 'address2' => 'line2', + 'city' => 'city', + 'state' => 'region', + 'postal' => 'postcode', + 'country' => 'country', + ); + + $value = array(); + + foreach ( $parts as $theirs => $ours ) { + if ( isset( $answer[ $theirs ] ) && is_scalar( $answer[ $theirs ] ) && '' !== (string) $answer[ $theirs ] ) { + $value[ $ours ] = (string) $answer[ $theirs ]; + } + } + + return $value ? $value : null; + + case 'checkboxes': + case 'multiselect': + // Picks arrive newline-joined in `value`, in the order they were + // ticked. The stored strings are the choice *labels* -- WPForms + // records what the visitor saw -- and the sanitiser keeps whatever + // arrives, so an import matches what WPForms itself displayed. + $raw = isset( $answer['value'] ) && is_scalar( $answer['value'] ) ? (string) $answer['value'] : ''; + + if ( '' === $raw ) { + return null; + } + + return array_values( array_filter( array_map( 'trim', explode( "\n", $raw ) ), 'strlen' ) ); + + case 'file': + // WPForms stores upload URLs; entries here hold attachment ids, and + // a URL on another plugin's disk is not an attachment. The honest + // mapping is none -- same as the sanitiser would enforce anyway. + return null; + + case 'likert': + // WPForms Pro's Likert storage is not a shape this reader can pin + // down without the plugin present, and a guessed parse would write + // wrong answers where blank ones are at least visibly blank. + return null; + } + + $raw = isset( $answer['value'] ) && is_scalar( $answer['value'] ) ? (string) $answer['value'] : ''; + + return '' !== $raw ? $raw : null; +} diff --git a/includes/importers.php b/includes/importers.php index cdb5595..3a62a83 100644 --- a/includes/importers.php +++ b/includes/importers.php @@ -28,11 +28,16 @@ * @since 0.2.0 * * @return array[] Importer id => { - * @type string $label Human name of the source plugin. - * @type callable $available Whether any of the source's data exists. - * @type callable $forms Source form id => title, for the picker. - * @type callable $import Imports one source form; returns the new - * form id or a WP_Error. + * @type string $label Human name of the source plugin. + * @type callable $available Whether any of the source's data exists. + * @type callable $forms Source form id => title, for the picker. + * @type callable $import Imports one source form; returns the new + * form id or a WP_Error. + * @type callable $entries Optional. How many stored submissions the + * source holds for one source form. + * @type callable $import_entries Optional. Imports a slice of them onto an + * AllTerrain form; returns { imported, + * skipped, done } or a WP_Error. * } */ function atf_importers() { @@ -61,6 +66,22 @@ function atf_importers() { continue; } + // Entry migration is optional, and half of it is useless: an importer + // that can count stored submissions but not import them would offer a + // number with no button behind it, and one that can import without + // counting cannot be asked how much work it has. Either both callbacks + // are present and callable, or the importer simply does forms. + foreach ( array( 'entries', 'import_entries' ) as $key ) { + if ( isset( $importer[ $key ] ) && ! is_callable( $importer[ $key ] ) ) { + unset( $importer['entries'], $importer['import_entries'] ); + break; + } + } + + if ( ! isset( $importer['entries'], $importer['import_entries'] ) ) { + unset( $importer['entries'], $importer['import_entries'] ); + } + $valid[ $id ] = $importer; } @@ -136,9 +157,13 @@ function atf_import_source_form( $importer_id, $source_id ) { * @param array $schema The converted schema, pre-normalisation. * @param string $importer_id Which importer produced it. * @param string $source_id The source plugin's id for the form. + * @param array $map Optional. Source field name => new field id. Kept + * on the form so its stored submissions can be read + * later; without it they are just values under names + * nothing here recognises. * @return int|WP_Error The new form's id. */ -function atf_create_imported_form( $title, $schema, $importer_id, $source_id ) { +function atf_create_imported_form( $title, $schema, $importer_id, $source_id, $map = array() ) { /** * Filters an imported schema before it is saved. * @@ -166,9 +191,330 @@ function atf_create_imported_form( $title, $schema, $importer_id, $source_id ) { atf_save_form_schema( $form_id, atf_normalize_schema( $schema ) ); + update_post_meta( + $form_id, + ATF_META_IMPORT_SOURCE, + array( + 'importer' => sanitize_key( $importer_id ), + 'source' => (string) $source_id, + ) + ); + + if ( $map ) { + update_post_meta( $form_id, ATF_META_IMPORT_MAP, array_map( 'strval', (array) $map ) ); + } + return $form_id; } +/** + * What an imported form was imported from. + * + * @param int $form_id The form. + * @return array|null { importer: string, source: string }, or null when the + * form was not imported. + */ +function atf_form_import_source( $form_id ) { + $source = get_post_meta( (int) $form_id, ATF_META_IMPORT_SOURCE, true ); + + if ( ! is_array( $source ) || empty( $source['importer'] ) || ! isset( $source['source'] ) ) { + return null; + } + + return array( + 'importer' => (string) $source['importer'], + 'source' => (string) $source['source'], + ); +} + +/** + * The source-field-name => field-id map recorded when a form was imported. + * + * @param int $form_id The form. + * @return array Source field name => field id. Empty when unknown. + */ +function atf_form_import_map( $form_id ) { + $map = get_post_meta( (int) $form_id, ATF_META_IMPORT_MAP, true ); + + return is_array( $map ) ? $map : array(); +} + +/** + * The forms that came from a source and could still have submissions to bring. + * + * Keyed by form id so the caller can offer each one by name. + * + * @return array[] Form id => { importer, source, label, count }. + */ +function atf_forms_with_importable_entries() { + $importers = atf_importers(); + $forms = get_posts( + array( + 'post_type' => ATF_FORM_TYPE, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_key' => ATF_META_IMPORT_SOURCE, + ) + ); + + $found = array(); + + foreach ( $forms as $form_id ) { + $source = atf_form_import_source( $form_id ); + + if ( ! $source || ! isset( $importers[ $source['importer'] ] ) ) { + continue; + } + + $importer = $importers[ $source['importer'] ]; + + if ( ! isset( $importer['entries'] ) || ! call_user_func( $importer['available'] ) ) { + continue; + } + + $count = (int) call_user_func( $importer['entries'], $source['source'], (int) $form_id ); + + if ( $count > 0 ) { + $found[ (int) $form_id ] = array( + 'importer' => $source['importer'], + 'source' => $source['source'], + 'label' => $importer['label'], + 'count' => $count, + ); + } + } + + return $found; +} + +/** + * The key that identifies one imported entry against its source record. + * + * A plain string rather than the `{ importer, source }` array used on forms, + * because this one is queried — `meta_value` matching an array means matching a + * serialised blob, which works until the day a value inside it changes shape. + * + * @param string $importer_id Which importer. + * @param string $record_id The source plugin's id for the stored submission. + * @return string + */ +function atf_entry_source_key( $importer_id, $record_id ) { + return sanitize_key( $importer_id ) . ':' . (string) $record_id; +} + +/** + * The source keys already imported onto a form. + * + * Read once per pass and held in memory: asking per record would be a query per + * record, and the answer cannot change underneath a single pass. + * + * @param int $form_id The form. + * @return array Source key => true. + */ +function atf_imported_entry_keys( $form_id ) { + global $wpdb; + + $keys = $wpdb->get_col( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ( + SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %d + )", + ATF_META_ENTRY_SOURCE, + ATF_META_FORM, + (int) $form_id + ) + ); + + return array_fill_keys( (array) $keys, true ); +} + +/** + * Stores one migrated submission as an entry. + * + * # Sanitised, deliberately not validated + * + * Values go through `atf_sanitize_submission()`, which coerces each one to the + * shape its field type stores. They do **not** go through validation, and that + * is the important half: validation enforces today's rules — required fields, + * choice whitelists, bounds — against answers given years ago, under a form that + * may since have lost the option somebody picked. A migration that dropped those + * answers would be quietly deleting history it was asked to preserve. Anything + * unsafe is removed by sanitising; anything merely *unexpected* is kept. + * + * # The date is the submission's, not today's + * + * `atf_store_entry()` stamps the current time and the current request's IP and + * user agent, which is right for a real submission and wrong for every one of + * these. Both are corrected afterwards rather than by duplicating that function, + * so entry titles, value encoding and file parenting stay in one place. + * + * @param int $form_id The form to store against. + * @param array $args { + * The submission, as the source stored it. + * + * @type array $values Source field name => stored value. + * @type string $importer Importer id. + * @type string $record The source plugin's id for this submission. + * @type int $submitted_at Unix time of the original submission. + * @type bool $spam Whether the source marked it as spam. + * @type string $ip Original remote address, if the source kept one. + * @type string $user_agent Original user agent, if the source kept one. + * } + * @return int|WP_Error The entry id, or an error. + */ +function atf_import_entry( $form_id, array $args ) { + $args = wp_parse_args( + $args, + array( + 'values' => array(), + 'importer' => '', + 'record' => '', + 'submitted_at' => 0, + 'spam' => false, + 'ip' => '', + 'user_agent' => '', + ) + ); + + $form_id = (int) $form_id; + $schema = atf_get_form_schema( $form_id ); + + if ( ! $schema ) { + return new WP_Error( 'atf_no_schema', __( 'That form has no schema.', 'allterrain-forms' ) ); + } + + $map = atf_form_import_map( $form_id ); + + if ( ! $map ) { + return new WP_Error( + 'atf_no_import_map', + __( 'That form has no field map, so its stored submissions cannot be read. Import the form again to record one.', 'allterrain-forms' ) + ); + } + + // Source names to field ids. A source field the form no longer has — a tag + // deleted before the migration, or one the importer had no equivalent for — + // simply has nowhere to go. + $raw = array(); + + foreach ( (array) $args['values'] as $name => $value ) { + if ( isset( $map[ $name ] ) ) { + $raw[ $map[ $name ] ] = $value; + } + } + + $values = atf_sanitize_submission( $schema, $raw ); + + $entry_id = atf_store_entry( + $form_id, + $schema, + $values, + array( + 'spam' => (bool) $args['spam'], + 'reason' => $args['spam'] ? 'imported' : '', + ) + ); + + if ( is_wp_error( $entry_id ) ) { + return $entry_id; + } + + $submitted_at = (int) $args['submitted_at']; + + if ( $submitted_at > 0 ) { + $gmt = gmdate( 'Y-m-d H:i:s', $submitted_at ); + + wp_update_post( + array( + 'ID' => $entry_id, + 'post_date' => get_date_from_gmt( $gmt ), + 'post_date_gmt' => $gmt, + ) + ); + } + + $context = json_decode( (string) get_post_meta( $entry_id, ATF_META_CONTEXT, true ), true ); + $context = is_array( $context ) ? $context : array(); + + $context['ip'] = (string) $args['ip']; + $context['userAgent'] = (string) $args['user_agent']; + $context['referrer'] = ''; + $context['userId'] = 0; + $context['imported'] = sanitize_key( $args['importer'] ); + + if ( $submitted_at > 0 ) { + $context['submitted'] = gmdate( 'Y-m-d H:i:s', $submitted_at ); + } + + update_post_meta( $entry_id, ATF_META_CONTEXT, wp_slash( wp_json_encode( $context ) ) ); + update_post_meta( $entry_id, ATF_META_ENTRY_SOURCE, atf_entry_source_key( $args['importer'], $args['record'] ) ); + + return $entry_id; +} + +/** + * Brings a slice of one source form's stored submissions across. + * + * Sliced rather than done in one pass because a form that has been live for + * years is the normal case, not the exceptional one, and the request that + * imports it is an ordinary admin POST with an ordinary time limit. + * + * @param int $form_id The AllTerrain form to import onto. + * @param int $limit How many to attempt in this pass. + * @return array|WP_Error { imported, skipped, done, remaining }. + */ +function atf_import_form_entries( $form_id, $limit = 100 ) { + if ( ! atf_can_edit_forms() ) { + return new WP_Error( 'atf_forbidden', __( 'You cannot import submissions.', 'allterrain-forms' ), array( 'status' => 403 ) ); + } + + $form_id = (int) $form_id; + $source = atf_form_import_source( $form_id ); + + if ( ! $source ) { + return new WP_Error( 'atf_not_imported', __( 'That form was not imported, so it has no submissions to bring across.', 'allterrain-forms' ) ); + } + + $importers = atf_importers(); + + if ( ! isset( $importers[ $source['importer'] ]['import_entries'] ) ) { + return new WP_Error( 'atf_no_entry_import', __( 'That source cannot import stored submissions.', 'allterrain-forms' ) ); + } + + $result = call_user_func( + $importers[ $source['importer'] ]['import_entries'], + $source['source'], + $form_id, + max( 1, (int) $limit ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $result = wp_parse_args( + (array) $result, + array( + 'imported' => 0, + 'skipped' => 0, + 'done' => true, + 'remaining' => 0, + ) + ); + + /** + * Fires after a pass of entry importing. + * + * @param int $form_id The form imported onto. + * @param array $result Counts: imported, skipped, done, remaining. + * @param array $source Where it came from: importer, source. + */ + do_action( 'atf_entries_imported', $form_id, $result, $source ); + + return $result; +} + /** * How many forms each available importer can see, keyed by importer id. * @@ -360,7 +706,46 @@ function atf_render_import_page() { ); } + // The outcome of an entry-import pass. Display only, same as above. + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $entries = isset( $_GET['atf_entries'] ) ? absint( $_GET['atf_entries'] ) : 0; + $remaining = isset( $_GET['atf_remaining'] ) ? absint( $_GET['atf_remaining'] ) : 0; + $entry_error = isset( $_GET['atf_entry_error'] ) ? sanitize_key( wp_unslash( $_GET['atf_entry_error'] ) ) : ''; + $showed_entry = isset( $_GET['atf_entries'] ) || '' !== $entry_error; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + if ( '' !== $entry_error ) { + printf( + '

%s

', + esc_html__( 'Those submissions could not be brought across. Import the form again to record the field map, then try once more.', 'allterrain-forms' ) + ); + } elseif ( $showed_entry ) { + printf( + '

%2$s%3$s

', + $remaining ? 'info' : 'success', + esc_html( + sprintf( + /* translators: %d: number of submissions imported. */ + _n( '%d submission brought across.', '%d submissions brought across.', $entries, 'allterrain-forms' ), + $entries + ) + ), + esc_html( + $remaining ? ' ' . sprintf( + /* translators: %d: number still waiting. */ + _n( '%d still waiting — run it again.', '%d still waiting — run it again.', $remaining, 'allterrain-forms' ), + $remaining + ) : '' + ) + ); + } + if ( ! $available ) { + // Sources can vanish while their already-imported forms still have + // submissions waiting -- deleting CF7 leaves every Flamingo message + // exactly where it was -- so this section comes before the early return. + atf_render_entry_import_section(); + printf( '

%s

', esc_html__( 'No importable forms were found. Importers look for data from Contact Form 7, WPForms and Gravity Forms — the data is read even when the source plugin has been deactivated.', 'allterrain-forms' ) @@ -400,6 +785,12 @@ function atf_render_import_page() { ); } + // Forms already imported that still have submissions sitting in the old + // plugin. Offered above the pickers, because somebody who has just imported + // their forms is looking at an entries screen that says nothing yet, and + // this is the answer to why. + atf_render_entry_import_section(); + foreach ( $available as $id => $importer ) { $forms = call_user_func( $importer['forms'] ); @@ -458,6 +849,111 @@ function atf_render_import_page() { echo ''; } +/** + * Renders the "bring the submissions too" section of the Import page. + * + * Prints nothing at all when there is nothing to bring, so a site whose sources + * never stored anything — a plain CF7 install with no Flamingo — sees the page + * exactly as it was. + * + * @return void + */ +function atf_render_entry_import_section() { + $forms = atf_forms_with_importable_entries(); + + if ( ! $forms ) { + return; + } + + $total = 0; + + foreach ( $forms as $form ) { + $total += (int) $form['count']; + } + + printf( '

%s

', esc_html__( 'Stored submissions', 'allterrain-forms' ) ); + + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of stored submissions found. */ + _n( + '%d stored submission is still held by the plugin it was submitted through. Bringing it across creates an entry with its original date; the original is left untouched.', + '%d stored submissions are still held by the plugins they were submitted through. Bringing them across creates entries with their original dates; the originals are left untouched.', + $total, + 'allterrain-forms' + ), + $total + ) + ) + ); + + echo ''; + + foreach ( $forms as $form_id => $form ) { + printf( + '', + esc_html( get_the_title( $form_id ) ), + esc_html( + sprintf( + /* translators: 1: source plugin name, 2: number of submissions. */ + __( 'from %1$s — %2$d waiting', 'allterrain-forms' ), + $form['label'], + (int) $form['count'] + ) + ), + esc_url( admin_url( 'admin-post.php' ) ), + (int) $form_id, + wp_nonce_field( 'atf-import', '_atf_nonce', true, false ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_nonce_field() builds escaped markup. + esc_html__( 'Bring submissions', 'allterrain-forms' ) + ); + } + + echo '
%1$s
%2$s
+
+ + + %5$s + +
+
'; +} + +/** + * Handles the "bring the submissions too" POST. + * + * One pass per submit, and the page it returns to offers the next one. A form + * with tens of thousands of stored messages therefore takes several clicks + * rather than one request that times out halfway and leaves nobody sure how far + * it got. + * + * @return void + */ +function atf_handle_import_entries_post() { + if ( ! atf_can_edit_forms() ) { + wp_die( esc_html__( 'You cannot import submissions.', 'allterrain-forms' ) ); + } + + check_admin_referer( 'atf-import', '_atf_nonce' ); + + $form_id = isset( $_POST['form'] ) ? absint( wp_unslash( $_POST['form'] ) ) : 0; + $result = atf_import_form_entries( $form_id, 200 ); + + $args = array( 'page' => 'allterrain-forms-import' ); + + if ( is_wp_error( $result ) ) { + $args['atf_entry_error'] = $result->get_error_code(); + } else { + $args['atf_entries'] = (int) $result['imported']; + $args['atf_remaining'] = (int) $result['remaining']; + } + + wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); + exit; +} +add_action( 'admin_post_atf_import_entries', 'atf_handle_import_entries_post' ); + /** * Handles the Import page's POST. * diff --git a/readme.txt b/readme.txt index 0214fac..3de9ea4 100644 --- a/readme.txt +++ b/readme.txt @@ -107,6 +107,11 @@ conditional logic across, payment and product fields keep their prices, an order total keeps calculating, and a Gravity Forms List becomes a real repeater. +The stored submissions come too. Messages Flamingo kept for Contact Form 7, +Gravity Forms entries, and WPForms Pro entries can all be brought across after +their form is — each with its original date, spam filed as spam, and the +originals left untouched. Running it twice never imports anything twice. + = Anti-spam that does not charge your visitors = A honeypot, a signed time trap, a per-address rate limit, a word blocklist, and diff --git a/tests/phpunit/tests/importersEntries.php b/tests/phpunit/tests/importersEntries.php new file mode 100644 index 0000000..f22477c --- /dev/null +++ b/tests/phpunit/tests/importersEntries.php @@ -0,0 +1,451 @@ +` rows, a channel term, spam under Flamingo's own post status — + * because that storage is the only contract available once Contact Form 7 and + * Flamingo are switched off, which is precisely when somebody migrates. + * + * @package AllTerrain_Forms + * @group allterrain-forms + */ + +/** + * Entry migration: the registry contract, the CF7/Flamingo reader, and the rules + * about dates, spam and repeat runs. + * + * @group allterrain-forms + */ +class ATF_Test_Importers_Entries extends WP_UnitTestCase { + + /** + * A CF7 template with one field of each shape the messages below use. + * + * @var string + */ + const TEMPLATE = ' + + + + + + + +[submit "Send"]'; + + /** + * Every status an imported entry can land in. + * + * Spelled out because `'any'` would find none of them: entry statuses are + * registered `exclude_from_search`, and `'any'` means "every status not + * excluded from search". It is the same blind spot that hides Flamingo's + * spam status from a query on the other side of this migration. + * + * @var string[] + */ + const ENTRY_STATUSES = array( 'atf-unread', 'atf-read', 'atf-spam' ); + + /** + * The CF7 form post id. + * + * @var int + */ + protected $cf7_id = 0; + + /** + * The Flamingo channel term id. + * + * @var int + */ + protected $channel_id = 0; + + /** + * Registers what Flamingo and CF7 would have registered, then builds a form. + */ + public function set_up() { + parent::set_up(); + + // Both post types belong to plugins that are not loaded in the test + // suite. Registering them here is not a convenience: the importer is + // specifically built to read this data with those plugins gone, and the + // posts and terms are what survive them. + register_post_type( 'wpcf7_contact_form', array( 'public' => false ) ); + register_post_type( 'flamingo_inbound', array( 'public' => false ) ); + register_post_status( 'flamingo-spam', array( 'exclude_from_search' => true ) ); + register_taxonomy( 'flamingo_inbound_channel', 'flamingo_inbound' ); + + $this->cf7_id = self::factory()->post->create( + array( + 'post_type' => 'wpcf7_contact_form', + 'post_title' => 'Property Enquiry', + 'post_name' => 'property-enquiry', + ) + ); + + update_post_meta( $this->cf7_id, '_form', self::TEMPLATE ); + + $term = wp_insert_term( 'Property Enquiry', 'flamingo_inbound_channel', array( 'slug' => 'property-enquiry' ) ); + $this->channel_id = (int) $term['term_id']; + + update_post_meta( $this->cf7_id, '_flamingo', array( 'channel' => $this->channel_id ) ); + + // The plugin's capabilities are granted on activation, which does not + // run in the suite -- an administrator has none of them until this does. + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * Writes one stored message the way Flamingo does. + * + * @param array $fields Field name => value. + * @param string $date GMT date, `Y-m-d H:i:s`. + * @param bool $spam Whether it is spam. + * @return int The message post id. + */ + protected function add_message( $fields, $date, $spam = false ) { + $id = self::factory()->post->create( + array( + 'post_type' => 'flamingo_inbound', + 'post_status' => $spam ? 'flamingo-spam' : 'publish', + 'post_title' => 'Message', + 'post_date_gmt' => $date, + 'post_date' => $date, + ) + ); + + foreach ( $fields as $name => $value ) { + update_post_meta( $id, sanitize_key( '_field_' . $name ), $value ); + } + + // Flamingo nulls the values inside `_fields` as it writes the per-field + // rows. Reproduced because an importer that read this array instead + // would find names with nothing under them and import blank entries. + update_post_meta( $id, '_fields', array_map( '__return_null', $fields ) ); + update_post_meta( + $id, + '_meta', + array( + 'remote_ip' => '203.0.113.7', + 'user_agent' => 'Mozilla/5.0 (Test)', + ) + ); + + wp_set_object_terms( $id, array( $this->channel_id ), 'flamingo_inbound_channel' ); + + return $id; + } + + /** + * Imports the CF7 form and returns the new form's id. + * + * @return int + */ + protected function import_form() { + return atf_import_source_form( 'contact-form-7', (string) $this->cf7_id ); + } + + /** + * An importer offering only one half of the entry contract gets neither. + * + * A count with no import behind it would put a number on the screen that no + * button could act on. + * + * @covers ::atf_importers + */ + public function test_half_an_entry_contract_is_dropped() { + $filter = static function ( $importers ) { + $importers['halfway'] = array( + 'label' => 'Halfway', + 'available' => '__return_true', + 'forms' => '__return_empty_array', + 'import' => '__return_zero', + 'entries' => '__return_zero', + ); + + return $importers; + }; + + add_filter( 'atf_importers', $filter ); + $importers = atf_importers(); + remove_filter( 'atf_importers', $filter ); + + $this->assertArrayHasKey( 'halfway', $importers, 'The importer itself is still valid.' ); + $this->assertArrayNotHasKey( 'entries', $importers['halfway'] ); + $this->assertArrayNotHasKey( 'import_entries', $importers['halfway'] ); + } + + /** + * Importing a form records where it came from and how its fields map. + * + * Without both, its stored submissions are unreadable later. + * + * @covers ::atf_create_imported_form + * @covers ::atf_form_import_source + * @covers ::atf_form_import_map + */ + public function test_import_records_source_and_map() { + $form_id = $this->import_form(); + + $this->assertNotWPError( $form_id ); + + $source = atf_form_import_source( $form_id ); + + $this->assertSame( 'contact-form-7', $source['importer'] ); + $this->assertSame( (string) $this->cf7_id, $source['source'] ); + + $map = atf_form_import_map( $form_id ); + + $this->assertArrayHasKey( 'your-name', $map ); + $this->assertArrayHasKey( 'bid-funding', $map ); + } + + /** + * Stored messages become entries, with their values under the new field ids. + * + * @covers ::atf_cf7_import_entries + * @covers ::atf_import_entry + */ + public function test_messages_become_entries() { + $this->add_message( + array( + 'your-name' => 'Elena Ruiz', + 'your-email' => 'elena.ruiz@example.com', + 'bid-funding' => 'Cash buyer', + 'your-message' => 'Is the terrace south facing?', + ), + '2025-03-04 09:15:00' + ); + + $form_id = $this->import_form(); + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 1, $result['imported'] ); + $this->assertTrue( $result['done'] ); + + $entries = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertCount( 1, $entries ); + + $map = atf_form_import_map( $form_id ); + $values = json_decode( get_post_meta( $entries[0]->ID, ATF_META_VALUES, true ), true ); + + $this->assertSame( 'Elena Ruiz', $values[ $map['your-name'] ] ); + $this->assertSame( 'Is the terrace south facing?', $values[ $map['your-message'] ] ); + } + + /** + * An entry keeps the date it was submitted, not the date it was migrated. + * + * A history that all arrives today is not a history. + * + * @covers ::atf_import_entry + */ + public function test_original_date_is_kept() { + $this->add_message( array( 'your-name' => 'Marcus Hale' ), '2024-11-19 14:02:00' ); + + $form_id = $this->import_form(); + atf_import_form_entries( $form_id ); + + $entries = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertSame( '2024-11-19 14:02:00', $entries[0]->post_date_gmt ); + + $context = json_decode( get_post_meta( $entries[0]->ID, ATF_META_CONTEXT, true ), true ); + + $this->assertSame( '203.0.113.7', $context['ip'] ); + $this->assertSame( 'contact-form-7', $context['imported'] ); + } + + /** + * Spam arrives as spam rather than landing in the unread pile. + * + * Flamingo files spam under a status registered `exclude_from_search`, which + * is exactly the status a `post_status => 'any'` query silently omits — so + * this also pins that the reader does not use one. + * + * @covers ::atf_cf7_import_entries + */ + public function test_spam_is_imported_as_spam() { + $this->add_message( array( 'your-name' => 'SEO Growth Team' ), '2025-01-02 08:00:00', true ); + $this->add_message( array( 'your-name' => 'Priya Nandra' ), '2025-01-03 08:00:00' ); + + $form_id = $this->import_form(); + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 2, $result['imported'], 'The spam message was found as well as the published one.' ); + + $spam = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => ATF_STATUS_SPAM, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertCount( 1, $spam ); + } + + /** + * Running it twice does not import anything twice. + * + * The natural response to a migration that looks incomplete is to run it + * again, so it has to be safe to. + * + * @covers ::atf_cf7_import_entries + */ + public function test_second_run_imports_nothing_new() { + $this->add_message( array( 'your-name' => 'Tomas Berg' ), '2025-02-01 10:00:00' ); + $this->add_message( array( 'your-name' => 'Sofia Marchetti' ), '2025-02-02 10:00:00' ); + + $form_id = $this->import_form(); + + $first = atf_import_form_entries( $form_id ); + $this->assertSame( 2, $first['imported'] ); + + $second = atf_import_form_entries( $form_id ); + $this->assertSame( 0, $second['imported'] ); + $this->assertSame( 2, $second['skipped'] ); + + $entries = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertCount( 2, $entries ); + } + + /** + * A pass stops at the limit and says how much is left. + * + * @covers ::atf_cf7_import_entries + */ + public function test_import_is_chunked() { + for ( $i = 0; $i < 5; $i++ ) { + $this->add_message( array( 'your-name' => 'Person ' . $i ), sprintf( '2025-04-0%d 09:00:00', $i + 1 ) ); + } + + $form_id = $this->import_form(); + $result = atf_import_form_entries( $form_id, 2 ); + + $this->assertSame( 2, $result['imported'] ); + $this->assertFalse( $result['done'] ); + $this->assertSame( 3, $result['remaining'] ); + } + + /** + * The count reports what is still waiting, and reaches zero when done. + * + * @covers ::atf_cf7_entry_count + * @covers ::atf_forms_with_importable_entries + */ + public function test_count_falls_to_zero() { + $this->add_message( array( 'your-name' => 'Ruth Ellery' ), '2025-05-01 09:00:00' ); + + $form_id = $this->import_form(); + + $this->assertSame( 1, atf_cf7_entry_count( (string) $this->cf7_id, $form_id ) ); + $this->assertArrayHasKey( $form_id, atf_forms_with_importable_entries() ); + + atf_import_form_entries( $form_id ); + + $this->assertSame( 0, atf_cf7_entry_count( (string) $this->cf7_id, $form_id ) ); + $this->assertArrayNotHasKey( $form_id, atf_forms_with_importable_entries() ); + } + + /** + * An answer the form no longer offers is kept, not thrown away. + * + * Sanitising runs; validation deliberately does not. A choice retired since + * the message was sent is still what that person said. + * + * @covers ::atf_import_entry + */ + public function test_retired_choice_survives() { + $this->add_message( + array( + 'your-name' => 'Nils Andersen', + 'bid-funding' => 'Paying in gold bars', + ), + '2025-06-01 09:00:00' + ); + + $form_id = $this->import_form(); + atf_import_form_entries( $form_id ); + + $entries = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $map = atf_form_import_map( $form_id ); + $values = json_decode( get_post_meta( $entries[0]->ID, ATF_META_VALUES, true ), true ); + + $this->assertSame( 'Paying in gold bars', $values[ $map['bid-funding'] ] ); + } + + /** + * A form that was never imported has nothing to bring across. + * + * @covers ::atf_import_form_entries + */ + public function test_native_form_is_rejected() { + $form_id = self::factory()->post->create( array( 'post_type' => ATF_FORM_TYPE ) ); + + $result = atf_import_form_entries( $form_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'atf_not_imported', $result->get_error_code() ); + } + + /** + * The source's own records are never touched. + * + * @covers ::atf_cf7_import_entries + */ + public function test_source_is_left_alone() { + $message_id = $this->add_message( array( 'your-name' => 'Aoife Brennan' ), '2025-07-01 09:00:00' ); + + $form_id = $this->import_form(); + atf_import_form_entries( $form_id ); + + $message = get_post( $message_id ); + + $this->assertInstanceOf( 'WP_Post', $message ); + $this->assertSame( 'publish', $message->post_status ); + $this->assertSame( 'Aoife Brennan', get_post_meta( $message_id, '_field_your-name', true ) ); + } +} diff --git a/tests/phpunit/tests/importersEntriesGravityforms.php b/tests/phpunit/tests/importersEntriesGravityforms.php new file mode 100644 index 0000000..6ff06b2 --- /dev/null +++ b/tests/phpunit/tests/importersEntriesGravityforms.php @@ -0,0 +1,426 @@ +query( "CREATE TABLE {$wpdb->prefix}gf_form ( id mediumint(8) unsigned NOT NULL auto_increment, title varchar(150) NOT NULL, is_trash tinyint(1) NOT NULL default 0, PRIMARY KEY (id) )" ); + $wpdb->query( "CREATE TABLE {$wpdb->prefix}gf_form_meta ( form_id mediumint(8) unsigned NOT NULL, display_meta longtext, notifications longtext, confirmations longtext, PRIMARY KEY (form_id) )" ); + $wpdb->query( "CREATE TABLE {$wpdb->prefix}gf_entry ( id int(10) unsigned NOT NULL auto_increment, form_id mediumint(8) unsigned NOT NULL, status varchar(20) NOT NULL default 'active', date_created datetime NOT NULL, ip varchar(39) NOT NULL default '', user_agent varchar(250) NOT NULL default '', PRIMARY KEY (id) )" ); + $wpdb->query( "CREATE TABLE {$wpdb->prefix}gf_entry_meta ( id bigint(20) unsigned NOT NULL auto_increment, entry_id int(10) unsigned NOT NULL, meta_key varchar(255), meta_value longtext, PRIMARY KEY (id) )" ); + + $wpdb->insert( + $wpdb->prefix . 'gf_form', + array( + 'title' => 'Order Enquiry', + 'is_trash' => 0, + ) + ); + $this->gf_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->prefix . 'gf_form_meta', + array( + 'form_id' => $this->gf_id, + 'display_meta' => wp_json_encode( $this->display_meta() ), + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * Drops the fixture tables. + */ + public function tear_down() { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dropping the fixture; table names cannot be placeholders. + foreach ( array( 'gf_form', 'gf_form_meta', 'gf_entry', 'gf_entry_meta' ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}{$table}" ); + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + parent::tear_down(); + } + + /** + * A display_meta with one field of each shape the entries below use. + * + * The captcha in the middle matters: it converts to nothing, so the map + * must show a gap exactly where the conversion left one. + * + * @return array + */ + protected function display_meta() { + return array( + 'title' => 'Order Enquiry', + 'fields' => array( + array( + 'type' => 'name', + 'id' => 1, + 'label' => 'Name', + 'inputs' => array( + array( 'id' => '1.3' ), + array( 'id' => '1.6' ), + ), + ), + array( + 'type' => 'email', + 'id' => 2, + 'label' => 'Email', + ), + array( + 'type' => 'checkbox', + 'id' => 3, + 'label' => 'Colours', + 'choices' => array( + array( + 'text' => 'Red', + 'value' => 'Red', + ), + array( + 'text' => 'Blue', + 'value' => 'Blue', + ), + ), + ), + array( + 'type' => 'multiselect', + 'id' => 4, + 'label' => 'Extras', + 'choices' => array( + array( + 'text' => 'Gift wrap', + 'value' => 'Gift wrap', + ), + array( + 'text' => 'Express', + 'value' => 'Express', + ), + ), + ), + array( + 'type' => 'list', + 'id' => 5, + 'label' => 'Items', + 'enableColumns' => true, + 'choices' => array( + array( 'text' => 'Item' ), + array( 'text' => 'Qty' ), + ), + ), + array( + 'type' => 'address', + 'id' => 6, + 'label' => 'Address', + ), + array( + 'type' => 'captcha', + 'id' => 7, + ), + array( + 'type' => 'textarea', + 'id' => 8, + 'label' => 'Message', + ), + ), + ); + } + + /** + * Writes one stored entry the way Gravity does. + * + * @param array $meta Input id => value. + * @param string $date GMT date, `Y-m-d H:i:s`. + * @param string $status Gravity status: active, spam, trash. + * @return int The entry id. + */ + protected function add_entry( $meta, $date, $status = 'active' ) { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Writing the fixture. + $wpdb->insert( + $wpdb->prefix . 'gf_entry', + array( + 'form_id' => $this->gf_id, + 'status' => $status, + 'date_created' => $date, + 'ip' => '203.0.113.9', + 'user_agent' => 'Mozilla/5.0 (Test)', + ) + ); + $entry_id = (int) $wpdb->insert_id; + + foreach ( $meta as $key => $value ) { + $wpdb->insert( + $wpdb->prefix . 'gf_entry_meta', + array( + 'entry_id' => $entry_id, + 'meta_key' => (string) $key, + 'meta_value' => (string) $value, + ) + ); + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery + + return $entry_id; + } + + /** + * Imports the Gravity form and returns the new form's id. + * + * @return int + */ + protected function import_form() { + return atf_import_source_form( 'gravityforms', (string) $this->gf_id ); + } + + /** + * The entries stored against a form, oldest first. + * + * @param int $form_id The form. + * @return WP_Post[] + */ + protected function entries( $form_id ) { + return get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'ASC', + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + } + + /** + * The recomputed map mints the same ids the conversion did — gaps included. + * + * @covers ::atf_gf_map + */ + public function test_map_matches_conversion() { + $map = atf_gf_map( $this->display_meta() ); + + $this->assertSame( 'f1', $map['1'] ); + $this->assertSame( 'f6', $map['6'] ); + $this->assertSame( 'f8', $map['8'], 'Ids keep counting past the dropped captcha.' ); + $this->assertArrayNotHasKey( '7', $map, 'A captcha converts to nothing, so it maps to nothing.' ); + } + + /** + * Every stored shape arrives as the shape its new field stores. + * + * @covers ::atf_gf_import_entries + * @covers ::atf_gf_entry_values + * @covers ::atf_gf_entry_value + */ + public function test_shapes_survive_the_trip() { + $this->add_entry( + array( + '1.3' => 'Elena', + '1.6' => 'Ruiz', + '2' => 'elena.ruiz@example.com', + '3.1' => 'Red', + '3.2' => 'Blue', + '4' => '["Gift wrap","Express"]', + '5' => serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Gravity itself serialises List values; the fixture reproduces its storage. + array( + array( + 'Item' => 'Widget', + 'Qty' => '2', + ), + array( + 'Item' => 'Bolt', + 'Qty' => '5', + ), + ) + ), + '6.1' => '12 High Street', + '6.3' => 'London', + '6.5' => 'SW1A 1AA', + '6.6' => 'United Kingdom', + '8' => "Two lines\nof message", + ), + '2025-03-04 09:15:00' + ); + + $form_id = $this->import_form(); + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 1, $result['imported'] ); + + $entries = $this->entries( $form_id ); + + $this->assertCount( 1, $entries ); + + $map = atf_form_import_map( $form_id ); + $values = json_decode( get_post_meta( $entries[0]->ID, ATF_META_VALUES, true ), true ); + + $this->assertSame( + array( + 'first' => 'Elena', + 'last' => 'Ruiz', + ), + $values[ $map['1'] ] + ); + $this->assertSame( 'elena.ruiz@example.com', $values[ $map['2'] ] ); + $this->assertSame( array( 'Red', 'Blue' ), $values[ $map['3'] ] ); + $this->assertSame( array( 'Gift wrap', 'Express' ), $values[ $map['4'] ] ); + $this->assertSame( + array( + array( + 's1' => 'Widget', + 's2' => '2', + ), + array( + 's1' => 'Bolt', + 's2' => '5', + ), + ), + $values[ $map['5'] ] + ); + $this->assertSame( + array( + 'line1' => '12 High Street', + 'city' => 'London', + 'postcode' => 'SW1A 1AA', + 'country' => 'United Kingdom', + ), + $values[ $map['6'] ] + ); + $this->assertSame( "Two lines\nof message", $values[ $map['8'] ] ); + + $this->assertSame( '2025-03-04 09:15:00', $entries[0]->post_date_gmt ); + + $context = json_decode( get_post_meta( $entries[0]->ID, ATF_META_CONTEXT, true ), true ); + + $this->assertSame( '203.0.113.9', $context['ip'] ); + $this->assertSame( 'gravityforms', $context['imported'] ); + } + + /** + * Spam arrives as spam; trash stays where somebody put it. + * + * @covers ::atf_gf_import_entries + * @covers ::atf_gf_entry_count + */ + public function test_spam_comes_and_trash_stays() { + $this->add_entry( array( '2' => 'seo.team@example.net' ), '2025-01-02 08:00:00', 'spam' ); + $this->add_entry( array( '2' => 'priya@example.com' ), '2025-01-03 08:00:00' ); + $this->add_entry( array( '2' => 'binned@example.com' ), '2025-01-04 08:00:00', 'trash' ); + + $form_id = $this->import_form(); + + $this->assertSame( 2, atf_gf_entry_count( (string) $this->gf_id, $form_id ), 'Trash is not offered.' ); + + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 2, $result['imported'] ); + $this->assertTrue( $result['done'] ); + + $spam = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => ATF_STATUS_SPAM, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertCount( 1, $spam ); + } + + /** + * Running it twice imports nothing twice, and chunking says what is left. + * + * @covers ::atf_gf_import_entries + */ + public function test_second_run_and_chunking() { + for ( $i = 0; $i < 3; $i++ ) { + $this->add_entry( array( '2' => "person{$i}@example.com" ), sprintf( '2025-04-0%d 09:00:00', $i + 1 ) ); + } + + $form_id = $this->import_form(); + + $first = atf_import_form_entries( $form_id, 2 ); + + $this->assertSame( 2, $first['imported'] ); + $this->assertFalse( $first['done'] ); + $this->assertSame( 1, $first['remaining'] ); + + $second = atf_import_form_entries( $form_id, 2 ); + + $this->assertSame( 1, $second['imported'] ); + $this->assertSame( 2, $second['skipped'] ); + $this->assertTrue( $second['done'] ); + + $this->assertCount( 3, $this->entries( $form_id ) ); + } + + /** + * The source tables are never touched. + * + * @covers ::atf_gf_import_entries + */ + public function test_source_is_left_alone() { + global $wpdb; + + $entry_id = $this->add_entry( array( '2' => 'aoife@example.com' ), '2025-07-01 09:00:00' ); + + $form_id = $this->import_form(); + atf_import_form_entries( $form_id ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Asserting against the fixture. + $this->assertSame( 'active', $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}gf_entry WHERE id = %d", $entry_id ) ) ); + $this->assertSame( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}gf_entry_meta WHERE entry_id = %d AND meta_key = '2'", $entry_id ) ) ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + } +} diff --git a/tests/phpunit/tests/importersEntriesWpforms.php b/tests/phpunit/tests/importersEntriesWpforms.php new file mode 100644 index 0000000..ec9eab1 --- /dev/null +++ b/tests/phpunit/tests/importersEntriesWpforms.php @@ -0,0 +1,410 @@ +query( "CREATE TABLE {$wpdb->prefix}wpforms_entries ( entry_id bigint(20) NOT NULL auto_increment, form_id bigint(20) NOT NULL, status varchar(30) NOT NULL default '', fields longtext, date datetime NOT NULL, ip_address varchar(128) NOT NULL default '', user_agent varchar(256) NOT NULL default '', PRIMARY KEY (entry_id) )" ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + + $this->wpf_id = self::factory()->post->create( + array( + 'post_type' => 'wpforms', + 'post_title' => 'Booking Request', + 'post_content' => wp_slash( wp_json_encode( $this->document() ) ), + ) + ); + + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * Drops the fixture table. + */ + public function tear_down() { + global $wpdb; + + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wpforms_entries" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Dropping the fixture. + + parent::tear_down(); + } + + /** + * A WPForms document with one field of each shape the entries below use. + * + * The captcha in the middle matters: it converts to nothing, so the map + * must show a gap exactly where the conversion left one. + * + * @return array + */ + protected function document() { + return array( + 'id' => 7, + 'fields' => array( + array( + 'id' => 0, + 'type' => 'name', + 'format' => 'first-last', + 'label' => 'Name', + ), + array( + 'id' => 1, + 'type' => 'email', + 'label' => 'Email', + ), + array( + 'id' => 2, + 'type' => 'checkbox', + 'label' => 'Rooms', + 'choices' => array( + array( 'label' => 'Garden room' ), + array( 'label' => 'Sea view' ), + ), + ), + array( + 'id' => 3, + 'type' => 'address', + 'label' => 'Address', + ), + array( + 'id' => 4, + 'type' => 'captcha', + ), + array( + 'id' => 5, + 'type' => 'textarea', + 'label' => 'Requests', + ), + ), + 'settings' => array( 'form_title' => 'Booking Request' ), + ); + } + + /** + * Writes one stored entry the way WPForms Pro does. + * + * @param array $fields Field id => stored field object. + * @param string $date GMT date, `Y-m-d H:i:s`. + * @param string $status WPForms status: '' for ordinary, spam, trash, partial, abandoned. + * @return int The entry id. + */ + protected function add_entry( $fields, $date, $status = '' ) { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Writing the fixture. + $wpdb->insert( + $wpdb->prefix . 'wpforms_entries', + array( + 'form_id' => $this->wpf_id, + 'status' => $status, + 'fields' => wp_json_encode( $fields ), + 'date' => $date, + 'ip_address' => '203.0.113.11', + 'user_agent' => 'Mozilla/5.0 (Test)', + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + + return (int) $wpdb->insert_id; + } + + /** + * Imports the WPForms form and returns the new form's id. + * + * @return int + */ + protected function import_form() { + return atf_import_source_form( 'wpforms', (string) $this->wpf_id ); + } + + /** + * The entries stored against a form. + * + * @param int $form_id The form. + * @return WP_Post[] + */ + protected function entries( $form_id ) { + return get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => self::ENTRY_STATUSES, + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'ASC', + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + } + + /** + * The recomputed map mints the same ids the conversion did — gaps included. + * + * @covers ::atf_wpforms_map + */ + public function test_map_matches_conversion() { + $map = atf_wpforms_map( $this->document() ); + + $this->assertSame( 'f1', $map['0'] ); + $this->assertSame( 'f4', $map['3'] ); + $this->assertSame( 'f6', $map['5'], 'Ids keep counting past the dropped captcha.' ); + $this->assertArrayNotHasKey( '4', $map, 'A captcha converts to nothing, so it maps to nothing.' ); + } + + /** + * Every stored shape arrives as the shape its new field stores. + * + * @covers ::atf_wpforms_import_entries + * @covers ::atf_wpforms_entry_values + * @covers ::atf_wpforms_entry_value + */ + public function test_shapes_survive_the_trip() { + $this->add_entry( + array( + '0' => array( + 'id' => 0, + 'type' => 'name', + 'value' => 'Elena Ruiz', + 'first' => 'Elena', + 'last' => 'Ruiz', + ), + '1' => array( + 'id' => 1, + 'type' => 'email', + 'value' => 'elena.ruiz@example.com', + ), + '2' => array( + 'id' => 2, + 'type' => 'checkbox', + 'value' => "Garden room\nSea view", + ), + '3' => array( + 'id' => 3, + 'type' => 'address', + 'value' => "12 High Street\nLondon, SW1A 1AA", + 'address1' => '12 High Street', + 'address2' => '', + 'city' => 'London', + 'state' => '', + 'postal' => 'SW1A 1AA', + 'country' => 'GB', + ), + '5' => array( + 'id' => 5, + 'type' => 'textarea', + 'value' => "A cot, please.\nAnd a late checkout.", + ), + ), + '2025-03-04 09:15:00' + ); + + $form_id = $this->import_form(); + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 1, $result['imported'] ); + + $entries = $this->entries( $form_id ); + + $this->assertCount( 1, $entries ); + + $map = atf_form_import_map( $form_id ); + $values = json_decode( get_post_meta( $entries[0]->ID, ATF_META_VALUES, true ), true ); + + $this->assertSame( + array( + 'first' => 'Elena', + 'last' => 'Ruiz', + ), + $values[ $map['0'] ] + ); + $this->assertSame( 'elena.ruiz@example.com', $values[ $map['1'] ] ); + $this->assertSame( array( 'Garden room', 'Sea view' ), $values[ $map['2'] ] ); + $this->assertSame( + array( + 'line1' => '12 High Street', + 'city' => 'London', + 'postcode' => 'SW1A 1AA', + 'country' => 'GB', + ), + $values[ $map['3'] ] + ); + $this->assertSame( "A cot, please.\nAnd a late checkout.", $values[ $map['5'] ] ); + + $this->assertSame( '2025-03-04 09:15:00', $entries[0]->post_date_gmt ); + + $context = json_decode( get_post_meta( $entries[0]->ID, ATF_META_CONTEXT, true ), true ); + + $this->assertSame( '203.0.113.11', $context['ip'] ); + $this->assertSame( 'wpforms', $context['imported'] ); + } + + /** + * Spam comes across as spam; trash and half-typed forms stay behind. + * + * `partial` and `abandoned` are forms nobody ever sent — importing them + * would put entries on the screen that no visitor believes they submitted. + * + * @covers ::atf_wpforms_import_entries + * @covers ::atf_wpforms_entry_count + */ + public function test_statuses_sort_themselves() { + $answer = static function ( $email ) { + return array( + '1' => array( + 'id' => 1, + 'type' => 'email', + 'value' => $email, + ), + ); + }; + + $this->add_entry( $answer( 'seo.team@example.net' ), '2025-01-02 08:00:00', 'spam' ); + $this->add_entry( $answer( 'priya@example.com' ), '2025-01-03 08:00:00' ); + $this->add_entry( $answer( 'paid@example.com' ), '2025-01-04 08:00:00', 'completed' ); + $this->add_entry( $answer( 'binned@example.com' ), '2025-01-05 08:00:00', 'trash' ); + $this->add_entry( $answer( 'halfway@example.com' ), '2025-01-06 08:00:00', 'partial' ); + $this->add_entry( $answer( 'gone@example.com' ), '2025-01-07 08:00:00', 'abandoned' ); + + $form_id = $this->import_form(); + + $this->assertSame( 3, atf_wpforms_entry_count( (string) $this->wpf_id, $form_id ) ); + + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 3, $result['imported'] ); + $this->assertTrue( $result['done'] ); + + $spam = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => ATF_STATUS_SPAM, + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $form_id, + ) + ); + + $this->assertCount( 1, $spam, 'A payment entry is an ordinary entry, not spam.' ); + } + + /** + * Running it twice imports nothing twice. + * + * @covers ::atf_wpforms_import_entries + */ + public function test_second_run_imports_nothing_new() { + $this->add_entry( + array( + '1' => array( + 'id' => 1, + 'type' => 'email', + 'value' => 'tomas@example.com', + ), + ), + '2025-02-01 10:00:00' + ); + + $form_id = $this->import_form(); + + $first = atf_import_form_entries( $form_id ); + $this->assertSame( 1, $first['imported'] ); + + $second = atf_import_form_entries( $form_id ); + $this->assertSame( 0, $second['imported'] ); + $this->assertSame( 1, $second['skipped'] ); + + $this->assertCount( 1, $this->entries( $form_id ) ); + } + + /** + * A Lite site — no entries table at all — is simply done, not broken. + * + * @covers ::atf_wpforms_import_entries + * @covers ::atf_wpforms_entry_count + */ + public function test_lite_site_has_nothing_to_bring() { + global $wpdb; + + $form_id = $this->import_form(); + + $wpdb->query( "DROP TABLE {$wpdb->prefix}wpforms_entries" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removing the fixture is the scenario. + + $this->assertSame( 0, atf_wpforms_entry_count( (string) $this->wpf_id, $form_id ) ); + + $result = atf_import_form_entries( $form_id ); + + $this->assertSame( 0, $result['imported'] ); + $this->assertTrue( $result['done'] ); + } + + /** + * The source table is never touched. + * + * @covers ::atf_wpforms_import_entries + */ + public function test_source_is_left_alone() { + global $wpdb; + + $entry_id = $this->add_entry( + array( + '1' => array( + 'id' => 1, + 'type' => 'email', + 'value' => 'aoife@example.com', + ), + ), + '2025-07-01 09:00:00' + ); + + $form_id = $this->import_form(); + atf_import_form_entries( $form_id ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Asserting against the fixture. + $row = $wpdb->get_row( $wpdb->prepare( "SELECT status, fields FROM {$wpdb->prefix}wpforms_entries WHERE entry_id = %d", $entry_id ) ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + + $this->assertSame( '', $row->status ); + $this->assertStringContainsString( 'aoife@example.com', $row->fields ); + } +}