PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / migrator / importers / cf7-importer.php
sureforms / inc / migrator / importers Last commit date
cf7-importer.php 2 months ago gravity-importer.php 2 months ago ninja-importer.php 2 months ago wpforms-importer.php 2 months ago
cf7-importer.php
790 lines
1 <?php
2 /**
3 * Contact Form 7 importer.
4 *
5 * Parses the shortcode-template stored in `wpcf7_contact_form` post meta
6 * `_form` and translates each form-tag into a serialized SureForms block.
7 *
8 * The shortcode-parsing regexes are adapted from Fluent Forms'
9 * `ContactForm7Migrator::formatAsFluentField()` (GPL-2.0+). The block-emit
10 * pipeline is SureForms-specific.
11 *
12 * Source CF7 form-tags supported in v1: text/text*, email/email*, url/url*,
13 * tel/tel*, number/number*, range, date, textarea/textarea*, select/select*,
14 * checkbox/checkbox*, radio, acceptance, submit. Unsupported tags (file,
15 * quiz, captchar, hidden) are flagged via Base_Migrator::note_unsupported().
16 *
17 * @package sureforms
18 * @since 2.11.0
19 */
20
21 namespace SRFM\Inc\Migrator\Importers;
22
23 use SRFM\Inc\Migrator\Base_Migrator;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit;
27 }
28
29 /**
30 * Cf7_Importer
31 *
32 * @since 2.11.0
33 */
34 class Cf7_Importer extends Base_Migrator {
35 /**
36 * Submit-button label parsed from the source form.
37 *
38 * Captured during `build_form_content()` and written to the
39 * `_srfm_submit_button_text` meta in `get_form_metas()` so each migrated
40 * form keeps the original CTA. SureForms auto-renders the submit button
41 * from this meta — it is never a content block.
42 *
43 * @var string
44 */
45 private $submit_label = '';
46
47 /**
48 * Map of CF7 field `name` attribute → final SureForms slug for the current
49 * form. Used by `translate_mail_shortcodes()` to rewrite CF7 mail-template
50 * shortcodes like `[your-name]` into SureForms smart tags `{your-name}`
51 * (or the deduped equivalent).
52 *
53 * Note: slug-collision tracking (`used_slugs` + `reserve_slug()`) lives on
54 * `Base_Migrator` so every importer shares the dedupe logic.
55 *
56 * @var array<string,string>
57 */
58 private $field_slug_map = [];
59
60 /**
61 * Constructor — set source identifiers.
62 *
63 * @since 2.11.0
64 */
65 public function __construct() {
66 $this->key = 'cf7';
67 $this->title = 'Contact Form 7';
68 }
69
70 /**
71 * Whether Contact Form 7 is currently active.
72 *
73 * @since 2.11.0
74 * @return bool
75 */
76 public function exist() {
77 return defined( 'WPCF7_PLUGIN' ) || defined( 'WPCF7_VERSION' );
78 }
79
80 /**
81 * Enumerate all CF7 forms as `wpcf7_contact_form` posts.
82 *
83 * @since 2.11.0
84 * @return array<int,array<string,mixed>>
85 */
86 protected function get_source_forms() {
87 $posts = get_posts(
88 [
89 'post_type' => 'wpcf7_contact_form',
90 'post_status' => [ 'publish', 'draft', 'private' ],
91 'posts_per_page' => -1,
92 'orderby' => 'title',
93 'order' => 'ASC',
94 ]
95 );
96 $out = [];
97 foreach ( $posts as $post ) {
98 $out[] = [
99 'id' => (int) $post->ID,
100 'name' => $post->post_title,
101 ];
102 }
103 return $out;
104 }
105
106 /**
107 * Get the source form id.
108 *
109 * @since 2.11.0
110 *
111 * @param array<string,mixed> $form Form descriptor from get_source_forms().
112 * @return int
113 */
114 protected function get_source_form_id( array $form ) {
115 if ( ! isset( $form['id'] ) || ! is_numeric( $form['id'] ) ) {
116 return 0;
117 }
118 return (int) $form['id'];
119 }
120
121 /**
122 * Get the source form display name.
123 *
124 * @since 2.11.0
125 *
126 * @param array<string,mixed> $form Form descriptor.
127 * @return string
128 */
129 protected function get_source_form_name( array $form ) {
130 $name = isset( $form['name'] ) && is_string( $form['name'] ) ? $form['name'] : '';
131 return '' === $name ? __( 'Untitled Form', 'sureforms' ) : $name;
132 }
133
134 /**
135 * Build SureForms post-meta payload for an imported CF7 form.
136 *
137 * CF7 stores its mail templates in `_mail` / `_mail_2` post meta using
138 * `[field-name]` shortcodes that don't map cleanly to SureForms smart
139 * tags. Rather than ship a half-working translation, we match Fluent
140 * Forms' approach: return a sane default admin notification and a
141 * generic confirmation message. The imported form is fully usable —
142 * users can refine the email body in SureForms' Single Form Settings.
143 *
144 * @since 2.11.0
145 *
146 * @param array<string,mixed> $form Form descriptor.
147 * @return array<string,mixed> SureForms meta_input payload.
148 */
149 protected function get_form_metas( array $form ) {
150 $title = $this->get_source_form_name( $form );
151 $post_id = $this->get_source_form_id( $form );
152
153 $cf7_mail = $post_id ? get_post_meta( $post_id, '_mail', true ) : [];
154 $cf7_mail = is_array( $cf7_mail ) ? $cf7_mail : [];
155 $cf7_messages = $post_id ? get_post_meta( $post_id, '_messages', true ) : [];
156 $cf7_messages = is_array( $cf7_messages ) ? $cf7_messages : [];
157
158 $default_subject = sprintf(
159 /* translators: %s: form title from the source CF7 form. */
160 __( 'New submission: %s', 'sureforms' ),
161 $title
162 );
163
164 $notification = [
165 'id' => 1,
166 'status' => true,
167 'is_raw_format' => false,
168 'name' => __( 'Admin Notification Email', 'sureforms' ),
169 'email_to' => $this->translate_recipient( $cf7_mail['recipient'] ?? '', '{admin_email}' ),
170 'email_reply_to' => '{admin_email}',
171 'from_name' => '{site_title}',
172 'from_email' => '{admin_email}',
173 'email_cc' => '',
174 'email_bcc' => '',
175 'subject' => $this->translate_mail_shortcodes( $cf7_mail['subject'] ?? '', $default_subject ),
176 'email_body' => $this->translate_mail_shortcodes( $cf7_mail['body'] ?? '', '{all_data}' ),
177 ];
178
179 $mail_sent_ok = isset( $cf7_messages['mail_sent_ok'] ) ? trim( (string) $cf7_messages['mail_sent_ok'] ) : '';
180 $confirmation = [
181 'id' => 1,
182 'confirmation_type' => 'same page',
183 'page_url' => '',
184 'custom_url' => '',
185 'message' => '' !== $mail_sent_ok ? wp_kses_post( $mail_sent_ok ) : $this->default_confirmation_message(),
186 'submission_action' => 'hide form',
187 ];
188
189 $metas = [
190 '_srfm_email_notification' => [ $notification ],
191 '_srfm_form_confirmation' => [ $confirmation ],
192 ];
193
194 // CF7 [submit "Label"] → SureForms submit-button text. SureForms renders
195 // the submit button from this meta, so no button block is added to the
196 // post content (see Base_Migrator::import_forms).
197 if ( '' !== $this->submit_label ) {
198 $metas['_srfm_submit_button_text'] = $this->submit_label;
199 }
200
201 return $metas;
202 }
203
204 /**
205 * Parse a CF7 form's shortcode template into SureForms block markup.
206 *
207 * Pipeline:
208 * 1. Read `_form` post meta.
209 * 2. Split into per-line tokens.
210 * 3. Strip `<label>` wrappers, extract labels, drop quiz tags.
211 * 4. Per form-tag: regex out attributes, map to srfm/* block.
212 *
213 * @since 2.11.0
214 *
215 * @param array<string,mixed> $form Form descriptor.
216 * @return string Concatenated field block markup.
217 */
218 protected function build_form_content( array $form ) {
219 $this->submit_label = '';
220 $this->used_slugs = [];
221 $this->field_slug_map = [];
222 $post_id = $this->get_source_form_id( $form );
223 if ( ! $post_id ) {
224 return '';
225 }
226 $raw = get_post_meta( $post_id, '_form', true );
227 if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
228 return '';
229 }
230 /**
231 * Filters the raw CF7 template string before parsing.
232 *
233 * Lets add-on importers (e.g. SureForms Pro) rewrite the source
234 * template — for instance to replace `[step]` Multi-Step markers with
235 * synthetic field tags that the rest of the pipeline can map to a
236 * SureForms block.
237 *
238 * @since 2.11.0
239 *
240 * @param string $raw Raw CF7 `_form` template.
241 * @param string $key Migrator source key (always `cf7` here).
242 * @param array<string,mixed> $form Source form descriptor.
243 */
244 $raw = (string) apply_filters( 'srfm_migrator_preprocess_template', $raw, $this->key, $form );
245 $lines = preg_split( '/\r\n|\r|\n/', $raw );
246 $lines = is_array( $lines ) ? $lines : [];
247 $lines = $this->strip_labels_and_blanks( $lines );
248 $tag_blobs = $this->collect_tag_blobs( $lines );
249 $content = '';
250 foreach ( $tag_blobs as $blob ) {
251 // A single line can carry several form-tags (e.g. two-column layouts
252 // like `[text* first-name] [text* last-name]`). Split into one
253 // sub-blob per tag so every field is emitted, not just the first.
254 foreach ( $this->split_blob_into_tag_blobs( $blob ) as $sub_blob ) {
255 $markup = $this->build_field_from_tag_blob( $sub_blob );
256 if ( '' !== $markup ) {
257 $content .= $markup;
258 }
259 }
260 }
261 return $content;
262 }
263
264 /**
265 * Map a CF7 form-tag name (without the trailing `*`) to a SureForms block
266 * template method on Block_Templates.
267 *
268 * @since 2.11.0
269 *
270 * @return array<string,string>
271 */
272 private function tag_to_template_map() {
273 $map = [
274 'text' => 'input',
275 'email' => 'email',
276 'url' => 'url',
277 'tel' => 'phone',
278 'number' => 'number',
279 'range' => 'number',
280 'date' => 'input',
281 'textarea' => 'textarea',
282 'select' => 'dropdown',
283 // CF7 [checkbox] is always a multi-option group, so it maps to
284 // srfm/multi-choice in multi-select mode — NOT srfm/checkbox, which
285 // is SureForms' single on/off checkbox and carries no options.
286 'checkbox' => 'multi_choice',
287 'radio' => 'multi_choice',
288 'acceptance' => 'gdpr',
289 ];
290 /**
291 * Filters the CF7-tag → Block_Templates-method map.
292 *
293 * Lets add-on importers (e.g. SureForms Pro) overlay extra mappings
294 * such as `['date' => 'date_picker', 'file' => 'upload', 'hidden' =>
295 * 'hidden_field', 'range' => 'slider']`. The corresponding method
296 * must be emitted by a `srfm_migrator_block_template` subscriber.
297 *
298 * @since 2.11.0
299 *
300 * @param array<string,string> $map Tag name → Block_Templates method.
301 * @param string $key Migrator source key (`cf7`).
302 */
303 return (array) apply_filters( 'srfm_migrator_tag_to_template_map', $map, $this->key );
304 }
305
306 /**
307 * Translate a CF7 mail-template string into a SureForms smart-tag string.
308 *
309 * Rewrites two flavors of CF7 shortcodes:
310 * - Field references — `[your-name]` becomes `{your-name}` (or the
311 * deduped slug from `$this->field_slug_map`).
312 * - CF7 system tags — `[_post_title]` → `{post_title}`, `[_user_email]`
313 * → `{email_address}`, and friends. Unknown system tags are dropped.
314 *
315 * Empty input returns the supplied fallback so callers get a sane default.
316 *
317 * @since 2.11.0
318 *
319 * @param string $body Raw CF7 mail template.
320 * @param string $fallback Returned verbatim when `$body` is blank.
321 * @return string Translated body, or fallback.
322 */
323 private function translate_mail_shortcodes( $body, $fallback = '' ) {
324 $body = (string) $body;
325 if ( '' === trim( $body ) ) {
326 return $fallback;
327 }
328
329 // CF7 system tags → SureForms smart tags. Only tags with a direct
330 // counterpart are mapped; unknown tags are stripped at the end.
331 $system_map = [
332 '_post_title' => '{post_title}',
333 '_post_url' => '{post_url}',
334 '_post_id' => '{post_id}',
335 '_post_author' => '{author_name}',
336 '_user_email' => '{email_address}',
337 '_user_login' => '{username}',
338 '_user_agent' => '{user_agent}',
339 '_remote_ip' => '{ip_address}',
340 '_url' => '{current_url}',
341 '_date' => '{date}',
342 '_time' => '{time}',
343 '_site_title' => '{site_title}',
344 '_site_url' => '{site_url}',
345 ];
346 foreach ( $system_map as $cf7 => $srfm ) {
347 $body = str_replace( '[' . $cf7 . ']', $srfm, $body );
348 }
349
350 // Field references — only translate tokens that match a known CF7 name.
351 // The callback returns the matched literal when there's no mapping so
352 // unrelated bracket text (e.g. "[Reserved]") survives.
353 if ( ! empty( $this->field_slug_map ) ) {
354 $body = preg_replace_callback(
355 '/\[([a-zA-Z0-9_\-]+)\]/',
356 function ( $m ) {
357 $name = $m[1];
358 if ( isset( $this->field_slug_map[ $name ] ) ) {
359 return '{' . $this->field_slug_map[ $name ] . '}';
360 }
361 return $m[0];
362 },
363 $body
364 );
365 }
366
367 return is_string( $body ) ? $body : $fallback;
368 }
369
370 /**
371 * Translate the CF7 mail recipient string into a SureForms `email_to`
372 * value. Falls back to `{admin_email}` when the recipient is empty or
373 * uses unrecognised tokens (so the imported form still routes to a real
374 * inbox even when the source template referenced `[_user_email]` for an
375 * auto-reply mail that didn't get ported).
376 *
377 * @since 2.11.0
378 *
379 * @param string $recipient CF7 `_mail.recipient` value.
380 * @param string $fallback Returned when recipient is unusable.
381 * @return string
382 */
383 private function translate_recipient( $recipient, $fallback ) {
384 $recipient = trim( (string) $recipient );
385 if ( '' === $recipient ) {
386 return $fallback;
387 }
388 $translated = $this->translate_mail_shortcodes( $recipient, $fallback );
389 // Sanity check: if translation produced empty or only smart tags without
390 // a concrete address, keep the fallback so the notification is deliverable.
391 if ( '' === trim( $translated ) ) {
392 return $fallback;
393 }
394 return $translated;
395 }
396
397 /**
398 * Remove `<label>...</label>` wrappers and blank lines, keep the label text
399 * inline so the next pass can pair it with the form-tag.
400 *
401 * @since 2.11.0
402 *
403 * @param array<int,string> $lines Raw template lines.
404 * @return array<int,string>
405 */
406 private function strip_labels_and_blanks( array $lines ) {
407 $out = [];
408 foreach ( $lines as $line ) {
409 $line = trim( $line );
410 if ( '' === $line ) {
411 continue;
412 }
413 if ( false !== strpos( $line, '<label>' ) || false !== strpos( $line, '</label>' ) ) {
414 $line = trim( str_replace( [ '<label>', '</label>' ], '', $line ) );
415 }
416 if ( '' === $line ) {
417 continue;
418 }
419 $out[] = $line;
420 }
421 return $out;
422 }
423
424 /**
425 * Walk the cleaned lines and produce per-tag blob strings of the form
426 * `Label text [text* name "default" placeholder "foo"]`.
427 *
428 * @since 2.11.0
429 *
430 * @param array<int,string> $lines Cleaned template lines.
431 * @return array<int,string>
432 */
433 private function collect_tag_blobs( array $lines ) {
434 $out = [];
435 $count = count( $lines );
436 for ( $i = 0; $i < $count; $i++ ) {
437 $line = $lines[ $i ];
438 // Skip CF7 quiz tags — no SureForms equivalent.
439 if ( 0 === strpos( $line, '[quiz' ) ) {
440 $this->note_unsupported( 'Quiz' );
441 continue;
442 }
443 // CF7 Multi-Step Forms addon — `[step ...]` wraps groups of fields.
444 // Without explicit support, the step boundaries silently flatten into
445 // a single SureForms form, hiding navigation logic from the user.
446 if ( 0 === strpos( $line, '[step' ) || 0 === strpos( $line, '[/step' ) ) {
447 $this->note_unsupported( __( 'Multi-Step Forms addon', 'sureforms' ) );
448 continue;
449 }
450 // CF7 Conditional Fields addon — `[group ...]` wraps conditional groups.
451 // As with [step], silently dropping these would produce a form whose
452 // behaviour differs from the source. Flag it once per form.
453 if ( 0 === strpos( $line, '[group' ) || 0 === strpos( $line, '[/group' ) ) {
454 $this->note_unsupported( __( 'Conditional Fields addon', 'sureforms' ) );
455 continue;
456 }
457 $bracket_pos = strpos( $line, '[' );
458 if ( false === $bracket_pos ) {
459 // Line has only label text; pair with next line if it holds a tag.
460 if ( isset( $lines[ $i + 1 ] ) && false !== strpos( $lines[ $i + 1 ], '[' ) ) {
461 $out[] = $line . ' ' . $lines[ $i + 1 ];
462 ++$i;
463 }
464 continue;
465 }
466 // Inline label + tag on the same line.
467 $out[] = $line;
468 }
469 return $out;
470 }
471
472 /**
473 * Split a blob that may hold several form-tags into one sub-blob per field.
474 *
475 * Two-column CF7 templates put multiple tags on one line
476 * (`[text* first-name] [text* last-name]`); resolving only the first tag
477 * silently dropped the rest. Each sub-blob keeps the label text that
478 * precedes its tag. Block-syntax tags with a matching closing tag
479 * (e.g. `[acceptance id] I agree [/acceptance]`) are kept whole, and bare
480 * closing tags are absorbed rather than treated as new fields.
481 *
482 * @since 2.11.0
483 *
484 * @param string $blob One cleaned template blob.
485 * @return array<int,string> One sub-blob per opening form-tag (>= 1 entry).
486 */
487 private function split_blob_into_tag_blobs( $blob ) {
488 if ( ! preg_match_all( '/\[[^\]]+\]/', $blob, $m, PREG_OFFSET_CAPTURE ) ) {
489 return [ $blob ];
490 }
491 $tokens = $m[0];
492 // Only one bracket token: nothing to split, preserve original behaviour.
493 if ( count( $tokens ) < 2 ) {
494 return [ $blob ];
495 }
496
497 $out = [];
498 $cursor = 0; // Start of the current field's label region.
499 foreach ( $tokens as $token ) {
500 $token_str = (string) $token[0];
501 $token_off = (int) $token[1];
502 $token_end = $token_off + strlen( $token_str );
503
504 // Bare closing tag (`[/acceptance]`) — absorbed by its opener below.
505 if ( 0 === strpos( $token_str, '[/' ) ) {
506 continue;
507 }
508
509 // If a matching closing tag follows, extend the field through it so
510 // block-syntax tags stay intact as a single field.
511 $field_end = $token_end;
512 if ( preg_match( '/^\[\s*([A-Za-z0-9_-]+)/', $token_str, $nm ) ) {
513 $close = '[/' . strtolower( $nm[1] ) . ']';
514 $close_at = stripos( $blob, $close, $token_end );
515 if ( false !== $close_at ) {
516 $field_end = $close_at + strlen( $close );
517 }
518 }
519
520 $sub = trim( substr( $blob, $cursor, $field_end - $cursor ) );
521 if ( '' !== $sub ) {
522 $out[] = $sub;
523 }
524 $cursor = $field_end;
525 }
526
527 return ! empty( $out ) ? $out : [ $blob ];
528 }
529
530 /**
531 * Translate one tag blob into a SureForms block. Handles `[submit]` by
532 * stashing the label rather than emitting a block.
533 *
534 * @since 2.11.0
535 *
536 * @param string $blob One tag blob (label + [shortcode]).
537 * @return string Block markup, or '' if not emitted.
538 */
539 private function build_field_from_tag_blob( $blob ) {
540 // Extract label text (everything before the first `[`).
541 $label = '';
542 if ( preg_match( '/^(.*?)\[/', $blob, $m ) ) {
543 // CF7 templates often wrap field rows in `<p>...</p>` or `<label>...</label>`;
544 // strip any HTML so wrapper markup doesn't bleed into the rendered field label.
545 $label = trim( wp_strip_all_tags( $m[1] ) );
546 }
547
548 // Extract the tag body (between the brackets).
549 if ( ! preg_match( '/\[([^\]]+)\]/', $blob, $m ) ) {
550 return '';
551 }
552 $body = trim( $m[1] );
553 $parts = preg_split( '/\s+/', $body );
554 if ( ! is_array( $parts ) || empty( $parts ) ) {
555 return '';
556 }
557
558 $head = (string) $parts[0];
559 $required = '*' === substr( $head, -1 );
560 $tag_name = rtrim( $head, '*' );
561 $tag_name = strtolower( $tag_name );
562
563 // Handle `[submit "Send"]` — capture label, no block emitted.
564 if ( 'submit' === $tag_name ) {
565 if ( preg_match_all( '/(["\'])(.*?)\1/', $body, $matches ) && ! empty( $matches[2] ) ) {
566 $this->submit_label = (string) $matches[2][0];
567 }
568 return '';
569 }
570
571 /**
572 * Filters the list of CF7 tags that have no SureForms equivalent.
573 *
574 * Add-on importers (e.g. SureForms Pro) can drop entries from this
575 * list when they ship a block that covers the tag — Pro removes
576 * `file` and `hidden` because it provides `srfm/upload` and
577 * `srfm/hidden`.
578 *
579 * @since 2.11.0
580 *
581 * @param array<int,string> $tags Lower-cased CF7 tag names.
582 * @param string $key Migrator source key (`cf7`).
583 */
584 $unsupported_tags = (array) apply_filters(
585 'srfm_migrator_unsupported_tags',
586 [ 'file', 'captchar', 'hidden' ],
587 $this->key
588 );
589 if ( in_array( $tag_name, $unsupported_tags, true ) ) {
590 $this->note_unsupported( '' !== $label ? $label : $tag_name );
591 return '';
592 }
593
594 $template_map = $this->tag_to_template_map();
595 if ( ! isset( $template_map[ $tag_name ] ) ) {
596 $this->note_unsupported( '' !== $label ? $label : $tag_name );
597 return '';
598 }
599 $template_method = $template_map[ $tag_name ];
600
601 $attrs = $this->extract_tag_attrs( $body, $tag_name );
602 $field_label = '' !== $label ? $label : ucfirst( $tag_name );
603
604 // Acceptance: the quoted text becomes the consent HTML, but reserve the
605 // slug from the original `name` attribute so [acceptance accept-this ...]
606 // reserves "accept-this", not the long consent sentence.
607 $slug_seed = $field_label;
608 $cf7_name = isset( $parts[1] ) ? (string) $parts[1] : '';
609 // CF7 field name is the 2nd token unless that token is an option (`size:`, `min:`, …).
610 if ( '' !== $cf7_name && false === strpos( $cf7_name, ':' ) && '"' !== $cf7_name[0] && "'" !== $cf7_name[0] ) {
611 $slug_seed = $cf7_name;
612 }
613 $resolved_slug = $this->reserve_slug( $slug_seed );
614 if ( '' !== $cf7_name ) {
615 $this->field_slug_map[ $cf7_name ] = $resolved_slug;
616 }
617
618 $args = [
619 'label' => $field_label,
620 'slug' => $resolved_slug,
621 'required' => $required,
622 'placeholder' => $attrs['placeholder'],
623 'default_value' => $attrs['default'],
624 'min' => $attrs['min'],
625 'max' => $attrs['max'],
626 'min_length' => $attrs['minlength'],
627 'max_length' => $attrs['maxlength'],
628 'options' => $attrs['choices'],
629 'multiple' => $attrs['multiple'],
630 ];
631
632 // CF7 [date] → srfm/input (plain text). SureForms has no native date
633 // field, so this is a best-effort text mapping — the value still
634 // imports, it just isn't a date picker.
635
636 // Acceptance consent text becomes the GDPR label. CF7 supports two
637 // syntaxes: inline `[acceptance id "I agree"]` (quoted token) and block
638 // `[acceptance id] I agree [/acceptance]` (text between the tags).
639 if ( 'acceptance' === $tag_name ) {
640 if ( ! empty( $attrs['quoted'] ) ) {
641 $args['label'] = (string) $attrs['quoted'][0];
642 } elseif ( preg_match( '/\[acceptance[^\]]*\](.*?)\[\/acceptance\]/s', $blob, $cm ) ) {
643 $consent = trim( wp_strip_all_tags( $cm[1] ) );
644 if ( '' !== $consent ) {
645 $args['label'] = $consent;
646 }
647 }
648 }
649
650 // CF7 [checkbox] is a multi-option group → render as multi-select
651 // (srfm/multi-choice with singleSelection:false).
652 if ( 'checkbox' === $tag_name ) {
653 $args['multiple'] = true;
654 }
655
656 // Multi-select for select tag with `multiple` attribute.
657 if ( 'select' === $tag_name && $attrs['multiple'] ) {
658 $args['multiple'] = true;
659 }
660
661 $fallback_label = '' !== $label ? $label : $tag_name;
662 $markup = $this->dispatch_template( $template_method, $args );
663 if ( '' === $markup ) {
664 /**
665 * Filters the markup for a template method this importer doesn't
666 * know about, allowing add-ons (e.g. SureForms Pro) to emit blocks
667 * for new template names registered via
668 * `srfm_migrator_tag_to_template_map`.
669 *
670 * Subscribers should return the serialized Gutenberg block string
671 * for the given `$method`+`$args`, or an empty string to fall
672 * through to the unsupported-fields warning.
673 *
674 * @since 2.11.0
675 *
676 * @param string $markup Default empty string.
677 * @param string $method Template method name.
678 * @param array<string,mixed> $args Block args.
679 * @param string $key Migrator source key (`cf7`).
680 */
681 $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $template_method, $args, $this->key );
682 }
683 if ( '' === $markup ) {
684 $this->note_unsupported( $fallback_label );
685 }
686 return $markup;
687 }
688
689 /**
690 * Match a CF7 `name:value` option, accepting bare, double- or single-quoted
691 * values so multi-word options like `default:"Hello World"` aren't dropped.
692 *
693 * @since 2.11.0
694 *
695 * @param string $name CF7 option name (e.g. `default`, `min`, `max`).
696 * @param string $body Tag body (without the surrounding brackets).
697 * @return string Matched value, or '' if the option is absent.
698 */
699 private function match_tag_option( $name, $body ) {
700 $pattern = '/\b' . preg_quote( $name, '/' ) . ':(?:"([^"]*)"|\'([^\']*)\'|([^\s"\']+))/';
701 if ( ! preg_match( $pattern, $body, $m ) ) {
702 return '';
703 }
704 if ( isset( $m[1] ) && '' !== $m[1] ) {
705 return $m[1];
706 }
707 if ( isset( $m[2] ) && '' !== $m[2] ) {
708 return $m[2];
709 }
710 return $m[3] ?? '';
711 }
712
713 /**
714 * Extract attribute tokens from a CF7 form-tag body.
715 *
716 * Mirrors the attribute syntax documented at
717 * https://contactform7.com/tag-syntax/ — single-token attrs (`autocomplete:foo`)
718 * and quoted-list attrs (`"opt 1" "opt 2"`).
719 *
720 * @since 2.11.0
721 *
722 * @param string $body Tag body (without the surrounding brackets).
723 * @param string $tag_name Lower-cased tag name (without trailing `*`).
724 * @return array{placeholder:string,default:string,min:string,max:string,minlength:string,maxlength:string,step:string,choices:array<int,string>,quoted:array<int,string>,multiple:bool,autocomplete:string}
725 */
726 private function extract_tag_attrs( $body, $tag_name ) {
727 $attrs = [
728 'placeholder' => '',
729 'default' => '',
730 'min' => '',
731 'max' => '',
732 'minlength' => '',
733 'maxlength' => '',
734 'step' => '',
735 'choices' => [],
736 'quoted' => [],
737 'multiple' => false,
738 'autocomplete' => '',
739 ];
740
741 $attrs['min'] = $this->match_tag_option( 'min', $body );
742 $attrs['max'] = $this->match_tag_option( 'max', $body );
743 $attrs['default'] = $this->match_tag_option( 'default', $body );
744 if ( preg_match( '/\bminlength:([0-9]+)/', $body, $m ) ) {
745 $attrs['minlength'] = $m[1];
746 }
747 if ( preg_match( '/\bmaxlength:([0-9]+)/', $body, $m ) ) {
748 $attrs['maxlength'] = $m[1];
749 }
750 if ( preg_match( '/\bstep:([0-9.]+)/', $body, $m ) ) {
751 $attrs['step'] = $m[1];
752 }
753 if ( preg_match( '/(?:placeholder|watermark)\s+"([^"]+)"/', $body, $m ) ) {
754 $attrs['placeholder'] = $m[1];
755 }
756 if ( preg_match( '/\bautocomplete:([A-Za-z0-9_-]+)/', $body, $m ) ) {
757 $attrs['autocomplete'] = $m[1];
758 }
759 if ( false !== strpos( $body, ' multiple' ) || preg_match( '/\bmultiple\b/', $body ) ) {
760 $attrs['multiple'] = true;
761 }
762
763 // Capture every quoted string in the body for radio/checkbox/select/acceptance.
764 if ( preg_match_all( '/(["\'])(.*?)\1/', $body, $matches ) && ! empty( $matches[2] ) ) {
765 $attrs['quoted'] = array_values( $matches[2] );
766 }
767
768 // Choice-bearing tags (select / radio / checkbox) — the trailing
769 // quoted strings are the option labels.
770 $choice_tags = [ 'select', 'radio', 'checkbox' ];
771 if ( in_array( $tag_name, $choice_tags, true ) ) {
772 // `placeholder "foo"` is captured separately; remove it from choices.
773 $choices = $attrs['quoted'];
774 if ( '' !== $attrs['placeholder'] ) {
775 $choices = array_values(
776 array_filter(
777 $choices,
778 static function ( $c ) use ( $attrs ) {
779 return $c !== $attrs['placeholder'];
780 }
781 )
782 );
783 }
784 $attrs['choices'] = $choices;
785 }
786
787 return $attrs;
788 }
789 }
790