From ff5c3be5d349f5ca42e18a2e34f42dea08defb41 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Sun, 16 Aug 2026 19:21:34 +0200 Subject: [PATCH] Import forms from Contact Form 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single biggest barrier to switching form plugins is the fifteen forms somebody already built. Forms → Import now lists every wpcf7_contact_form post on the site — read from the database directly, so it works after CF7 has been deactivated — and turns each one into an ordinary AllTerrain form: tags become fields with their labels read from the surrounding markup, the mail template becomes a notification with [your-name] rewritten onto {field:…} merge tags, Mail (2) comes along only when it was switched on, and the thank-you message becomes the confirmation. The source is never modified, so importing is safe to try and safe to repeat. The registry is a filter (atf_importers) that the built-in importer uses exactly as a third-party source would, plus atf_imported_schema to correct a mapping before it saves and atf_form_imported after the fact. WPForms and Gravity Forms follow in their own changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp --- allterrain-forms.php | 2 + docs/hooks-reference.md | 53 +++ includes/importer-cf7.php | 576 ++++++++++++++++++++++++++++++ includes/importers.php | 373 +++++++++++++++++++ readme.txt | 17 + tests/phpunit/tests/importers.php | 294 +++++++++++++++ 6 files changed, 1315 insertions(+) create mode 100644 includes/importer-cf7.php create mode 100644 includes/importers.php create mode 100644 tests/phpunit/tests/importers.php diff --git a/allterrain-forms.php b/allterrain-forms.php index 1700e16..531f3f7 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -154,6 +154,8 @@ require_once ATF_DIR . 'includes/dev-mode.php'; require_once ATF_DIR . 'includes/demo-data.php'; require_once ATF_DIR . 'includes/templates.php'; +require_once ATF_DIR . 'includes/importers.php'; +require_once ATF_DIR . 'includes/importer-cf7.php'; require_once ATF_DIR . 'includes/rest.php'; require_once ATF_DIR . 'includes/preview.php'; require_once ATF_DIR . 'includes/shortcode.php'; diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index 200c6b7..bd6ea06 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -776,6 +776,59 @@ do_action( 'atf_retention_applied', int $deleted ); --- +## Importers + +### `atf_importers` — Filter — *Experimental* + +```php +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 +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. + +Importers read the source plugin's *data*, not its API — the moment somebody +most wants to import is right after deactivating the old plugin. + +```php +add_filter( 'atf_importers', function ( $importers ) { + $importers['my-forms'] = array( + 'label' => 'My Forms', + 'available' => fn() => (bool) get_option( 'my_forms' ), + 'forms' => fn() => wp_list_pluck( get_option( 'my_forms' ), 'title', 'id' ), + 'import' => 'my_forms_import_one', + ); + + return $importers; +} ); +``` + +### `atf_imported_schema` — Filter — *Experimental* + +```php +apply_filters( 'atf_imported_schema', array $schema, string $importer_id, string $source_id ); +``` + +The converted schema, before it is normalised and saved. The place to correct +a mapping the converter got wrong for your data — rename a field, add a +notification — without forking the importer. + +### `atf_form_imported` — Action — *Experimental* + +```php +do_action( 'atf_form_imported', int $form_id, string $importer_id, string $source_id ); +``` + +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. + +--- + ## Themes ### `atf_theme_tokens` — Filter — *Stable* diff --git a/includes/importer-cf7.php b/includes/importer-cf7.php new file mode 100644 index 0000000..b88de1c --- /dev/null +++ b/includes/importer-cf7.php @@ -0,0 +1,576 @@ +` markup carrying the human question. Both halves matter: the tag + * becomes the field, the label text becomes the field's label, and the mail + * template's `[your-name]` references become `{field:…}` merge tags on the new + * form's notification. + * + * @package AllTerrain_Forms + */ + +defined( 'ABSPATH' ) || exit; + +/** + * Registers the importer. + * + * @since 0.2.0 + * + * @param array[] $importers Importer id => definition. + * @return array[] + */ +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', + ); + + return $importers; +} +add_filter( 'atf_importers', 'atf_register_cf7_importer' ); + +/** + * Whether any Contact Form 7 forms exist on this site. + * + * Counted from the posts table rather than asking the plugin, so the data is + * found whether CF7 is active, deactivated or already deleted. + * + * @since 0.2.0 + * + * @return bool + */ +function atf_cf7_available() { + return (bool) atf_cf7_forms(); +} + +/** + * The CF7 forms on this site, id => title. + * + * @since 0.2.0 + * + * @return array + */ +function atf_cf7_forms() { + $posts = get_posts( + array( + 'post_type' => 'wpcf7_contact_form', + 'post_status' => 'any', + // A hard bound, not a page size: a site with more CF7 forms than + // this has other problems, and an unbounded query on a page load + // is how an importer takes the admin down with it. + 'posts_per_page' => 200, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $forms = array(); + + foreach ( $posts as $post ) { + $forms[ (string) $post->ID ] = '' !== $post->post_title ? $post->post_title : __( '(untitled)', 'allterrain-forms' ); + } + + return $forms; +} + +/** + * Imports one CF7 form. + * + * @since 0.2.0 + * + * @param string $source_id The CF7 post id. + * @return int|WP_Error The new form's id. + */ +function atf_cf7_import( $source_id ) { + $post = get_post( absint( $source_id ) ); + + if ( ! $post || 'wpcf7_contact_form' !== $post->post_type ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + $schema = atf_cf7_convert( + (string) get_post_meta( $post->ID, '_form', true ), + (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 ); +} + +/** + * Converts one CF7 form's stored pieces into a schema. + * + * Pure — no database, no capability checks — so the conversion rules can be + * tested against fixture templates without inserting posts. + * + * @since 0.2.0 + * + * @param string $template The `_form` template. + * @param array $mail The `_mail` settings. + * @param array $mail_2 The `_mail_2` settings. + * @param array $messages The `_messages` strings. + * @return array A raw schema, ready for `atf_normalize_schema()`. + */ +function atf_cf7_convert( $template, $mail, $mail_2, $messages ) { + $parsed = atf_cf7_parse_template( $template ); + + $schema = array( + 'fields' => $parsed['fields'], + 'notifications' => array(), + 'confirmations' => array(), + ); + + if ( '' !== $parsed['submit_label'] ) { + $schema['settings'] = array( 'submitLabel' => $parsed['submit_label'] ); + } + + $notification = atf_cf7_convert_mail( $mail, $parsed['map'], __( 'Notification', 'allterrain-forms' ) ); + + if ( $notification ) { + $schema['notifications'][] = $notification; + } + + // Mail (2) only counts when its own switch is on — CF7 stores the settings + // either way, and importing a disabled autoresponder as a live one would + // start e-mailing visitors nobody meant to e-mail. + if ( ! empty( $mail_2['active'] ) ) { + $second = atf_cf7_convert_mail( $mail_2, $parsed['map'], __( 'Mail (2)', 'allterrain-forms' ) ); + + if ( $second ) { + $schema['notifications'][] = $second; + } + } + + if ( ! empty( $messages['mail_sent_ok'] ) ) { + $schema['confirmations'][] = array( + 'name' => __( 'Thank you', 'allterrain-forms' ), + 'type' => 'message', + 'message' => (string) $messages['mail_sent_ok'], + ); + } + + return $schema; +} + +/** + * Parses a CF7 template into fields. + * + * @since 0.2.0 + * + * @param string $template The `_form` template. + * @return array { + * @type array[] $fields The converted fields, in template order. + * @type array $map CF7 field name => new field id. + * @type string $submit_label The `[submit]` tag's label, or ''. + * } + */ +function atf_cf7_parse_template( $template ) { + $fields = array(); + $map = array(); + $submit_label = ''; + $next = 1; + + if ( ! preg_match_all( '/\[([a-zA-Z_][a-zA-Z0-9_]*)(\*)?((?:[^\]"]|"[^"]*")*)\]/', (string) $template, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) { + return array( + 'fields' => $fields, + 'map' => $map, + 'submit_label' => $submit_label, + ); + } + + foreach ( $matches as $match ) { + $type = strtolower( $match[1][0] ); + $required = '' !== $match[2][0]; + $rest = atf_cf7_parse_tag_body( isset( $match[3][0] ) ? $match[3][0] : '' ); + $offset = $match[0][1]; + + if ( 'submit' === $type ) { + $submit_label = isset( $rest['values'][0] ) ? $rest['values'][0] : ''; + continue; + } + + // A closing tag ([/acceptance]) or a tag with no name is not a field. + if ( '' === $rest['name'] || '/' === $type[0] ) { + continue; + } + + $field = atf_cf7_tag_to_field( $type, $required, $rest, "f{$next}" ); + + if ( ! $field ) { + continue; + } + + $field['label'] = atf_cf7_label_for( (string) $template, $offset, $rest['name'] ); + + // Acceptance carries its condition as tag *content* — + // `[acceptance name] I agree… [/acceptance]` — which reads better as + // the consent line than as the label. + if ( 'consent' === $field['type'] ) { + $content = atf_cf7_tag_content( (string) $template, $offset + strlen( $match[0][0] ), 'acceptance' ); + + if ( '' !== $content ) { + $field['consentText'] = $content; + } + } + + $fields[] = $field; + $map[ $rest['name'] ] = $field['id']; + ++$next; + } + + return array( + 'fields' => $fields, + 'map' => $map, + 'submit_label' => $submit_label, + ); +} + +/** + * Splits a tag's body into its name, options and quoted values. + * + * CF7's grammar inside the brackets: the first bare word is the field name, + * further bare words are options (`min:1`, `placeholder`, `multiple`), and + * every double-quoted string is a value — a choice, a default, a label. + * + * @since 0.2.0 + * + * @param string $body Everything between the tag's type and its `]`. + * @return array { name: string, options: string[], values: string[] } + */ +function atf_cf7_parse_tag_body( $body ) { + $name = ''; + $options = array(); + $values = array(); + + preg_match_all( '/"([^"]*)"|(\S+)/', (string) $body, $parts, PREG_SET_ORDER ); + + foreach ( $parts as $part ) { + if ( isset( $part[1] ) && '' !== $part[0] && '"' === $part[0][0] ) { + $values[] = $part[1]; + continue; + } + + if ( '' === $name ) { + $name = $part[2]; + continue; + } + + $options[] = $part[2]; + } + + return array( + 'name' => $name, + 'options' => $options, + 'values' => $values, + ); +} + +/** + * One CF7 option's parameter, e.g. `min:3` => `3`. + * + * @since 0.2.0 + * + * @param string[] $options The tag's bare options. + * @param string $key The option name. + * @return string The part after the colon, or '' when absent. + */ +function atf_cf7_option( $options, $key ) { + foreach ( $options as $option ) { + if ( 0 === strpos( $option, $key . ':' ) ) { + return substr( $option, strlen( $key ) + 1 ); + } + } + + return ''; +} + +/** + * Converts one parsed tag into a field. + * + * @since 0.2.0 + * + * @param string $type The CF7 tag type, without the `*`. + * @param bool $required Whether the tag carried a `*`. + * @param array $rest The parsed body: name, options, values. + * @param string $id The id the new field will take. + * @return array|null The field, or null when the tag has no equivalent. + */ +function atf_cf7_tag_to_field( $type, $required, $rest, $id ) { + $simple = array( + 'text' => 'text', + 'email' => 'email', + 'url' => 'url', + 'tel' => 'tel', + 'textarea' => 'textarea', + 'number' => 'number', + 'range' => 'range', + 'date' => 'date', + 'hidden' => 'hidden', + 'file' => 'file', + ); + + $field = array( + 'id' => $id, + 'required' => $required, + ); + + $options = $rest['options']; + $values = $rest['values']; + + if ( isset( $simple[ $type ] ) ) { + $field['type'] = $simple[ $type ]; + + // The first quoted value is the default — unless the `placeholder` + // option (or its older alias `watermark`) says it is the grey hint + // text instead. CF7 overloads the slot; the option is the tiebreak. + if ( isset( $values[0] ) && '' !== $values[0] ) { + if ( in_array( 'placeholder', $options, true ) || in_array( 'watermark', $options, true ) ) { + $field['placeholder'] = $values[0]; + } else { + $field['default'] = $values[0]; + } + } + + foreach ( array( 'min', 'max', 'minlength', 'maxlength', 'step' ) as $bound ) { + $value = atf_cf7_option( $options, $bound ); + + if ( '' !== $value ) { + $field[ $bound ] = $value; + } + } + + if ( 'file' === $field['type'] ) { + unset( $field['default'], $field['placeholder'] ); + + $filetypes = atf_cf7_option( $options, 'filetypes' ); + + if ( '' !== $filetypes ) { + $field['filetypes'] = array_values( array_filter( array_map( 'trim', explode( '|', strtolower( $filetypes ) ) ) ) ); + } + } + + return $field; + } + + switch ( $type ) { + case 'select': + $field['type'] = in_array( 'multiple', $options, true ) ? 'multiselect' : 'select'; + $field['choices'] = atf_cf7_choices( $values ); + + return $field; + + case 'checkbox': + // An `exclusive` checkbox group allows exactly one answer, which + // is a radio group wearing checkbox clothes. + $field['type'] = in_array( 'exclusive', $options, true ) ? 'radio' : 'checkboxes'; + $field['choices'] = atf_cf7_choices( $values ); + + return $field; + + case 'radio': + $field['type'] = 'radio'; + $field['choices'] = atf_cf7_choices( $values ); + + return $field; + + case 'acceptance': + $field['type'] = 'consent'; + // CF7 acceptance is required by default; the `optional` option is + // how a form relaxes it. + $field['required'] = ! in_array( 'optional', $options, true ); + + return $field; + + case 'quiz': + // CF7's quiz is an anti-spam question, and this plugin already + // screens for spam without charging the visitor for it. + case 'captchac': + case 'captchar': + case 'count': + case 'response': + return null; + } + + // An unrecognised tag — a CF7 add-on's type — becomes a plain text field + // rather than disappearing: a visible field with the right label is + // reviewable in the builder, a silently dropped one is data loss. + $field['type'] = 'text'; + + return $field; +} + +/** + * Turns CF7's quoted choice values into choices. + * + * CF7 supports a `label|value` pipe syntax inside each quoted string; without + * a pipe the label is the value, as ordinary CF7 forms use. + * + * @since 0.2.0 + * + * @param string[] $values The tag's quoted strings. + * @return array[] + */ +function atf_cf7_choices( $values ) { + $choices = array(); + + foreach ( $values as $value ) { + $parts = explode( '|', $value, 2 ); + + $choices[] = array( + 'label' => trim( $parts[0] ), + 'value' => trim( isset( $parts[1] ) ? $parts[1] : $parts[0] ), + ); + } + + return $choices; +} + +/** + * The human label for a tag, read from the markup around it. + * + * CF7's default template wraps each tag as ``, so the text between the opening `