Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.18% covered (warning)
67.18%
131 / 195
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Util
67.18% covered (warning)
67.18%
131 / 195
46.67% covered (danger)
46.67%
7 / 15
129.46
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 register_pattern
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
3
 grunion_contact_form_set_block_template_attribute
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 grunion_contact_form_set_block_template_part_id_global
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 grunion_contact_form_unset_block_template_part_id_global
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 grunion_contact_form_suspend_block_template_id_in_post_content
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 grunion_contact_form_restore_block_template_id_after_post_content
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 grunion_contact_form_filter_widget_block_content
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 grunion_delete_old_spam
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
4.09
 grunion_delete_old_temp_feedback
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 jetpack_tracks_record_grunion_pre_message_sent
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 grunion_contact_form_apply_block_attribute
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 modify_contact_form_blocks_recursive
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_export_filename
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_add_colon_to_label
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Contact_Form_Util class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Status\Host;
11
12/**
13 * This class serves as a container for what previously were standalone grunion functions.
14 * In the long term we should aim to move things to other classes and gradually get rid of this rather than adding more.
15 */
16class Util {
17
18    /**
19     * Saved values of the block_template global while core/post-content blocks render.
20     *
21     * A stack so nested or sequential post-content renders (e.g. a query loop) restore
22     * correctly. @see grunion_contact_form_suspend_block_template_id_in_post_content().
23     *
24     * @var array
25     */
26    private static $block_template_id_suspended = array();
27
28    /**
29     * Registers all relevant actions and filters for this class.
30     */
31    public static function init() {
32        add_filter( 'template_include', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_attribute' );
33
34        add_action( 'render_block_core_template_part_post', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
35        add_action( 'render_block_core_template_part_file', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
36        add_action( 'render_block_core_template_part_none', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
37        add_action( 'gutenberg_render_block_core_template_part_post', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
38        add_action( 'gutenberg_render_block_core_template_part_file', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
39        add_action( 'gutenberg_render_block_core_template_part_none', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_set_block_template_part_id_global' );
40
41        add_filter( 'render_block', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_unset_block_template_part_id_global', 10, 2 );
42        add_filter( 'widget_block_content', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_filter_widget_block_content', 1, 3 );
43
44        // Suspend the block_template global while core/post-content renders, so forms in the post
45        // body are attributed to the post (and gated on the author), not to the template.
46        add_filter( 'pre_render_block', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_suspend_block_template_id_in_post_content', 10, 2 );
47        add_filter( 'render_block', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_contact_form_restore_block_template_id_after_post_content', 10, 2 );
48
49        // Registered at bootstrap, not on `init`, so has_editor_feature_flag() is right for callers
50        // that run earlier (#48080). Paid-plan flags stay in Contact_Form_Block::register_feature().
51        add_filter( 'jetpack_block_editor_feature_flags', '\Automattic\Jetpack\Extensions\Contact_Form\Contact_Form_Block::register_central_form_management_default' );
52
53        add_action( 'init', '\Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin::init', 9 );
54        add_action( 'grunion_scheduled_delete', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_delete_old_spam' );
55        add_action( 'grunion_scheduled_delete_temp', '\Automattic\Jetpack\Forms\ContactForm\Util::grunion_delete_old_temp_feedback' );
56        add_action( 'grunion_pre_message_sent', '\Automattic\Jetpack\Forms\ContactForm\Util::jetpack_tracks_record_grunion_pre_message_sent', 12, 3 );
57    }
58
59    /**
60     * Registers contact form block patterns.
61     */
62    public static function register_pattern() {
63        $category_slug = 'forms';
64        register_block_pattern_category( $category_slug, array( 'label' => __( 'Forms', 'jetpack-forms' ) ) );
65
66        $patterns = array(
67            'contact-form'         => array(
68                'title'      => __( 'Contact Form', 'jetpack-forms' ),
69                'blockTypes' => array( 'jetpack/contact-form' ),
70                'categories' => array( $category_slug ),
71                'content'    => '<!-- wp:jetpack/contact-form -->
72                    <div class="wp-block-jetpack-contact-form">
73                        <!-- wp:jetpack/field-name {"required":true} /-->
74                        <!-- wp:jetpack/field-email {"required":true} /-->
75                        <!-- wp:jetpack/field-textarea /-->
76                        <!-- wp:button {"tagName":"button","type":"submit"} -->
77                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Contact us</button></div>
78                        <!-- /wp:button -->
79                    </div>
80                    <!-- /wp:jetpack/contact-form -->',
81            ),
82            'newsletter-form'      => array(
83                'title'      => __( 'Lead Capture Form', 'jetpack-forms' ),
84                'blockTypes' => array( 'jetpack/contact-form' ),
85                'categories' => array( $category_slug ),
86                'content'    => '<!-- wp:jetpack/contact-form -->
87                    <div class="wp-block-jetpack-contact-form">
88                        <!-- wp:jetpack/field-name {"required":true} /-->
89                        <!-- wp:jetpack/field-email {"required":true} /-->
90                        <!-- wp:jetpack/field-consent /-->
91                        <!-- wp:button {"tagName":"button","type":"submit"} -->
92                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Subscribe</button></div>
93                        <!-- /wp:button -->
94                    </div>
95                    <!-- /wp:jetpack/contact-form -->',
96            ),
97            'rsvp-form'            => array(
98                'title'      => __( 'RSVP Form', 'jetpack-forms' ),
99                'blockTypes' => array( 'jetpack/contact-form' ),
100                'categories' => array( $category_slug ),
101                'content'    => '<!-- wp:jetpack/contact-form {"subject":"A new RSVP from your website"} -->
102                    <div class="wp-block-jetpack-contact-form">
103                        <!-- wp:jetpack/field-name {"required":true} /-->
104                        <!-- wp:jetpack/field-email {"required":true} /-->
105                        <!-- wp:jetpack/field-radio {"label":"Attending?","required":true,"options":["Yes","No"]} /-->
106                        <!-- wp:jetpack/field-textarea {"label":"Other Details"} /-->
107                        <!-- wp:button {"tagName":"button","type":"submit"} -->
108                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Send RSVP</button></div>
109                        <!-- /wp:button -->
110                    </div>
111                    <!-- /wp:jetpack/contact-form -->',
112            ),
113            'registration-form'    => array(
114                'title'      => __( 'Registration Form', 'jetpack-forms' ),
115                'blockTypes' => array( 'jetpack/contact-form' ),
116                'categories' => array( $category_slug ),
117                'content'    => '<!-- wp:jetpack/contact-form {"subject":"A new registration from your website"} -->
118                    <div class="wp-block-jetpack-contact-form">
119                        <!-- wp:jetpack/field-name {"required":true} /-->
120                        <!-- wp:jetpack/field-email {"required":true} /-->
121                        <!-- wp:jetpack/field-telephone {"label":"Phone Number"} /-->
122                        <!-- wp:jetpack/field-select {"label":"How did you hear about us?","options":["Search Engine","Social Media","TV","Radio","Friend or Family"]} /-->
123                        <!-- wp:jetpack/field-textarea {"label":"Other Details"} /-->
124                        <!-- wp:button {"tagName":"button","type":"submit"} -->
125                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Send</button></div>
126                        <!-- /wp:button -->
127                    </div>
128                    <!-- /wp:jetpack/contact-form -->',
129            ),
130            'appointment-form'     => array(
131                'title'      => __( 'Appointment Form', 'jetpack-forms' ),
132                'blockTypes' => array( 'jetpack/contact-form' ),
133                'categories' => array( $category_slug ),
134                'content'    => '<!-- wp:jetpack/contact-form {"subject":"A new appointment booked from your website"} -->
135                    <div class="wp-block-jetpack-contact-form">
136                        <!-- wp:jetpack/field-name {"required":true} /-->
137                        <!-- wp:jetpack/field-email {"required":true} /-->
138                        <!-- wp:jetpack/field-telephone {"required":true} /-->
139                        <!-- wp:jetpack/field-date {"label":"Date","required":true} /-->
140                        <!-- wp:jetpack/field-radio {"label":"Time","required":true,"options":["Morning","Afternoon"]} /-->
141                        <!-- wp:jetpack/field-textarea {"label":"Notes"} /-->
142                        <!-- wp:button {"tagName":"button","type":"submit"} -->
143                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Book Appointment</button></div>
144                        <!-- /wp:button -->
145                    </div>
146                    <!-- /wp:jetpack/contact-form -->',
147            ),
148            'feedback-form'        => array(
149                'title'      => __( 'Feedback Form', 'jetpack-forms' ),
150                'blockTypes' => array( 'jetpack/contact-form' ),
151                'categories' => array( $category_slug ),
152                'content'    => '<!-- wp:jetpack/contact-form {"subject":"New feedback received from your website"} -->
153                    <div class="wp-block-jetpack-contact-form">
154                        <!-- wp:jetpack/field-name {"required":true} /-->
155                        <!-- wp:jetpack/field-email {"required":true} /-->
156                        <!-- wp:jetpack/field-rating {"required":true} -->
157                            <div><!-- wp:jetpack/label {"label":"Please rate our website"} /-->
158                        <!-- wp:jetpack/input-rating /--></div>
159                        <!-- /wp:jetpack/field-rating -->
160                        <!-- wp:jetpack/field-textarea {"label":"How could we improve?"} /-->
161                        <!-- wp:button {"tagName":"button","type":"submit"} -->
162                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Send Feedback</button></div>
163                        <!-- /wp:button -->
164                    </div>
165                    <!-- /wp:jetpack/contact-form -->',
166            ),
167            'salesforce-lead-form' => array(
168                'title'      => __( 'Salesforce Lead Form', 'jetpack-forms' ),
169                'blockTypes' => array( 'jetpack/contact-form' ),
170                'categories' => array( $category_slug ),
171                'content'    => '<!-- wp:jetpack/contact-form {"formTitle":"Salesforce Lead Form"} -->
172                    <div class="wp-block-jetpack-contact-form">
173                        <!-- wp:jetpack/field-name {"label":"First Name","required":true,"id":"first_name"} /-->
174                        <!-- wp:jetpack/field-name {"label":"Last Name","required":true,"id":"last_name"} /-->
175                        <!-- wp:jetpack/field-email {"label":"Email","required":true,"id":"email"} /-->
176                        <!-- wp:jetpack/field-telephone {"label":"Phone","id":"phone"} /-->
177                        <!-- wp:jetpack/field-text {"label":"Company","id":"company"} /-->
178                        <!-- wp:jetpack/field-text {"label":"Job Title","id":"title"} /-->
179                        <!-- wp:button {"tagName":"button","type":"submit"} -->
180                            <div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Submit</button></div>
181                        <!-- /wp:button -->
182                    </div>
183                    <!-- /wp:jetpack/contact-form -->',
184            ),
185        );
186
187        /*
188         * The Lead Capture pattern is the twin of the 'lead-capture-form' block variation, which is
189         * hidden on WordPress.com Simple and WoA sites: there the Subscribe block owns newsletter
190         * signups, and a form with a Subscribe button subscribes nobody. Drop the pattern on those
191         * hosts too, so it isn't the last remaining route into a form that looks like a signup and
192         * silently isn't one.
193         */
194        if ( ( new Host() )->is_wpcom_platform() ) {
195            unset( $patterns['newsletter-form'] );
196        }
197
198        foreach ( $patterns as $name => $pattern ) {
199            register_block_pattern( $name, $pattern );
200        }
201    }
202
203    /**
204     * Sets the 'block_template' attribute on all instances of wp:jetpack/contact-form in
205     * the $_wp_current_template_content global variable.
206     *
207     * The $_wp_current_template_content global variable is hydrated immediately prior to
208     * 'template_include' in wp-includes/template-loader.php.
209     *
210     * This fixes Contact Form Blocks added to FSE _templates_ (e.g. Single or 404).
211     *
212     * @param string $template Template to be loaded.
213     */
214    public static function grunion_contact_form_set_block_template_attribute( $template ) {
215        global $_wp_current_template_content;
216        if ( ! is_string( $template ) ) {
217            return $template;
218        }
219
220        if ( 'template-canvas.php' === basename( $template ) ) {
221            Contact_Form::style_on();
222            $_wp_current_template_content = self::grunion_contact_form_apply_block_attribute(
223                $_wp_current_template_content,
224                array(
225                    'block_template' => 'canvas',
226                )
227            );
228
229            // Mark that we are rendering a block template, so forms in the template chrome are
230            // attributed to it. This global is the trusted signal Feedback_Source::get_current()
231            // uses for the block_template source type (the content attribute is not trusted). It
232            // is suspended while core/post-content renders so a form in the post body is not
233            // mistaken for a template-authored form.
234            global $_wp_current_template_id;
235            $GLOBALS['grunion_block_template_id'] = ! empty( $_wp_current_template_id ) ? $_wp_current_template_id : 'canvas';
236        }
237        return $template;
238    }
239
240    /**
241     * Sets the $grunion_block_template_part_id global.
242     *
243     * This is part of the fix for Contact Form Blocks added to FSE _template parts_ (e.g footer).
244     * The global is processed in Contact_Form::parse().
245     *
246     * @param string $template_part_id ID for the currently rendered template part.
247     */
248    public static function grunion_contact_form_set_block_template_part_id_global( $template_part_id ) {
249        $GLOBALS['grunion_block_template_part_id'] = $template_part_id;
250    }
251
252    /**
253     * Unsets the global when block is done rendering.
254     *
255     * @param string $content Rendered block content.
256     * @param array  $block   The full block, including name and attributes.
257     * @return string
258     */
259    public static function grunion_contact_form_unset_block_template_part_id_global( $content, $block ) {
260        if ( isset( $block['blockName'] )
261            && 'core/template-part' === $block['blockName']
262            && isset( $GLOBALS['grunion_block_template_part_id'] ) ) {
263            unset( $GLOBALS['grunion_block_template_part_id'] );
264        }
265        return $content;
266    }
267
268    /**
269     * Suspends the block_template global while a core/post-content block renders.
270     *
271     * The core/post-content block renders the post body, which may contain a contact form
272     * authored by a user without edit_theme_options. Such a form must be attributed to the post
273     * (and gated on the post author), not to the surrounding template, so the trusted
274     * block_template signal is removed for the duration of the render and restored afterwards.
275     *
276     * Hooked on `pre_render_block`; returns its first argument unchanged so rendering proceeds.
277     *
278     * @param string|null $pre_render   The pre-rendered content. Default null.
279     * @param array       $parsed_block The block being rendered.
280     * @return string|null Unchanged $pre_render.
281     */
282    public static function grunion_contact_form_suspend_block_template_id_in_post_content( $pre_render, $parsed_block ) {
283        if ( isset( $parsed_block['blockName'] ) && 'core/post-content' === $parsed_block['blockName'] ) {
284            self::$block_template_id_suspended[] = $GLOBALS['grunion_block_template_id'] ?? null;
285            unset( $GLOBALS['grunion_block_template_id'] );
286        }
287        return $pre_render;
288    }
289
290    /**
291     * Restores the block_template global once a core/post-content block has finished rendering.
292     *
293     * Counterpart to grunion_contact_form_suspend_block_template_id_in_post_content(). Hooked on
294     * `render_block`; returns the block content unchanged.
295     *
296     * @param string $content Rendered block content.
297     * @param array  $block   The full block, including name and attributes.
298     * @return string Unchanged $content.
299     */
300    public static function grunion_contact_form_restore_block_template_id_after_post_content( $content, $block ) {
301        if ( isset( $block['blockName'] )
302            && 'core/post-content' === $block['blockName']
303            && ! empty( self::$block_template_id_suspended ) ) {
304            $restored = array_pop( self::$block_template_id_suspended );
305            if ( null !== $restored ) {
306                $GLOBALS['grunion_block_template_id'] = $restored;
307            }
308        }
309        return $content;
310    }
311
312    /**
313     * Sets the 'widget' attribute on all instances of the contact form in the widget block.
314     *
315     * @param string           $content  Existing widget block content.
316     * @param array            $instance Array of settings for the current widget.
317     * @param \WP_Widget_Block $widget   Current Block widget instance.
318     * @return string
319     */
320    public static function grunion_contact_form_filter_widget_block_content( $content, $instance, $widget ) {
321        Contact_Form::style_on();
322        // Inject 'block_template' => <widget-id> into all instances of the contact form block.
323        return self::grunion_contact_form_apply_block_attribute(
324            $content,
325            array(
326                'widget' => $widget->id,
327            )
328        );
329    }
330
331    /**
332     * Deletes old spam feedbacks to keep the posts table size under control.
333     */
334    public static function grunion_delete_old_spam() {
335        global $wpdb;
336
337        $grunion_delete_limit = 100;
338
339        $now_gmt = current_time( 'mysql', true );
340        // Use the spam status changed date if available, otherwise fall back to post_date_gmt for backward compatibility
341        $sql      = $wpdb->prepare(
342            "
343            SELECT p.`ID`
344            FROM $wpdb->posts p
345            LEFT JOIN $wpdb->postmeta pm ON p.`ID` = pm.`post_id` AND pm.`meta_key` = '_spam_status_changed_gmt'
346            WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > COALESCE( pm.`meta_value`, p.`post_date_gmt` )
347                AND p.`post_type` = 'feedback'
348                AND p.`post_status` = 'spam'
349            LIMIT %d
350        ",
351            $now_gmt,
352            $grunion_delete_limit
353        );
354        $post_ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
355
356        foreach ( (array) $post_ids as $post_id ) {
357            // force a full delete, skip the trash
358            wp_delete_post( $post_id, true );
359        }
360
361        if (
362            /**
363             * Filter if the module run OPTIMIZE TABLE on the core WP tables.
364             *
365             * @module contact-form
366             *
367             * @since 1.3.1
368             * @since 6.4.0 Set to false by default.
369             *
370             * @param bool $filter Should Jetpack optimize the table, defaults to false.
371             */
372            apply_filters( 'grunion_optimize_table', false )
373        ) {
374            $wpdb->query( "OPTIMIZE TABLE $wpdb->posts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
375        }
376
377        // if we hit the max then schedule another run
378        if ( count( $post_ids ) >= $grunion_delete_limit ) {
379            wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
380        }
381    }
382
383    /**
384     * Deletes old temp feedback to keep the posts table size under control.
385     *
386     * @since 6.5.0
387     */
388    public static function grunion_delete_old_temp_feedback() {
389        global $wpdb;
390
391        $grunion_delete_limit = 100;
392
393        $now_gmt = current_time( 'mysql', true );
394        $sql     = $wpdb->prepare(
395            "
396            SELECT `ID`
397            FROM $wpdb->posts
398            WHERE DATE_SUB( %s, INTERVAL 1 DAY ) > `post_date_gmt`
399                AND `post_type` = 'feedback'
400                AND `post_status` = 'jp-temp-feedback'
401            LIMIT %d
402        ",
403            $now_gmt,
404            $grunion_delete_limit
405        );
406
407        // The SQL query is already prepared with $wpdb->prepare() above, and direct query is needed for performance-critical cleanup operation
408        $post_ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
409
410        foreach ( (array) $post_ids as $post_id ) {
411            // force a full delete, skip the trash
412            wp_delete_post( $post_id, true );
413        }
414
415        if (
416            /**
417             * Filter if the module run OPTIMIZE TABLE on the core WP tables.
418             *
419             * @module contact-form
420             *
421             * @since 6.5.0
422             *
423             * @param bool $filter Should Jetpack optimize the table, defaults to false.
424             */
425            apply_filters( 'grunion_optimize_table', false )
426        ) {
427            // OPTIMIZE TABLE is a MySQL-specific maintenance command that cannot be prepared and is only run when explicitly enabled via filter
428            $wpdb->query( "OPTIMIZE TABLE $wpdb->posts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
429        }
430
431        // if we hit the max then schedule another run
432        if ( count( $post_ids ) >= $grunion_delete_limit ) {
433            wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete_temp' );
434        }
435    }
436
437    /**
438     * Send an event to Tracks on form submission.
439     *
440     * @param int   $post_id - the post_id for the CPT that is created.
441     * @param array $all_values - array containing all form fields.
442     * @param array $extra_values - array containing extra form metadata.
443     *
444     * @return null|void
445     */
446    public static function jetpack_tracks_record_grunion_pre_message_sent( $post_id, $all_values = array(), $extra_values = array() ) {
447        $post = get_post( $post_id );
448        if ( $post ) {
449            $extra = gmdate( 'Y-W', strtotime( $post->post_date_gmt ) );
450        } else {
451            $extra = 'no-post';
452        }
453
454        /** This action is documented in jetpack/modules/widgets/social-media-icons.php */
455        do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_message_sent', $extra );
456
457        $form_type = isset( $extra_values['widget'] ) ? 'widget' : 'block';
458
459        $context = '';
460        if ( isset( $extra_values['block_template'] ) ) {
461            $context = 'template';
462        } elseif ( isset( $extra_values['block_template_part'] ) ) {
463            $context = 'template_part';
464        }
465
466        $plugin = Contact_Form_Plugin::init();
467
468        $plugin->record_tracks_event(
469            'jetpack_forms_message_sent',
470            array(
471                'post_id'     => $post_id,
472                'form_type'   => $form_type,
473                'context'     => $context,
474                'has_consent' => empty( $all_values['email_marketing_consent'] ) ? 0 : 1,
475            )
476        );
477    }
478
479    /**
480     * Adds a given attribute to all instances of the Contact Form block.
481     *
482     * @param string $content  Existing content to process.
483     * @param array  $new_attr New attributes to add.
484     * @return string
485     */
486    public static function grunion_contact_form_apply_block_attribute( $content, $new_attr ) {
487        if ( ! is_string( $content ) ) {
488            // If the content is not a string, we cannot process it.
489            return $content;
490        }
491
492        if ( false === stripos( $content, 'wp:jetpack/contact-form' ) ) {
493            return $content;
494        }
495
496        // Parse blocks using WordPress core function.
497        $blocks = parse_blocks( $content );
498
499        // Recursively modify contact form blocks.
500        $modified_blocks = self::modify_contact_form_blocks_recursive( $blocks, $new_attr );
501
502        // Serialize back to block markup.
503        return serialize_blocks( $modified_blocks );
504    }
505
506    /**
507     * Recursively modifies contact form blocks to add new attributes.
508     *
509     * @param array $blocks    Array of parsed blocks.
510     * @param array $new_attr  New attributes to add.
511     * @return array Modified blocks array.
512     */
513    private static function modify_contact_form_blocks_recursive( $blocks, $new_attr ) {
514        foreach ( $blocks as &$block ) {
515            // Check if this is a contact form block.
516            if ( 'jetpack/contact-form' === $block['blockName'] ) {
517                // Merge new attributes with existing ones.
518                $block['attrs'] = array_merge(
519                    $block['attrs'] ?? array(),
520                    $new_attr
521                );
522            }
523
524            // Recursively process inner blocks.
525            if ( ! empty( $block['innerBlocks'] ) ) {
526                $block['innerBlocks'] = self::modify_contact_form_blocks_recursive(
527                    $block['innerBlocks'],
528                    $new_attr
529                );
530            }
531        }
532
533        return $blocks;
534    }
535
536    /**
537     * Get a filename for export tasks
538     *
539     * @param string $source The filtered source for exported data.
540     * @return string The filename without source nor date suffix.
541     */
542    public static function get_export_filename( $source = '' ) {
543        return $source === ''
544            ? sprintf(
545                /* translators: Site title, used to craft the export filename, eg "MySite - Jetpack Form Responses" */
546                __( '%s - Jetpack Form Responses', 'jetpack-forms' ),
547                sanitize_file_name( get_bloginfo( 'name' ) )
548            )
549            : sprintf(
550                /* translators: 1: Site title; 2: post title. Used to craft the export filename, eg "MySite - Jetpack Form Responses - Contact" */
551                __( '%1$s - Jetpack Form Responses - %2$s', 'jetpack-forms' ),
552                sanitize_file_name( get_bloginfo( 'name' ) ),
553                sanitize_file_name( html_entity_decode( $source, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) )
554            );
555    }
556
557    /**
558     * Ensures a field label ends with a colon, unless it ends with a question mark.
559     *
560     * @param string $label The field label.
561     * @return string The formatted label.
562     */
563    public static function maybe_add_colon_to_label( $label ) {
564        $formatted_label = $label ? $label : '';
565        // Special case for the Terms consent field block which a period after the label.
566        $formatted_label = str_ends_with( $formatted_label, '?' ) ? $formatted_label : rtrim( $formatted_label, ':.' ) . ':';
567
568        return $formatted_label;
569    }
570}