Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.81% covered (danger)
43.81%
680 / 1552
28.16% covered (danger)
28.16%
29 / 103
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form_Plugin
43.74% covered (danger)
43.74%
678 / 1550
28.16% covered (danger)
28.16%
29 / 103
52654.34
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 daily_akismet_meta_cleanup
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 strip_tags
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 __construct
89.55% covered (warning)
89.55%
120 / 134
0.00% covered (danger)
0.00%
0 / 1
15.26
 has_editor_feature_flag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 remove_from_related_posts_allowed_post_types
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 disable_forms_view_script_concat
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 register_contact_form_blocks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_block_support_classes_and_styles
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
10
 get_block_style_classes
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
5.33
 block_attributes_to_shortcode_attributes
51.53% covered (warning)
51.53%
84 / 163
0.00% covered (danger)
0.00%
0 / 1
347.11
 get_image_option_letter
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 reset_step
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_form_step
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 gutenblock_render_form_step_navigation
87.93% covered (warning)
87.93%
51 / 58
0.00% covered (danger)
0.00%
0 / 1
19.63
 gutenblock_render_form_progress_indicator
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
182
 get_style_variation_shortcode_attributes
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
8.35
 gutenblock_render_field_text
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_name
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_email
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_url
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_date
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_telephone
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 gutenblock_render_field_textarea
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_checkbox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_checkbox_multiple
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_option
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_radio
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_select
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_consent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 gutenblock_render_field_file
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_dropzone
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 gutenblock_render_field_hidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_field_number
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_time
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_image_select
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 admin_menu
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 allow_feedback_rest_api_type
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 unread_count
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
19
 get_unread_count_badge_markup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_unread_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recalculate_unread_count
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 process_form_submission
21.68% covered (danger)
21.68%
31 / 143
0.00% covered (danger)
0.00%
0 / 1
1154.95
 ajax_request
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
110
 validate_parent_post
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 insert_feedback_filter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 add_shortcode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 tokenize_label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_value
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 format_value_for_display
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 replace_tokens_with_input
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 track_current_widget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 track_current_widget_before
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 track_current_widget_after
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_current_widget_context
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 widget_atts
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 widget_shortcode_hack
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 is_spam_blocklist
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_in_disallowed_list
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 prepare_for_akismet
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 is_spam_akismet
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
156
 akismet_submit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 form_posts_dropdown
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 get_post_content_for_csv_export
n/a
0 / 0
n/a
0 / 0
1
 get_post_meta_for_csv_export
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
240
 get_parsed_field_contents_of_post
n/a
0 / 0
n/a
0 / 0
1
 map_parsed_field_contents_of_post_to_field_names
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 register_personal_data_exporter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 register_personal_data_eraser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 personal_data_exporter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 internal_personal_data_exporter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 internal_personal_data_formater
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
5
 personal_data_eraser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 _internal_personal_data_eraser
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 personal_data_post_ids_by_email
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 set_pde_email_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 personal_data_search_filter
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
6.03
 get_export_feedback_data
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 format_feedback_data_for_csv
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 get_export_data_for_posts
n/a
0 / 0
n/a
0 / 0
1
 get_well_known_column_names
n/a
0 / 0
n/a
0 / 0
1
 get_feedback_entries_from_post
65.08% covered (warning)
65.08%
41 / 63
0.00% covered (danger)
0.00%
0 / 1
48.53
 download_feedback_as_csv
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 create_new_form
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
90
 record_tracks_event
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 esc_csv
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_all_parent_post_ids
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 get_feedbacks_as_options
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 get_field_names
n/a
0 / 0
n/a
0 / 0
3
 has_json_data
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 parse_feedback_content
n/a
0 / 0
n/a
0 / 0
10
 parse_fields_from_content
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 make_csv_row_from_feedback
n/a
0 / 0
n/a
0 / 0
6
 get_ip_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 use_block_editor_for_post_type
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 use_block_editor_for_post
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 restrict_feedback_comments_to_logged_in
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 reverse_that_print
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
9
 untrash_feedback_status_handler
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 track_spam_status_change
n/a
0 / 0
n/a
0 / 0
3
 track_feedback_status_change
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 purge_edge_cache_on_form_status_change
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 track_spam_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 track_recount_unread
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 can_use_analytics
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 parse_menu_item
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
15.53
 gutenblock_render_field_rating
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 gutenblock_render_field_slider
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 redirect_edit_feedback_to_jetpack_forms
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 validate_export_to_gdrive_request
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 export_to_gdrive
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2/**
3 * Contact_Form_Plugin class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\Extensions\Contact_Form\Contact_Form_Block;
13use Automattic\Jetpack\Forms\Dashboard\Dashboard;
14use Automattic\Jetpack\Forms\Editor\Form_Editor;
15use Automattic\Jetpack\Forms\Jetpack_Forms;
16use Automattic\Jetpack\Forms\Service\Form_Webhooks;
17use Automattic\Jetpack\Forms\Service\Google_Drive;
18use Automattic\Jetpack\Forms\Service\Hostinger_Reach_Integration;
19use Automattic\Jetpack\Forms\Service\MailPoet_Integration;
20use Automattic\Jetpack\Forms\Service\Post_To_Url;
21use Automattic\Jetpack\Status;
22use Automattic\Jetpack\Terms_Of_Service;
23use Automattic\Jetpack\Tracking;
24use Jetpack_Options;
25use WP_Block;
26use WP_Block_Patterns_Registry;
27use WP_Block_Type_Registry;
28use WP_Error;
29use WP_Post;
30
31// Load the Form_Submission_Error class.
32require_once __DIR__ . '/class-form-submission-error.php';
33
34// Load the Form_Preview class.
35require_once __DIR__ . '/class-form-preview.php';
36
37/**
38 * Sets up various actions, filters, post types, post statuses, shortcodes.
39 */
40class Contact_Form_Plugin {
41
42    /**
43     *
44     * The Widget ID of the widget currently being processed.  Used to build the unique contact-form ID for forms embedded in widgets.
45     *
46     * @var string
47     */
48    public $current_widget_id;
49
50    /**
51     * The Sidebar ID of the sidebar currently being processed.  Used to build the unique contact-form ID for forms embedded in sidebars.
52     *
53     * @var string
54     */
55    public $current_sidebar_id;
56
57    /**
58     * If the contact form field is being used.
59     *
60     * @var bool
61     */
62    public static $using_contact_form_field = false;
63
64    /**
65     *
66     * The last Feedback Post ID Erased as part of the Personal Data Eraser.
67     * Helps with pagination.
68     *
69     * @var int
70     */
71    private $pde_last_post_id_erased = 0;
72
73    /**
74     *
75     * The email address for which we are deleting/exporting all feedbacks
76     * as part of a Personal Data Eraser or Personal Data Exporter request.
77     *
78     * @var string
79     */
80    private $pde_email_address = '';
81
82    /**
83     * The number of steps in the form.
84     *
85     * This is used to determine how many steps are in the form when using the multi-step feature.
86     * It is incremented each time a new step is added.
87     *
88     * @var int
89     */
90    public static $step_count = 0;
91
92    /*
93     * Field keys that might be present in the entry json but we don't want to show to the admin
94     * since they not something that the visitor entered into the form.
95     *
96     * @var array
97     */
98    const NON_PRINTABLE_FIELDS = array(
99        'entry_title'             => '',
100        'email_marketing_consent' => '',
101        'entry_permalink'         => '',
102        'entry_page'              => '',
103        'feedback_id'             => '',
104    );
105
106    /**
107     * GDrive export nonce field name
108     *
109     * @var string The nonce field name for GDrive export.
110     */
111    private $export_nonce_field_gdrive = 'feedback_export_nonce_gdrive';
112
113    /**
114     * Initializing function.
115     */
116    public static function init() {
117        static $instance = false;
118
119        if ( ! $instance ) {
120            $instance = new Contact_Form_Plugin();
121
122            // Schedule our daily cleanup
123            add_action( 'wp_scheduled_delete', array( $instance, 'daily_akismet_meta_cleanup' ) );
124        }
125
126        return $instance;
127    }
128
129    /**
130     * Runs daily to clean up spam detection metadata after 15 days.  Keeps your DB squeaky clean.
131     */
132    public function daily_akismet_meta_cleanup() {
133        global $wpdb;
134
135        $feedback_ids = $wpdb->get_col( "SELECT p.ID FROM {$wpdb->posts} as p INNER JOIN {$wpdb->postmeta} as m on m.post_id = p.ID WHERE p.post_type = 'feedback' AND m.meta_key = '_feedback_akismet_values' AND DATE_SUB(NOW(), INTERVAL 15 DAY) > p.post_date_gmt LIMIT 10000" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
136
137        if ( empty( $feedback_ids ) ) {
138            return;
139        }
140
141        /**
142         * Fires right before deleting the _feedback_akismet_values post meta on $feedback_ids
143         *
144         * @module contact-form
145         *
146         * @since 6.1.0
147         *
148         * @param array $feedback_ids list of feedback post ID
149         */
150        do_action( 'jetpack_daily_akismet_meta_cleanup_before', $feedback_ids );
151        foreach ( $feedback_ids as $feedback_id ) {
152            delete_post_meta( $feedback_id, '_feedback_akismet_values' );
153        }
154
155        /**
156         * Fires right after deleting the _feedback_akismet_values post meta on $feedback_ids
157         *
158         * @module contact-form
159         *
160         * @since 6.1.0
161         *
162         * @param array $feedback_ids list of feedback post ID
163         */
164        do_action( 'jetpack_daily_akismet_meta_cleanup_after', $feedback_ids );
165    }
166
167    /**
168     * Strips HTML tags from input.  Output is NOT HTML safe.
169     *
170     * @param mixed $data_with_tags - data we're stripping HTML tags from.
171     * @return mixed
172     */
173    public static function strip_tags( $data_with_tags ) {
174        $data_without_tags = array();
175        if ( is_array( $data_with_tags ) ) {
176            foreach ( $data_with_tags as $index => $value ) {
177                if ( is_array( $value ) ) {
178                    $data_without_tags[ $index ] = self::strip_tags( $value );
179                    continue;
180                }
181
182                $index = sanitize_text_field( (string) $index );
183                $value = wp_kses_post( (string) $value );
184                $value = str_replace( '&amp;', '&', $value ); // undo damage done by wp_kses_normalize_entities()
185
186                $data_without_tags[ $index ] = $value;
187            }
188        } else {
189            $data_without_tags = wp_kses_post( (string) $data_with_tags );
190            $data_without_tags = str_replace( '&amp;', '&', $data_without_tags ); // undo damage done by wp_kses_normalize_entities()
191        }
192
193        return $data_without_tags;
194    }
195
196    /**
197     * Class uses singleton pattern; use Contact_Form_Plugin::init() to initialize.
198     */
199    protected function __construct() {
200        $this->add_shortcode();
201
202        // While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
203        add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
204        add_action( 'dynamic_sidebar_before', array( $this, 'track_current_widget_before' ) );
205        add_action( 'dynamic_sidebar_after', array( $this, 'track_current_widget_after' ) );
206
207        // If Text Widgets don't get shortcode processed, hack ours into place.
208        if (
209            version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' )
210            && ! has_filter( 'widget_text', 'do_shortcode' )
211        ) {
212            add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 );
213        }
214
215        add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_blocklist' ), 10, 2 );
216        add_filter( 'jetpack_contact_form_in_comment_disallowed_list', array( $this, 'is_in_disallowed_list' ), 10, 2 );
217        // Akismet to the rescue
218        if ( defined( 'AKISMET_VERSION' ) || function_exists( 'akismet_http_post' ) ) {
219            add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_akismet' ), 10, 2 );
220            add_action( 'contact_form_akismet', array( $this, 'akismet_submit' ), 10, 2 );
221        }
222
223        add_action( 'loop_start', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'style_on' ) );
224        add_action( 'pre_amp_render_post', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'style_on' ) );
225
226        add_action( 'wp_ajax_grunion-contact-form', array( $this, 'ajax_request' ) );
227        add_action( 'wp_ajax_nopriv_grunion-contact-form', array( $this, 'ajax_request' ) );
228
229        // GDPR: personal data exporter & eraser.
230        add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
231        add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
232
233        // Export to CSV feature
234        if ( is_admin() ) {
235            add_action( 'wp_ajax_feedback_export', array( $this, 'download_feedback_as_csv' ) );
236            add_action( 'wp_ajax_create_new_form', array( $this, 'create_new_form' ) );
237            add_action( 'wp_ajax_grunion_export_to_gdrive', array( $this, 'export_to_gdrive' ) );
238        }
239        add_action( 'admin_menu', array( $this, 'admin_menu' ) );
240        add_action( 'current_screen', array( $this, 'unread_count' ) );
241        add_action( 'current_screen', array( $this, 'redirect_edit_feedback_to_jetpack_forms' ) );
242
243        add_filter( 'use_block_editor_for_post_type', array( $this, 'use_block_editor_for_post_type' ), 10, 2 );
244        add_filter( 'use_block_editor_for_post', array( $this, 'use_block_editor_for_post' ), 10, 2 );
245
246        // Restrict feedback comments to logged-in users only
247        add_filter( 'comments_open', array( $this, 'restrict_feedback_comments_to_logged_in' ), 10, 2 );
248
249        // custom post type we'll use to keep copies of the feedback items
250        register_post_type(
251            'feedback',
252            array(
253                'labels'                 => array(
254                    'name'               => __( 'Form Responses', 'jetpack-forms' ),
255                    'singular_name'      => __( 'Form Responses', 'jetpack-forms' ),
256                    'search_items'       => __( 'Search Responses', 'jetpack-forms' ),
257                    'not_found'          => __( 'No responses found', 'jetpack-forms' ),
258                    'not_found_in_trash' => __( 'No responses found', 'jetpack-forms' ),
259                ),
260                'menu_icon'              => 'dashicons-feedback',
261                // when the legacy menu item is retired, we don't want to show the default post type listing
262                'show_ui'                => false,
263                'show_in_menu'           => false,
264                'show_in_admin_bar'      => false,
265                'public'                 => false,
266                'rewrite'                => false,
267                'query_var'              => false,
268                'capability_type'        => 'page',
269                'show_in_rest'           => true,
270                'rest_controller_class'  => '\Automattic\Jetpack\Forms\ContactForm\Contact_Form_Endpoint',
271                'supports'               => array( 'comments' ),
272                'default_comment_status' => 'open',
273                'capabilities'           => array(
274                    'create_posts'        => 'do_not_allow',
275                    'publish_posts'       => 'publish_pages',
276                    'edit_posts'          => 'edit_pages',
277                    'edit_others_posts'   => 'edit_others_pages',
278                    'delete_posts'        => 'delete_pages',
279                    'delete_others_posts' => 'delete_others_pages',
280                    'read_private_posts'  => 'read_private_pages',
281                    'edit_post'           => 'edit_page',
282                    'delete_post'         => 'delete_page',
283                    'read_post'           => 'read_page',
284                ),
285                'map_meta_cap'           => true,
286            )
287        );
288        add_filter( 'wp_untrash_post_status', array( $this, 'untrash_feedback_status_handler' ), 10, 3 );
289
290        // Add to REST API post type allowed list.
291        add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_feedback_rest_api_type' ) );
292
293        // Don't let related posts hook into feedback post type.
294        add_filter( 'jetpack_related_posts_rest_api_allowed_post_types', array( $this, 'remove_from_related_posts_allowed_post_types' ) );
295
296        // Add "spam" as a post status
297        register_post_status(
298            'spam',
299            array(
300                'label'                  => 'Spam',
301                'public'                 => false,
302                'exclude_from_search'    => true,
303                'show_in_admin_all_list' => false,
304                // translators: The spam count.
305                'label_count'            => _n_noop( 'Spam <span class="count">(%s)</span>', 'Spam <span class="count">(%s)</span>', 'jetpack-forms' ),
306                'protected'              => true,
307                '_builtin'               => false,
308            )
309        );
310
311        // Add "jp-temp-feedback" as a post status for temporary storage when saveResponses is 'no'.
312        // We want these responses skip the inbox but we still need to keep them in the database so that
313        // filters and integrations continue to work.
314        register_post_status(
315            'jp-temp-feedback',
316            array(
317                'label'                  => 'Temporary Feedback Status',
318                'public'                 => false,
319                'internal'               => true,
320                'exclude_from_search'    => true,
321                'show_in_admin_all_list' => false,
322                'protected'              => true,
323                '_builtin'               => false,
324            )
325        );
326
327        // Track when post status changes to feedback posts types.
328        add_action( 'transition_post_status', array( $this, 'track_feedback_status_change' ), 10, 3 );
329
330        // Purge edge cache when a jetpack_form post is published, updated, or unpublished.
331        add_action( 'transition_post_status', array( $this, 'purge_edge_cache_on_form_status_change' ), 10, 3 );
332
333        // POST handler
334        if (
335            isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
336            &&
337            isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action'] // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verification should happen when hook fires.
338            &&
339            isset( $_POST['contact-form-id'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes
340        ) {
341            add_action( 'template_redirect', array( $this, 'process_form_submission' ) );
342        }
343
344        /*
345         * Can be dequeued by placing the following in wp-content/themes/yourtheme/functions.php
346         *
347         *  function remove_grunion_style() {
348         *      wp_deregister_style('grunion.css');
349         *  }
350         *  add_action('wp_print_styles', 'remove_grunion_style');
351         */
352        wp_register_style( 'grunion.css', Jetpack_Forms::plugin_url() . '../dist/contact-form/css/grunion.css', array(), \JETPACK__VERSION );
353        wp_style_add_data( 'grunion.css', 'rtl', 'replace' );
354
355        wp_register_style(
356            'jetpack-forms-layout',
357            Jetpack_Forms::plugin_url() . '../dist/contact-form/css/jetpack-forms-layout.css',
358            array(),
359            \JETPACK__VERSION
360        );
361
362        wp_register_style(
363            'jetpack-form-status-notice',
364            Jetpack_Forms::plugin_url() . '../dist/contact-form/css/form-status-notice.css',
365            array(),
366            \JETPACK__VERSION
367        );
368
369        add_filter( 'js_do_concat', array( __CLASS__, 'disable_forms_view_script_concat' ), 10, 3 );
370
371        if ( defined( 'JETPACK__PLUGIN_DIR' ) ) {
372            // Register Unauthenticated file download hooks.
373            require_once JETPACK__PLUGIN_DIR . 'unauth-file-upload.php';
374        }
375
376        self::register_contact_form_blocks();
377
378        // Register MailPoet integration hook after the class is loaded.
379        if ( Jetpack_Forms::is_mailpoet_enabled() ) {
380            add_action(
381                'grunion_after_feedback_post_inserted',
382                array( MailPoet_Integration::class, 'handle_mailpoet_integration' ),
383                15,
384                4
385            );
386        }
387
388        // Register Hostinger Reach integration hook after the class is loaded.
389        if ( Jetpack_Forms::is_hostinger_reach_enabled() ) {
390            add_action(
391                'grunion_after_feedback_post_inserted',
392                array( Hostinger_Reach_Integration::class, 'handle_hostinger_reach_integration' ),
393                16,
394                4
395            );
396        }
397
398        if ( self::has_editor_feature_flag( 'central-form-management' ) ) {
399            Contact_Form::register_post_type();
400            Form_Editor::init();
401            Form_Preview::init();
402        }
403    }
404
405    /**
406     * Check if a feature flag is enabled.
407     *
408     * @param string $flag The feature flag to check.
409     * @return bool
410     */
411    public static function has_editor_feature_flag( $flag ) {
412        /** This filter is documented in jetpack/class.jetpack-gutenberg.php. */
413        $feature_flags = apply_filters( 'jetpack_block_editor_feature_flags', array() );
414        return ! empty( $feature_flags[ $flag ] );
415    }
416    /**
417     * Remove feedback post type from the allowed post types for related posts.
418     *
419     * @param array $post_types The allowed post types.
420     * @return array The allowed post types.
421     */
422    public static function remove_from_related_posts_allowed_post_types( $post_types ) {
423        return array_diff( $post_types, array( 'feedback' ) );
424    }
425
426    /**
427     * Prevent 'jp-forms-view' script from being concatenated.
428     *
429     * @param array  $do_concat - the concatenation flag.
430     * @param string $handle - script name.
431     */
432    public static function disable_forms_view_script_concat( $do_concat, $handle ) {
433        if ( 'jp-forms-view' === $handle ) {
434            $do_concat = false;
435        }
436        return $do_concat;
437    }
438
439    /**
440     * Register the contact form block.
441     */
442    private static function register_contact_form_blocks() {
443        Contact_Form_Block::register_block();
444        // Field render methods.
445        Contact_Form_Block::register_child_blocks();
446    }
447
448    /**
449     * Generate block support CSS classes and inline styles for block supports
450     * via the style engine.
451     *
452     * @param string $block_name - the block name.
453     * @param array  $attrs      - the block attributes.
454     * @param array  $options    - the types of support to apply.
455     *
456     * @return array
457     */
458    private static function get_block_support_classes_and_styles( $block_name, $attrs, $options = array() ) {
459        $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
460
461        if ( ! $block_type ) {
462            return array();
463        }
464
465        $default_options = array( 'color', 'typography', 'border', 'custom', 'spacing' );
466        $enabled_options = empty( $options ) ? $default_options : $options;
467
468        // Leverage the individual core block support functions to generate classes and styles.
469        $color_styles      = in_array( 'color', $enabled_options, true ) ? \wp_apply_colors_support( $block_type, $attrs ) : array();
470        $typography_styles = in_array( 'typography', $enabled_options, true ) ? \wp_apply_typography_support( $block_type, $attrs ) : array();
471        $border_styles     = in_array( 'border', $enabled_options, true ) ? \wp_apply_border_support( $block_type, $attrs ) : array();
472        $custom_classname  = in_array( 'custom', $enabled_options, true ) ? \wp_apply_custom_classname_support( $block_type, $attrs ) : array();
473        $spacing_styles    = in_array( 'spacing', $enabled_options, true ) ? \wp_apply_spacing_support( $block_type, $attrs ) : array();
474
475        // Merge all the block support classes and styles.
476        $classes = array_filter(
477            array(
478                $color_styles['class'] ?? '',
479                $typography_styles['class'] ?? '',
480                $border_styles['class'] ?? '',
481                $custom_classname['class'] ?? '',
482                $spacing_styles['class'] ?? '',
483            ),
484            'strlen'
485        );
486
487        $styles = array_filter(
488            array(
489                $color_styles['style'] ?? '',
490                $typography_styles['style'] ?? '',
491                $border_styles['style'] ?? '',
492                $spacing_styles['style'] ?? '',
493            ),
494            'strlen'
495        );
496
497        $merged_styles = array();
498
499        if ( ! empty( $classes ) ) {
500            $merged_styles['class'] = implode( ' ', $classes );
501        }
502        if ( ! empty( $styles ) ) {
503            $merged_styles['style'] = implode( ' ', $styles );
504        }
505
506        return $merged_styles;
507    }
508
509    /**
510     * Returns an array containing the field classes (including -wrap classes), the remaining classes without block style classes.
511     * The wrap classes are used for the wrapper div around the field.
512     *
513     * @param string $classname The class name.
514     *
515     * @return array {
516     *     @type string $fieldwrapperclasses         Classes that should be added to the field wrapper.
517     *     @type string $classes_without_block_style The remaining classes without block style classes, intended for internal field controls.
518     * }
519     */
520    private static function get_block_style_classes( $classname = '' ) {
521        if ( ! $classname ) {
522            return array(
523                'fieldwrapperclasses' => '',
524                'classes'             => '',
525            );
526        }
527
528        $field_wrapper_classes       = '';
529        $classes_without_block_style = '';
530
531        preg_match_all( '/is-style-([^\s]+)/i', $classname, $matches );
532
533        $block_style_classes = empty( $matches[0] ) ? '' : implode( ' ', $matches[0] );
534
535        if ( ! empty( $block_style_classes ) ) {
536            $wrap_classes          = ! empty( $matches[0] ) ? ' ' . implode( '-wrap ', array_filter( $matches[0] ) ) . '-wrap' : '';
537            $field_wrapper_classes = " $block_style_classes $wrap_classes";
538        }
539
540        // Remove block style classes from the original classname.
541        $classes_without_block_style = trim( preg_replace( '/is-style-([^\s]+)/i', '', $classname ) );
542
543        return array(
544            'fieldwrapperclasses' => $field_wrapper_classes,
545            'classes'             => $classes_without_block_style,
546        );
547    }
548
549    /**
550     * Turn block attribute to shortcode attributes.
551     *
552     * @param array         $atts  - the block attributes.
553     * @param string        $type  - the type.
554     * @param WP_Block|null $block - the block object.
555     *
556     * @return array
557     */
558    public static function block_attributes_to_shortcode_attributes( $atts, $type, $block = null ) {
559        $atts['type'] = $type;
560        if ( isset( $atts['className'] ) ) {
561            $atts['class'] = $atts['className'];
562            unset( $atts['className'] );
563        }
564
565        if ( isset( $atts['defaultValue'] ) ) {
566            $atts['default'] = $atts['defaultValue'];
567            unset( $atts['defaultValue'] );
568        }
569
570        // Process inner blocks to shortcode attributes.
571        if ( $block && ! empty( $block->parsed_block['innerBlocks'] ) ) {
572            // Only apply the block style classes to the field wrapper if the field is one of the new inner block types.
573            $add_block_style_classes_to_field_wrapper = false;
574
575            foreach ( $block->parsed_block['innerBlocks'] as $inner_block ) {
576                $block_name = $inner_block['blockName'] ?? '';
577
578                if ( 'jetpack/label' === $block_name ) {
579                    $atts['label']                            = $inner_block['attrs']['label'] ?? $inner_block['attrs']['defaultLabel'] ?? '';
580                    $atts['requiredText']                     = $inner_block['attrs']['requiredText'] ?? null;
581                    $label_attrs                              = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
582                    $atts['labelclasses']                     = 'wp-block-jetpack-label';
583                    $atts['labelclasses']                    .= isset( $label_attrs['class'] ) ? ' ' . $label_attrs['class'] : '';
584                    $atts['labelstyles']                      = $label_attrs['style'] ?? null;
585                    $add_block_style_classes_to_field_wrapper = true;
586
587                    // check if the block has been hidden by blockVisibility support
588                    $atts['labelhiddenbyblockvisibility'] = isset( $inner_block['attrs']['metadata']['blockVisibility'] ) && false === $inner_block['attrs']['metadata']['blockVisibility'];
589
590                    continue;
591                }
592
593                if ( 'jetpack/input' === $block_name ) {
594                    $atts['placeholder']   = $inner_block['attrs']['placeholder'] ?? '';
595                    $atts['min']           = $inner_block['attrs']['min'] ?? '';
596                    $atts['max']           = $inner_block['attrs']['max'] ?? '';
597                    $input_attrs           = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
598                    $atts['inputclasses']  = 'wp-block-jetpack-input';
599                    $atts['inputclasses'] .= isset( $input_attrs['class'] ) ? ' ' . $input_attrs['class'] : '';
600                    $atts['inputstyles']   = $input_attrs['style'] ?? null;
601
602                    if ( 'jetpack/field-select' === $block->name ) {
603                        $atts['togglelabel'] = $atts['placeholder'];
604                    }
605
606                    /*
607                        Borders for the outlined notched HTML.
608                    */
609                    $style_variation_data                     = self::get_style_variation_shortcode_attributes( $block_name, $inner_block['attrs'] );
610                    $atts                                     = array_merge( $atts, $style_variation_data );
611                    $add_block_style_classes_to_field_wrapper = true;
612
613                    continue;
614                }
615
616                // This input is exclusively used by the new telephone field.
617                if ( 'jetpack/phone-input' === $block_name ) {
618                    $atts['placeholder'] = $inner_block['attrs']['placeholder'] ?? '';
619
620                    if ( ! isset( $atts['showCountrySelector'] ) || ! $atts['showCountrySelector'] ) {
621                        unset( $atts['default'] );
622                    }
623
624                    if ( ! isset( $atts['showCountrySelector'] ) || ! $atts['showCountrySelector'] ) {
625                        unset( $atts['default'] );
626                    }
627
628                    $input_attrs           = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
629                    $atts['inputclasses']  = 'wp-block-jetpack-input';
630                    $atts['inputclasses'] .= isset( $input_attrs['class'] ) ? ' ' . $input_attrs['class'] : '';
631                    $atts['inputstyles']   = $input_attrs['style'] ?? null;
632
633                    /*
634                        Borders for the outlined notched HTML.
635                    */
636                    $style_variation_data                     = self::get_style_variation_shortcode_attributes( $block_name, $inner_block['attrs'] );
637                    $atts                                     = array_merge( $atts, $style_variation_data );
638                    $add_block_style_classes_to_field_wrapper = true;
639
640                    continue;
641                }
642
643                // The following handles when option blocks are a direct inner block for a field e.g. singular checkbox field.
644                if ( 'jetpack/option' === $block_name ) {
645                    $atts['label']                            = $inner_block['attrs']['label'] ?? $inner_block['attrs']['defaultLabel'] ?? '';
646                    $option_attrs                             = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
647                    $atts['optionclasses']                    = 'wp-block-jetpack-option';
648                    $atts['optionclasses']                   .= isset( $option_attrs['class'] ) ? ' ' . $option_attrs['class'] : '';
649                    $atts['optionstyles']                     = $option_attrs['style'] ?? null;
650                    $atts['requiredText']                     = $inner_block['attrs']['requiredText'] ?? ( $atts['requiredText'] ?? null );
651                    $add_block_style_classes_to_field_wrapper = true;
652
653                    continue;
654                }
655
656                // The following handles choice fields such as; Single Choice Field (radio) or Multiple Choice Field (checkbox).
657                if ( 'jetpack/options' === $block_name ) {
658                    $option_blocks           = $inner_block['innerBlocks'] ?? array();
659                    $options                 = array();
660                    $options_data            = array();
661                    $atts['optionsclasses']  = 'wp-block-jetpack-options';
662                    $options_attrs           = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
663                    $atts['optionsclasses'] .= isset( $options_attrs['class'] ) ? ' ' . $options_attrs['class'] : '';
664
665                    // Check if the block has left border, then apply a class for indentation.
666                    $global_styles = wp_get_global_styles(
667                        array( 'border' ),
668                        array(
669                            'block_name' => $block_name,
670                            'transforms' => array( 'resolve-variables' ),
671                        )
672                    );
673
674                    if ( isset( $inner_block['attrs']['style']['border']['width'] ) || isset( $inner_block['attrs']['style']['border']['left']['width'] ) || isset( $global_styles['width'] ) || isset( $global_styles['left']['width'] ) ) {
675                        $atts['optionsclasses'] .= ' jetpack-field-multiple__list--has-border';
676                    }
677
678                    $atts['optionsstyles'] = $options_attrs['style'] ?? null;
679
680                    foreach ( $option_blocks as $option ) {
681                        $option_label = trim( $option['attrs']['label'] ?? '' );
682
683                        if ( $option_label ) {
684                            $option_attrs = self::get_block_support_classes_and_styles( 'jetpack/option', $option['attrs'] );
685                            $option_data  = array( 'label' => $option_label );
686
687                            // Preserve isOther attribute from the option block so
688                            // server-side rendering can attach special handlers.
689                            if ( ! empty( $option['attrs']['isOther'] ) ) {
690                                $option_data['isOther'] = true;
691                                $atts['allowother']     = true;
692                            }
693                            if ( ! empty( $option['attrs']['otherPlaceholder'] ) ) {
694                                $option_data['otherPlaceholder'] = $option['attrs']['otherPlaceholder'];
695                            }
696
697                            if ( isset( $option_attrs['class'] ) ) {
698                                $option_data['class'] = $option_attrs['class'] . ' wp-block-jetpack-option';
699                            } else {
700                                $option_data['class'] = 'wp-block-jetpack-option';
701                            }
702
703                            if ( isset( $option_attrs['style'] ) ) {
704                                $option_data['style'] = $option_attrs['style'];
705                            }
706
707                            $options[]      = $option_label; // Legacy shortcode attribute in case filters are using it.
708                            $options_data[] = $option_data;
709                        }
710                    }
711
712                    $atts['options']     = implode( ',', $options );
713                    $atts['optionsdata'] = \wp_json_encode( $options_data, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP );
714
715                    /*
716                        Borders for the outlined notched HTML.
717                    */
718                    $style_variation_atts                     = self::get_style_variation_shortcode_attributes( $block_name, $inner_block['attrs'] );
719                    $atts                                     = array_merge( $atts, $style_variation_atts );
720                    $add_block_style_classes_to_field_wrapper = true;
721
722                    continue;
723                }
724
725                if ( 'jetpack/fieldset-image-options' === $block_name ) {
726                    $option_blocks           = $inner_block['innerBlocks'] ?? array();
727                    $options                 = array();
728                    $options_data            = array();
729                    $atts['optionsclasses']  = 'wp-block-jetpack-fieldset-image-options';
730                    $options_attrs           = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
731                    $atts['optionsclasses'] .= isset( $options_attrs['class'] ) ? ' ' . $options_attrs['class'] : '';
732
733                    // Check if the block has left border, then apply a class for indentation.
734                    $global_styles = wp_get_global_styles(
735                        array( 'border' ),
736                        array(
737                            'block_name' => $block_name,
738                            'transforms' => array( 'resolve-variables' ),
739                        )
740                    );
741
742                    if ( isset( $inner_block['attrs']['style']['border']['width'] ) || isset( $inner_block['attrs']['style']['border']['left']['width'] ) || isset( $global_styles['width'] ) || isset( $global_styles['left']['width'] ) ) {
743                        $atts['optionsclasses'] .= ' jetpack-field-image-select__list--has-border';
744                    }
745
746                    $atts['optionsstyles'] = $options_attrs['style'] ?? null;
747
748                    foreach ( $option_blocks as $option_index => $option ) {
749                        $option_label = trim( $option['attrs']['label'] ?? '' );
750
751                        // Generate letter for this option (A, B, C, ..., AA, AB, etc.)
752                        $option_letter = self::get_image_option_letter( $option_index + 1 );
753
754                        $option_attrs       = self::get_block_support_classes_and_styles( 'jetpack/input-image-option', $option['attrs'], array( 'typography', 'border', 'custom', 'spacing' ) );
755                        $option_attrs_color = self::get_block_support_classes_and_styles( 'jetpack/input-image-option', $option['attrs'], array( 'color' ) );
756                        $option_data        = array(
757                            'label'  => $option_label,
758                            'letter' => $option_letter,
759                            'image'  => $option['innerBlocks'][0],
760                        );
761
762                        if ( isset( $option_attrs['class'] ) ) {
763                            $option_data['class'] = $option_attrs['class'] . ' wp-block-jetpack-input-image-option';
764                        } else {
765                            $option_data['class'] = 'wp-block-jetpack-input-image-option';
766                        }
767                        if ( isset( $option_attrs_color['class'] ) ) {
768                            $option_data['classcolor'] = $option_attrs_color['class'];
769                        }
770
771                        if ( isset( $option_attrs['style'] ) ) {
772                            $option_data['style'] = $option_attrs['style'];
773                        }
774                        if ( isset( $option_attrs_color['style'] ) ) {
775                            $option_data['stylecolor'] = $option_attrs_color['style'];
776                        }
777
778                        $options[]      = $option_letter; // Legacy shortcode attribute - use letter for consistent submission
779                        $options_data[] = $option_data;
780                    }
781
782                    $atts['options']     = implode( ',', $options );
783                    $atts['optionsdata'] = \wp_json_encode( $options_data, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP );
784
785                    /*
786                        Borders for the outlined notched HTML.
787                    */
788                    $style_variation_atts                     = self::get_style_variation_shortcode_attributes( $block_name, $inner_block['attrs'] );
789                    $atts                                     = array_merge( $atts, $style_variation_atts );
790                    $add_block_style_classes_to_field_wrapper = true;
791
792                    continue;
793                }
794
795                if ( 'jetpack/input-rating' === $block_name ) {
796                    $input_attrs          = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
797                    $atts['inputclasses'] = isset( $input_attrs['class'] ) ? ' ' . $input_attrs['class'] : '';
798                    $atts['inputstyles']  = $input_attrs['style'] ?? null;
799                    $atts['iconStyle']    = $atts['iconStyle'] ?? $inner_block['attrs']['iconStyle'] ?? 'stars';
800                    continue;
801                }
802
803                if ( 'jetpack/input-range' === $block_name ) {
804                    $input_attrs          = self::get_block_support_classes_and_styles( $block_name, $inner_block['attrs'] );
805                    $atts['inputclasses'] = isset( $input_attrs['class'] ) ? ' ' . $input_attrs['class'] : '';
806                    $atts['inputstyles']  = $input_attrs['style'] ?? null;
807                    // Also add classes to the field wrapper so color/typography presets cascade to slider labels on the frontend.
808                    if ( isset( $input_attrs['class'] ) && $input_attrs['class'] ) {
809                        $atts['fieldwrapperclasses'] = trim( ( $atts['fieldwrapperclasses'] ?? '' ) . ' ' . $input_attrs['class'] );
810                    }
811                    $add_block_style_classes_to_field_wrapper = true;
812                    continue;
813                }
814            }
815
816            /*
817             * Add the `wp-block-jetpack-field-*` and `is-style-*` classes to the field wrapper div
818             * for fields that are one of the new inner block types.
819             * This ensures any updates to field block styles in theme.json or global styles are
820             * correctly applied.
821             */
822            if ( $add_block_style_classes_to_field_wrapper ) {
823                $atts['fieldwrapperclasses'] = 'wp-block-jetpack-field-' . $type;
824                if ( ! empty( $atts['class'] ) ) {
825                    $block_style_classes          = self::get_block_style_classes( $atts['class'] );
826                    $atts['fieldwrapperclasses'] .= $block_style_classes['fieldwrapperclasses'];
827                    // Return the rest of the classes without the block style classes.
828                    $atts['class'] = $block_style_classes['classes'];
829                }
830            }
831        }
832
833        return $atts;
834    }
835
836    /**
837     * Generates a letter for image options based on position (A, B, C, ..., AA, AB, etc.)
838     *
839     * @param int $position The 1-based position of the option.
840     * @return string The letter representation.
841     */
842    private static function get_image_option_letter( $position ) {
843        if ( $position < 1 ) {
844            return '';
845        }
846
847        $result = '';
848
849        while ( $position > 0 ) {
850            --$position;
851            $result   = chr( 65 + ( $position % 26 ) ) . $result;
852            $position = floor( $position / 26 );
853        }
854
855        return $result;
856    }
857
858    /**
859     * Resets the step counter back to 0.
860     */
861    public static function reset_step() {
862        self::$step_count = 0;
863    }
864
865    /**
866     * Render the number field.
867     *
868     * @param array  $atts - the block attributes.
869     * @param string $content - html content.
870     *
871     * @return string HTML for the number field.
872     */
873    public static function gutenblock_render_form_step( $atts, $content ) {
874        self::$step_count = 1 + self::$step_count;
875
876        $version = Constants::get_constant( 'JETPACK__VERSION' );
877        if ( empty( $version ) ) {
878            $version = '0.1';
879        }
880
881        \wp_enqueue_script_module(
882            'jetpack-form-step',
883            plugins_url( '../../dist/modules/form-step/view.js', __FILE__ ),
884            array( '@wordpress/interactivity' ),
885            $version
886        );
887
888        // Process content for marker classes and add interactivity
889        $processed_content = $content;
890
891        // Only process if we have the WP_HTML_Tag_Processor
892        if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
893            $blocks_content = do_blocks( $content );
894            $tags           = new \WP_HTML_Tag_Processor( $blocks_content );
895
896            // Move to the first token so the bookmark has a valid span, then set the bookmark.
897            $tags->next_tag();
898            $tags->set_bookmark( 'start' );
899
900            // Process blocks with the "next step" trigger
901            while ( $tags->next_tag( array( 'class_name' => 'trigger-next-step' ) ) ) {
902                // No need to set data-wp-interactive since the parent div already has it
903                $tags->set_attribute( 'data-wp-on--click', 'actions.nextStep' );
904            }
905
906            // Reset and process blocks with the "previous step" trigger
907            $tags->seek( 'start' );
908            while ( $tags->next_tag( array( 'class_name' => 'trigger-previous-step' ) ) ) {
909                $tags->set_attribute( 'data-wp-on--click', 'actions.previousStep' );
910            }
911
912            $processed_content = $tags->get_updated_html();
913        } else {
914            $processed_content = do_blocks( $content );
915        }
916        $is_current_step_class = ( self::$step_count === 1 ? 'is-current-step' : '' );
917        return '<div data-wp-interactive="jetpack/form" class="jetpack-form-step ' . $is_current_step_class . ' " data-wp-class--is-before-current="state.isBeforeCurrent" data-wp-class--is-after-current="state.isAfterCurrent" data-wp-class--is-current-step="state.isCurrentStep" ' . wp_interactivity_data_wp_context( array( 'step' => self::$step_count ) ) . ' >'
918                . $processed_content
919            . '</div>';
920    }
921
922    /**
923     * Render the number field.
924     *
925     * @param array  $atts - the block attributes.
926     * @param string $content - html content.
927     *
928     * @return string HTML for the number field.
929     */
930    public static function gutenblock_render_form_step_navigation( $atts, $content ) {
931
932        $version = Constants::get_constant( 'JETPACK__VERSION' );
933        if ( empty( $version ) ) {
934            $version = '0.1';
935        }
936        \wp_enqueue_script_module(
937            'jetpack-form-step-navigation',
938            plugins_url( '../../dist/modules/form-step-navigation/view.js', __FILE__ ),
939            array( '@wordpress/interactivity' ),
940            $version
941        );
942
943        // Enqueue the frontend style for the step navigation.
944        $style_handle = 'jetpack-form-step-navigation-style';
945        $style_path   = '../../dist/blocks/form-step-navigation/style.css';
946        if ( ! wp_style_is( $style_handle, 'enqueued' ) ) {
947            wp_enqueue_style( $style_handle, plugins_url( $style_path, __FILE__ ), array(), $version );
948        }
949
950        $button_blocks_html = do_blocks( $content );
951
952        $processor = new \WP_HTML_Tag_Processor( $button_blocks_html );
953
954        $processor->next_tag();
955        $processor->next_tag();
956
957        $processor->set_attribute( 'data-wp-interactive', 'jetpack/form' );
958
959        $class_names = array();
960
961        if ( ! empty( $atts['layout']['type'] ) ) {
962            $class_names[] = 'is-layout-' . sanitize_title( $atts['layout']['type'] );
963        }
964
965        if ( ! empty( $atts['layout']['orientation'] ) ) {
966            $class_names[] = 'is-' . sanitize_title( $atts['layout']['orientation'] );
967        }
968
969        if ( ! empty( $atts['layout']['justifyContent'] ) ) {
970            $class_names[] = 'is-content-justification-' . sanitize_title( $atts['layout']['justifyContent'] );
971        }
972
973        if ( ! empty( $atts['layout']['flexWrap'] ) && 'nowrap' === $atts['layout']['flexWrap'] ) {
974            $class_names[] = 'is-nowrap';
975        }
976
977        foreach ( $class_names as $class_name ) {
978            $processor->add_class( $class_name );
979        }
980
981        while ( $processor->next_tag() ) {
982            // Check for button type - support both legacy (data-id-attr) and new (class-based) identification.
983            $id              = $processor->get_attribute( 'data-id-attr' );
984            $is_previous_btn = 'previous-step' === $id || $processor->has_class( 'form-button-previous' );
985            $is_next_btn     = 'next-step' === $id || $processor->has_class( 'form-button-next' );
986            $is_submit_btn   = 'submit-step' === $id || $processor->has_class( 'form-button-submit' );
987
988            if ( $is_previous_btn ) {
989                $processor->remove_attribute( 'id' );
990                $processor->add_class( 'disable-spinner is-previous is-hidden' );
991                $processor->set_attribute( 'data-wp-on--click', 'actions.previousStep' );
992                $processor->set_attribute( 'data-wp-class--is-hidden', 'state.isFirstStep' );
993            }
994            if ( $is_next_btn ) {
995                $processor->remove_attribute( 'id' );
996                $processor->add_class( 'disable-spinner is-next' );
997                $processor->set_attribute( 'data-wp-on--click', 'actions.nextStep' );
998                $processor->set_attribute( 'data-wp-class--is-hidden', 'state.isLastStep' );
999            }
1000            if ( $is_submit_btn ) {
1001                $processor->remove_attribute( 'id' );
1002                if ( $processor->has_class( 'is-submit' ) ) {
1003                    $processor->add_class( 'is-hidden' );
1004                } else {
1005                    $processor->add_class( 'is-submit is-hidden' );
1006                }
1007
1008                $processor->set_attribute( 'data-wp-class--is-hidden', 'state.isNotLastStep' );
1009                if ( 'BUTTON' === $processor->get_tag() ) {
1010                    Contact_Form::add_submit_button_interactivity_attributes( $processor );
1011                } else {
1012                    $processor->set_bookmark( 'pre-button-search' );
1013                    if ( $processor->next_tag( 'button' ) ) {
1014                        Contact_Form::add_submit_button_interactivity_attributes( $processor );
1015                    } else {
1016                        $processor->seek( 'pre-button-search' );
1017                    }
1018                    $processor->release_bookmark( 'pre-button-search' );
1019                }
1020            }
1021        }
1022
1023        return $processor->get_updated_html();
1024    }
1025
1026    /**
1027     * Render the progress indicator.
1028     *
1029     * @param array $attributes - the block attributes.
1030     *
1031     * @return string HTML for the progress indicator.
1032     */
1033    public static function gutenblock_render_form_progress_indicator( $attributes ) {
1034        $version = Constants::get_constant( 'JETPACK__VERSION' );
1035        if ( empty( $version ) ) {
1036            $version = '0.1';
1037        }
1038
1039        // Get step count from Contact_Form_Block
1040        $max_steps = Contact_Form_Block::get_form_step_count();
1041
1042        $style_handle = 'jetpack-form-progress-indicator-style';
1043        if ( ! wp_style_is( $style_handle, 'enqueued' ) ) {
1044            wp_enqueue_style( $style_handle, plugins_url( 'dist/blocks/form-progress-indicator/style.css', dirname( __DIR__ ) ), array(), $version );
1045        }
1046
1047        $script_handle = 'jetpack-form-progress-indicator';
1048        \wp_enqueue_script_module(
1049            $script_handle,
1050            plugins_url( 'dist/modules/form-progress-indicator/view.js', dirname( __DIR__ ) ),
1051            array( '@wordpress/interactivity' ),
1052            $version
1053        );
1054
1055        $variant       = $attributes['variant'] ?? 'line';
1056        $is_dots_style = $variant === 'dots';
1057
1058        // Build custom CSS variables for progress indicator colors
1059        $custom_styles = array();
1060
1061        if ( isset( $attributes['progressColor'] ) ) {
1062            $custom_styles[] = '--jp-progress-active-color: ' . esc_attr( $attributes['progressColor'] );
1063        }
1064
1065        if ( isset( $attributes['progressBackgroundColor'] ) ) {
1066            $custom_styles[] = '--jp-progress-track-color: ' . esc_attr( $attributes['progressBackgroundColor'] );
1067        }
1068
1069        if ( isset( $attributes['textColor'] ) ) {
1070            $custom_styles[] = '--jp-progress-text-color: var(--wp--preset--color--' . esc_attr( $attributes['textColor'] ) . ')';
1071        } elseif ( isset( $attributes['style']['color']['text'] ) ) {
1072            $custom_styles[] = '--jp-progress-text-color: ' . esc_attr( $attributes['style']['color']['text'] );
1073        }
1074
1075        // Use WordPress Style Engine for block supports (dimensions, spacing, background, etc.)
1076        $generated_styles = wp_style_engine_get_styles( $attributes['style'] ?? array() );
1077
1078        $generated_css_parts = ! empty( $generated_styles['css'] ) ? explode( ';', $generated_styles['css'] ) : array();
1079        $all_styles          = array_filter( array_merge( $custom_styles, $generated_css_parts ) );
1080
1081        $extra_attributes = array();
1082        if ( ! empty( $all_styles ) ) {
1083            $extra_attributes['style'] = implode( '; ', $all_styles );
1084        }
1085
1086        // Add generated classnames if any
1087        $classes = array();
1088        if ( ! empty( $generated_styles['classnames'] ) ) {
1089            $classes[] = $generated_styles['classnames'];
1090        }
1091        // Add variant class
1092        $classes[] = 'is-variant-' . $variant;
1093
1094        $extra_attributes['class'] = implode( ' ', $classes );
1095
1096        $wrapper_attributes = get_block_wrapper_attributes( $extra_attributes );
1097
1098        // Build the complete HTML structure using output buffering for better readability
1099        ob_start();
1100        $progress_state = $is_dots_style ? 'state.getDotsProgress' : 'state.getStepProgress';
1101        ?>
1102        <div <?php echo wp_kses_post( $wrapper_attributes ); ?>>
1103            <div class="jetpack-form-progress-indicator-steps">
1104                <?php if ( $is_dots_style ) : ?>
1105                    <?php for ( $i = 0; $i < $max_steps; $i++ ) : ?>
1106                        <?php $step_context = array( 'stepIndex' => $i ); ?>
1107                        <div class="jetpack-form-progress-indicator-step"
1108                            data-wp-class--is-active="state.isStepActive"
1109                            data-wp-class--is-completed="state.isStepCompleted"
1110                            data-wp-context='<?php echo esc_attr( wp_json_encode( $step_context, JSON_HEX_AMP | JSON_UNESCAPED_SLASHES ) ); ?>'>
1111                            <div class="jetpack-form-progress-indicator-line"></div>
1112                            <div class="jetpack-form-progress-indicator-dot">
1113                                <span class="jetpack-form-progress-indicator-step-number">
1114                                    <span class="step-number"><?php echo esc_html( $i + 1 ); ?></span>
1115                                    <span class="step-checkmark" role="img" aria-label="<?php echo esc_attr__( 'Completed', 'jetpack-forms' ); ?>">
1116                                        <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
1117                                            <path d="M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z" fill="currentColor"/>
1118                                        </svg>
1119                                    </span>
1120                                </span>
1121                            </div>
1122                        </div>
1123                    <?php endfor; ?>
1124                <?php endif; ?>
1125                <div class="jetpack-form-progress-indicator-progress"
1126                    data-wp-style--width="<?php echo esc_attr( $progress_state ); ?>"></div>
1127            </div>
1128        </div>
1129        <?php
1130        return ob_get_clean();
1131    }
1132
1133    /**
1134     * Returns the form "Outlined" style classes and styles.
1135     * Important: The "Outlined" style is somewhat different as it uses custom HTML to create a border around the field's label.
1136     * When applying styles to the control, background and border styles are applied to the custom HTML, not the input itself.
1137     *
1138     * @param string $block_name - the block name.
1139     * @param array  $attrs - the block attributes.
1140     *
1141     * @return array
1142     */
1143    protected static function get_style_variation_shortcode_attributes( $block_name, $attrs ) {
1144        $picked_attributes = array();
1145
1146        // For style variations like the outlined style, we only care about porting specific attributes like background color and border
1147        // to the custom label HTML, so we pick those attributes and ignore the rest.
1148        if ( isset( $attrs['backgroundColor'] ) ) {
1149            $picked_attributes['backgroundColor'] = $attrs['backgroundColor'];
1150        }
1151
1152        if ( isset( $attrs['borderColor'] ) ) {
1153            $picked_attributes['borderColor'] = $attrs['borderColor'];
1154        }
1155
1156        if ( isset( $attrs['style']['border'] ) ) {
1157            $picked_attributes['style']['border'] = $attrs['style']['border'];
1158        }
1159
1160        if ( isset( $attrs['borderColor'] ) ) {
1161            $picked_attributes['borderColor'] = $attrs['borderColor'];
1162        }
1163
1164        if ( isset( $attrs['style']['color']['background'] ) ) {
1165            $picked_attributes['style']['color']['background'] = $attrs['style']['color']['background'];
1166        }
1167
1168        $block_support_styles = self::get_block_support_classes_and_styles( $block_name, $picked_attributes );
1169        return array(
1170            'stylevariationattributes' => isset( $picked_attributes['style'] ) ? \wp_json_encode( $picked_attributes['style'], JSON_UNESCAPED_SLASHES | JSON_HEX_AMP ) : '',
1171            'stylevariationclasses'    => isset( $block_support_styles['class'] ) ? ' ' . $block_support_styles['class'] : '',
1172            'stylevariationstyles'     => $block_support_styles['style'] ?? '',
1173        );
1174    }
1175
1176    /**
1177     * Render the text field.
1178     *
1179     * @param array    $atts - the block attributes.
1180     * @param string   $content - html content.
1181     * @param WP_Block $block - the block instance object.
1182     *
1183     * @return string HTML for the contact form field.
1184     */
1185    public static function gutenblock_render_field_text( $atts, $content, $block ) {
1186        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'text', $block );
1187        return Contact_Form::parse_contact_field( $atts, $content, $block );
1188    }
1189
1190    /**
1191     * Render the name field.
1192     *
1193     * @param array    $atts - the block attributes.
1194     * @param string   $content - html content.
1195     * @param WP_Block $block - the block instance object.
1196     *
1197     * @return string HTML for the contact form field.
1198     */
1199    public static function gutenblock_render_field_name( $atts, $content, $block ) {
1200        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'name', $block );
1201        return Contact_Form::parse_contact_field( $atts, $content, $block );
1202    }
1203
1204    /**
1205     * Render the email field.
1206     *
1207     * @param array    $atts - the block attributes.
1208     * @param string   $content - html content.
1209     * @param WP_Block $block - the block instance object.
1210     *
1211     * @return string HTML for the contact form field.
1212     */
1213    public static function gutenblock_render_field_email( $atts, $content, $block ) {
1214        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'email', $block );
1215        return Contact_Form::parse_contact_field( $atts, $content, $block );
1216    }
1217
1218    /**
1219     * Render the url field.
1220     *
1221     * @param array    $atts - the block attributes.
1222     * @param string   $content - html content.
1223     * @param WP_Block $block - the block instance object.
1224     *
1225     * @return string HTML for the contact form field.
1226     */
1227    public static function gutenblock_render_field_url( $atts, $content, $block ) {
1228        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'url', $block );
1229        return Contact_Form::parse_contact_field( $atts, $content, $block );
1230    }
1231
1232    /**
1233     * Render the date field.
1234     *
1235     * @param array    $atts - the block attributes.
1236     * @param string   $content - html content.
1237     * @param WP_Block $block - the block instance object.
1238     *
1239     * @return string HTML for the contact form field.
1240     */
1241    public static function gutenblock_render_field_date( $atts, $content, $block ) {
1242        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'date', $block );
1243        return Contact_Form::parse_contact_field( $atts, $content, $block );
1244    }
1245
1246    /**
1247     * Render the telephone field.
1248     *
1249     * @param array    $atts - the block attributes.
1250     * @param string   $content - html content.
1251     * @param WP_Block $block - the block instance object.
1252     *
1253     * @return string HTML for the contact form field.
1254     */
1255    public static function gutenblock_render_field_telephone( $atts, $content, $block ) {
1256        // conversion telephone to phone
1257        $type = empty( $atts['showCountrySelector'] ) ? 'telephone' : 'phone';
1258        $atts = self::block_attributes_to_shortcode_attributes( $atts, $type, $block );
1259        return Contact_Form::parse_contact_field( $atts, $content, $block );
1260    }
1261
1262    /**
1263     * Render the text area field.
1264     *
1265     * @param array    $atts - the block attributes.
1266     * @param string   $content - html content.
1267     * @param WP_Block $block - the block instance object.
1268     *
1269     * @return string HTML for the contact form field.
1270     */
1271    public static function gutenblock_render_field_textarea( $atts, $content, $block ) {
1272        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'textarea', $block );
1273        return Contact_Form::parse_contact_field( $atts, $content, $block );
1274    }
1275
1276    /**
1277     * Render the checkbox field.
1278     *
1279     * @param array    $atts - the block attributes.
1280     * @param string   $content - html content.
1281     * @param WP_Block $block - the block instance object.
1282     *
1283     * @return string HTML for the contact form field.
1284     */
1285    public static function gutenblock_render_field_checkbox( $atts, $content, $block ) {
1286        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox', $block );
1287        return Contact_Form::parse_contact_field( $atts, $content, $block );
1288    }
1289
1290    /**
1291     * Render the multiple checkbox field.
1292     *
1293     * @param array    $atts - the block attributes.
1294     * @param string   $content - html content.
1295     * @param WP_Block $block - the block instance object.
1296     *
1297     * @return string HTML for the contact form field.
1298     */
1299    public static function gutenblock_render_field_checkbox_multiple( $atts, $content, $block ) {
1300        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox-multiple', $block );
1301        return Contact_Form::parse_contact_field( $atts, $content, $block );
1302    }
1303
1304    /**
1305     * Render the multiple choice field option.
1306     *
1307     * @param array  $atts - the block attributes.
1308     * @param string $content - html content.
1309     *
1310     * @return string HTML for the contact form field.
1311     */
1312    public static function gutenblock_render_field_option( $atts, $content ) {
1313        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'field-option' );
1314        return Contact_Form::parse_contact_field( $atts, $content );
1315    }
1316
1317    /**
1318     * Render the radio button field.
1319     *
1320     * @param array    $atts - the block attributes.
1321     * @param string   $content - html content.
1322     * @param WP_Block $block - the block instance object.
1323     *
1324     * @return string HTML for the contact form field.
1325     */
1326    public static function gutenblock_render_field_radio( $atts, $content, $block ) {
1327        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'radio', $block );
1328        return Contact_Form::parse_contact_field( $atts, $content, $block );
1329    }
1330
1331    /**
1332     * Render the select field.
1333     *
1334     * @param array    $atts - the block attributes.
1335     * @param string   $content - html content.
1336     * @param WP_Block $block - the block instance object.
1337     *
1338     * @return string HTML for the contact form field.
1339     */
1340    public static function gutenblock_render_field_select( $atts, $content, $block ) {
1341        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'select', $block );
1342        return Contact_Form::parse_contact_field( $atts, $content, $block );
1343    }
1344
1345    /**
1346     * Render the consent field.
1347     *
1348     * @param array    $atts - the block attributes.
1349     * @param string   $content - html content.
1350     * @param WP_Block $block - the block instance object.
1351     */
1352    public static function gutenblock_render_field_consent( $atts, $content, $block ) {
1353        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'consent', $block );
1354
1355        if ( ! isset( $atts['implicitConsentMessage'] ) ) {
1356            $atts['implicitConsentMessage'] = __( "By submitting your information, you're giving us permission to email you. You may unsubscribe at any time.", 'jetpack-forms' );
1357        }
1358
1359        if ( ! isset( $atts['explicitConsentMessage'] ) ) {
1360            $atts['explicitConsentMessage'] = __( 'Can we send you an email from time to time?', 'jetpack-forms' );
1361        }
1362
1363        return Contact_Form::parse_contact_field( $atts, $content );
1364    }
1365
1366    /**
1367     * Render the file upload field.
1368     *
1369     * @param array    $atts - the block attributes.
1370     * @param string   $content - html content.
1371     * @param WP_Block $block - the block instance object.
1372     *
1373     * @return string HTML for the file upload field.
1374     */
1375    public static function gutenblock_render_field_file( $atts, $content, $block ) {
1376        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'file', $block );
1377        // Create wrapper div for the file field
1378        $output = '<div class="jetpack-form-file-field">';
1379
1380        // Render the file field
1381        $output .= Contact_Form::parse_contact_field( $atts, $content );
1382
1383        $output .= '</div>';
1384
1385        return $output;
1386    }
1387    /**
1388     * Render the dropzone field.
1389     *
1390     * @param array  $atts - the block attributes.
1391     * @param string $content - html content.
1392     *
1393     * @return string HTML for the dropzone field.
1394     */
1395    public static function gutenblock_render_dropzone( $atts, $content ) {
1396
1397        if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
1398            $processor = \WP_HTML_Processor::create_fragment( $content );
1399            while ( $processor->next_tag() ) {
1400                if ( $processor->has_class( 'wp-block-jetpack-dropzone' ) ) {
1401                    if ( isset( $atts['layout']['justifyContent'] ) ) {
1402                        $processor->add_class( 'is-content-justification-' . $atts['layout']['justifyContent'] );
1403                    }
1404                }
1405                if ( 'A' === $processor->get_tag() || 'BUTTON' === $processor->get_tag() ) {
1406                    $processor->set_attribute( 'tabindex', '-1' );
1407                }
1408            }
1409            $content = $processor->get_updated_html();
1410        }
1411
1412        return $content;
1413    }
1414    /**
1415     * Render the hidden field.
1416     *
1417     * @param array  $atts - the block attributes.
1418     * @param string $content - html content.
1419     *
1420     * @return string HTML for the hidden field.
1421     */
1422    public static function gutenblock_render_field_hidden( $atts, $content ) {
1423        // Convert block attributes to shortcode attributes.
1424        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'hidden' );
1425        // Parse the contact field.
1426        return Contact_Form::parse_contact_field( $atts, $content );
1427    }
1428
1429    /**
1430     * Render the number field.
1431     *
1432     * @param array    $atts - the block attributes.
1433     * @param string   $content - html content.
1434     * @param WP_Block $block - the block instance object.
1435     *
1436     * @return string HTML for the number field.
1437     */
1438    public static function gutenblock_render_field_number( $atts, $content, $block ) {
1439        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'number', $block );
1440        return Contact_Form::parse_contact_field( $atts, $content, $block );
1441    }
1442
1443    /**
1444     * Render the time field.
1445     *
1446     * @param array    $atts - the block attributes.
1447     * @param string   $content - html content.
1448     * @param WP_Block $block - the block instance object.
1449     *
1450     * @return string HTML for the time field.
1451     */
1452    public static function gutenblock_render_field_time( $atts, $content, $block ) {
1453        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'time', $block );
1454        return Contact_Form::parse_contact_field( $atts, $content, $block );
1455    }
1456
1457    /**
1458     * Render the image select field.
1459     *
1460     * @param array    $atts - the block attributes.
1461     * @param string   $content - html content.
1462     * @param WP_Block $block - the block instance object.
1463     *
1464     * @return string HTML for the image select form field.
1465     */
1466    public static function gutenblock_render_field_image_select( $atts, $content, $block ) {
1467        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'image-select', $block );
1468
1469        // Ensure showLabels is always present in the shortcode attributes, as it defaults to true.
1470        if ( ! array_key_exists( 'showLabels', $atts ) ) {
1471            $atts['showLabels'] = true;
1472        }
1473
1474        return Contact_Form::parse_contact_field( $atts, $content, $block );
1475    }
1476
1477    /**
1478     * Add the 'Form Responses' menu item as a submenu of Feedback.
1479     */
1480    public function admin_menu() {
1481        $slug = 'feedback';
1482
1483        // Do we still need to create the Feedback menu item for polldaddy?
1484        // WPCOM already handles this. Self hosted will depend on us until we produce a new release for polldaddy.
1485        if ( is_plugin_active( 'polldaddy/polldaddy.php' ) || ! Jetpack_Forms::is_legacy_menu_item_retired() ) {
1486            add_menu_page(
1487                __( 'Feedback', 'jetpack-forms' ),
1488                __( 'Feedback', 'jetpack-forms' ),
1489                'edit_pages',
1490                $slug,
1491                null,
1492                'dashicons-feedback',
1493                45
1494            );
1495        }
1496
1497        add_submenu_page(
1498            $slug,
1499            __( 'Form Responses', 'jetpack-forms' ),
1500            __( 'Form Responses', 'jetpack-forms' ),
1501            'edit_pages',
1502            'edit.php?post_type=feedback',
1503            null,
1504            0
1505        );
1506
1507        remove_submenu_page(
1508            $slug,
1509            $slug
1510        );
1511
1512        // remove the first default submenu item
1513        remove_submenu_page(
1514            $slug,
1515            'edit.php?post_type=feedback'
1516        );
1517    }
1518
1519    /**
1520     * Add to REST API post type allowed list.
1521     *
1522     * @param array $post_types - the post types.
1523     */
1524    public function allow_feedback_rest_api_type( $post_types ) {
1525        $post_types[] = 'feedback';
1526        return $post_types;
1527    }
1528
1529    /**
1530     * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
1531     *
1532     * @since 4.1.0
1533     */
1534    public function unread_count() {
1535
1536        global $submenu, $menu;
1537        if ( current_user_can( 'edit_pages' ) ) {
1538            // show the count on Jetpack and Jetpack â†’ Forms
1539            $unread = self::get_unread_count();
1540
1541            if ( isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) {
1542                $forms_unread_count_tag = $this->get_unread_count_badge_markup( $unread );
1543                $jetpack_badge_count    = $unread;
1544
1545                // Main menu entries
1546                foreach ( $menu as $index => $main_menu_item ) {
1547                    if ( isset( $main_menu_item[1] ) && 'jetpack_admin_page' === $main_menu_item[1] ) {
1548                        // Parse the menu item
1549                        $jetpack_menu_item = $this->parse_menu_item( $menu[ $index ][0] );
1550
1551                        if ( isset( $jetpack_menu_item['badge'] ) && is_numeric( $jetpack_menu_item['badge'] ) && intval( $jetpack_menu_item['badge'] ) ) {
1552                            $jetpack_badge_count += intval( $jetpack_menu_item['badge'] );
1553                        }
1554
1555                        if ( isset( $jetpack_menu_item['count'] ) && is_numeric( $jetpack_menu_item['count'] ) && intval( $jetpack_menu_item['count'] ) ) {
1556                            $jetpack_badge_count += intval( $jetpack_menu_item['count'] );
1557                        }
1558
1559                        if ( $unread > 0 ) {
1560                            $jetpack_unread_tag = $this->get_unread_count_badge_markup(
1561                                $jetpack_badge_count,
1562                                $jetpack_badge_count - $unread
1563                            );
1564
1565                            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1566                            $menu[ $index ][0] = $jetpack_menu_item['title'] . ' ' . $jetpack_unread_tag;
1567                        }
1568                    }
1569                }
1570
1571                // Jetpack submenu entries
1572                if ( $unread > 0 ) {
1573                    foreach ( $submenu['jetpack'] as $index => $menu_item ) {
1574                        /** This filter is documented in class-dashboard.php::init */
1575                        $admin_slug = apply_filters( 'jetpack_forms_alpha', true ) ? Dashboard::FORMS_WPBUILD_ADMIN_SLUG : Dashboard::ADMIN_SLUG;
1576                        if ( $admin_slug === $menu_item[2] ) {
1577                            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1578                            $submenu['jetpack'][ $index ][0] .= $forms_unread_count_tag;
1579                        }
1580                    }
1581                }
1582            }
1583            return;
1584        }
1585    }
1586
1587    /**
1588     * Build the admin menu unread count badge markup.
1589     *
1590     * Uses the `menu-counter` markup expected by admin color schemes so bubble
1591     * colors render correctly in the sidebar.
1592     *
1593     * @since 7.21.3
1594     *
1595     * @param int      $count         Badge count to display.
1596     * @param int|null $unread_diff   Optional diff for combined Jetpack menu badges.
1597     * @return string Badge HTML.
1598     */
1599    private function get_unread_count_badge_markup( $count, $unread_diff = null ) {
1600        $attributes = "class='menu-counter jp-feedback-unread-counter count-" . (int) $count . "'";
1601
1602        if ( null !== $unread_diff ) {
1603            $attributes = "data-unread-diff='" . (int) $unread_diff . "' " . $attributes;
1604        }
1605
1606        return " <span {$attributes}><span class='count'>" . number_format_i18n( $count ) . '</span></span>';
1607    }
1608
1609    /**
1610     * Get the count of unread feedback entries.
1611     *
1612     * @since 6.10.0
1613     *
1614     * @return int The count of unread feedback entries.
1615     */
1616    public static function get_unread_count() {
1617        return (int) get_option( 'jetpack_feedback_unread_count', 0 ); // previously defaulted named "feedback_unread_count".
1618    }
1619
1620    /**
1621     * Recalculate the count of unread feedback entries.
1622     *
1623     * @since 6.10.0
1624     *
1625     * @return int The count of unread feedback entries.
1626     */
1627    public static function recalculate_unread_count() {
1628        $count = Feedback::get_unread_count();
1629        update_option( 'jetpack_feedback_unread_count', $count );
1630        return $count;
1631    }
1632
1633    /**
1634     * Handles all contact-form POST submissions
1635     *
1636     * Conditionally attached to `template_redirect`
1637     */
1638    public function process_form_submission() {
1639        // Add a filter to replace tokens in the subject field with sanitized field values.
1640        add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
1641
1642        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Checked below for logged-in users only, see https://plugins.trac.wordpress.org/ticket/1859
1643        $id   = isset( $_POST['contact-form-id'] ) ? sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) ) : null;
1644        $hash = isset( $_POST['contact-form-hash'] ) ? sanitize_text_field( wp_unslash( $_POST['contact-form-hash'] ) ) : null;
1645        $hash = is_string( $hash ) ? preg_replace( '/[^\da-f]/i', '', $hash ) : $hash;
1646        // phpcs:enable
1647
1648        if ( ! is_string( $id ) || ! is_string( $hash ) ) {
1649            return Form_Submission_Error::system_error( 'invalid_form_id_or_hash', __( 'Invalid form ID or hash.', 'jetpack-forms' ) );
1650        }
1651
1652        if ( is_user_logged_in() ) {
1653            check_admin_referer( "contact-form_{$id}" );
1654        }
1655
1656        $is_widget              = str_starts_with( $id, 'widget-' );
1657        $is_block_template      = str_starts_with( $id, 'block-template-' );
1658        $is_block_template_part = str_starts_with( $id, 'block-template-part-' );
1659
1660        if ( isset( $_POST['jetpack_contact_form_jwt'] ) ) {
1661            $jwt = sanitize_text_field( wp_unslash( $_POST['jetpack_contact_form_jwt'] ) );
1662
1663            try {
1664                $form = Contact_Form::get_instance_from_jwt( $jwt, true );
1665            } catch ( \Exception $e ) {
1666                // Fail early if the JWT is invalid with detailed error information.
1667                return Form_Submission_Error::system_error(
1668                    'invalid_jwt',
1669                    $e->getMessage()
1670                );
1671            }
1672
1673            // Validate that the parent post/page where the form lives still exists and is not trashed/deleted
1674            $validation_error = $this->validate_parent_post( $form );
1675            if ( $validation_error ) {
1676                return $validation_error;
1677            }
1678
1679            $form->validate();
1680
1681            if ( $form->has_errors() ) {
1682                return $form->errors;
1683            }
1684
1685            if ( ! empty( $form->attributes['salesforceData'] ) ) {
1686                Post_To_Url::init();
1687            }
1688
1689            // Deprecate postToUrl, migrate to webhooks in case someone put it to work.
1690            if ( ! empty( $form->attributes['postToUrl'] ) ) {
1691                // webhooks should be a collection.
1692                // Turn postToUrl into a collection and merge with existing webhooks.
1693                $form->attributes['webhooks'] = array_merge(
1694                    $form->attributes['webhooks'] ?? array(),
1695                    array( $form->attributes['postToUrl'] )
1696                );
1697            }
1698
1699            /**
1700             * Filters the list of extra webhooks to be called when a form is submitted.
1701             *
1702             * This filter allows developers to programmatically add webhook configurations that will
1703             * receive form submission data. The webhooks added through this filter are merged
1704             * with any webhooks already configured in the form's attributes.
1705             *
1706             * Each webhook configuration array supports the following keys:
1707             * - `webhook_id` (string, required): Unique identifier for the webhook.
1708             * - `url` (string, required): The webhook URL to POST data to.
1709             * - `method` (string, optional): HTTP method. Default 'POST'.
1710             * - `verified` (bool, optional): Whether the webhook is verified. Default false.
1711             * - `format` (string, optional): Data format ('json'). Default 'json'.
1712             * - `enabled` (bool, optional): Whether the webhook is enabled. Default false.
1713             *
1714             * Example usage:
1715             * ```
1716             * add_filter( 'jetpack_forms_extra_webhooks', function( $webhooks, $form ) {
1717             *     if ( $form->get_attribute( 'id' ) === '123' ) {
1718             *         $webhooks[] = array(
1719             *            'webhook_id' => 'test-webhook-1',
1720             *            'url'        => '[your webhook URL]',
1721             *            'method'     => 'POST',
1722             *            'verified'   => false,
1723             *            'format'     => 'json',
1724             *            'enabled'    => true,
1725             *         );
1726             *     }
1727             *     return $webhooks;
1728             * }, 10, 2 );
1729             * ```
1730             *
1731             * @since 7.0.0
1732             *
1733             * @param array        $extra_webhooks Array of webhook configuration arrays. Default empty array.
1734             * @param Contact_Form $form           The form instance being processed.
1735             * @return array                       The modified array of webhook configurations.
1736             */
1737            $extra_webhooks = apply_filters( 'jetpack_forms_extra_webhooks', array(), $form );
1738            if ( ! empty( $extra_webhooks ) ) {
1739                $form->attributes['webhooks'] = array_merge(
1740                    $form->attributes['webhooks'] ?? array(),
1741                    $extra_webhooks
1742                );
1743            }
1744
1745            if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) {
1746                Form_Webhooks::init();
1747            }
1748
1749            // The decoded JWT carries a serialized Feedback_Source; when the
1750            // form was rendered in preview mode that source has is_test=true.
1751            // Flag the submission accordingly so the response is stored as a
1752            // test response. JWTs issued before this feature shipped simply
1753            // omit the flag and behave as regular submissions.
1754            $form->set_is_preview_submission( $form->get_source()->is_test() );
1755
1756            // Process the form
1757            return $form->process_submission();
1758        }
1759        /** This action is documented already in this file. */
1760        do_action( 'jetpack_forms_log', 'submission_missing_jwt' );
1761
1762        if ( $is_widget ) {
1763            // It's a form embedded in a text widget
1764            $this->current_widget_id = substr( $id, 7 ); // remove "widget-"
1765            $widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
1766
1767            // Is the widget active?
1768            $sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
1769
1770            // This is lame - no core API for getting a widget by ID
1771            $widget = $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ?? false;
1772
1773            if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
1774                // prevent PHP notices by populating widget args
1775                $widget_args = array(
1776                    'before_widget' => '',
1777                    'after_widget'  => '',
1778                    'before_title'  => '',
1779                    'after_title'   => '',
1780                );
1781                // This is lamer - no API for outputting a given widget by ID
1782                ob_start();
1783                // Process the widget to populate Contact_Form::$last
1784                call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
1785                ob_end_clean();
1786            }
1787        } elseif ( $is_block_template ) {
1788            /*
1789             * Recreate the logic in wp-includes/template-loader.php
1790             * that happens *after* 'template_redirect'.
1791             *
1792             * This logic populates the $_wp_current_template_content
1793             * global, which we need in order to render the contact
1794             * form for this block template.
1795             */
1796            // start of copy-pasta from wp-includes/template-loader.php.
1797            $tag_templates = array(
1798                'is_embed'             => 'get_embed_template',
1799                'is_404'               => 'get_404_template',
1800                'is_search'            => 'get_search_template',
1801                'is_front_page'        => 'get_front_page_template',
1802                'is_home'              => 'get_home_template',
1803                'is_privacy_policy'    => 'get_privacy_policy_template',
1804                'is_post_type_archive' => 'get_post_type_archive_template',
1805                'is_tax'               => 'get_taxonomy_template',
1806                'is_attachment'        => 'get_attachment_template',
1807                'is_single'            => 'get_single_template',
1808                'is_page'              => 'get_page_template',
1809                'is_singular'          => 'get_singular_template',
1810                'is_category'          => 'get_category_template',
1811                'is_tag'               => 'get_tag_template',
1812                'is_author'            => 'get_author_template',
1813                'is_date'              => 'get_date_template',
1814                'is_archive'           => 'get_archive_template',
1815            );
1816            $template      = false;
1817            // Loop through each of the template conditionals, and find the appropriate template file.
1818            // This is what calls locate_block_template() to hydrate $_wp_current_template_content.
1819            foreach ( $tag_templates as $tag => $template_getter ) {
1820                if ( call_user_func( $tag ) ) {
1821                    $template = call_user_func( $template_getter );
1822                }
1823                if ( $template ) {
1824                    if ( 'is_attachment' === $tag ) {
1825                        remove_filter( 'the_content', 'prepend_attachment' );
1826                    }
1827                    break;
1828                }
1829            }
1830            if ( ! $template ) {
1831                $template = get_index_template();
1832            }
1833            // end of copy-pasta from wp-includes/template-loader.php.
1834
1835            // Ensure 'block_template' attribute is added to any shortcodes in the template.
1836            $template = Util::grunion_contact_form_set_block_template_attribute( $template );
1837
1838            // Process the block template to populate Contact_Form::$last
1839            get_the_block_template_html();
1840        } elseif ( $is_block_template_part ) {
1841            $block_template_part_id   = str_replace( 'block-template-part-', '', $id );
1842            $bits                     = explode( '//', $block_template_part_id );
1843            $block_template_part_slug = array_pop( $bits );
1844            // Process the block part template to populate Contact_Form::$last
1845            $attributes = array(
1846                'theme'   => wp_get_theme()->get_stylesheet(),
1847                'slug'    => $block_template_part_slug,
1848                'tagName' => 'div',
1849            );
1850            do_blocks( '<!-- wp:template-part ' . wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' /-->' );
1851        } else {
1852            // It's a form embedded in a post
1853
1854            if ( ! is_post_publicly_viewable( $id ) && ! current_user_can( 'read_post', $id ) ) {
1855                // The user can't see the post.
1856                return Form_Submission_Error::system_error( 'post_not_viewable', __( 'You do not have permission to view this form.', 'jetpack-forms' ) );
1857            }
1858
1859            if ( post_password_required( $id ) ) {
1860                // The post is password-protected and the password is not provided.
1861                return Form_Submission_Error::system_error( 'post_password_required', __( 'This form requires a password.', 'jetpack-forms' ) );
1862            }
1863
1864            $post = get_post( $id );
1865
1866            // Process the content to populate Contact_Form::$last
1867            if ( $post ) {
1868                if ( str_contains( $post->post_content, '<!--nextpage-->' ) ) {
1869                    $postdata = generate_postdata( $post );
1870                    $page     = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : null; // phpcs:Ignore WordPress.Security.NonceVerification.Missing
1871                    $paged    = $page ?? 1;
1872                    $content  = $postdata['pages'][ $paged - 1 ] ?? $post->post_content;
1873                } else {
1874                    $content = $post->post_content;
1875                }
1876                /** This filter is already documented in core. wp-includes/post-template.php */
1877                apply_filters( 'the_content', $content );
1878            }
1879        }
1880
1881        // In future version we will be able to skip this step.
1882        $form = Contact_Form::$forms[ $hash ] ?? null;
1883
1884        // No form may mean user is using do_shortcode, grab the form using the stored post meta
1885        if ( ! $form && is_numeric( $id ) && $hash ) {
1886
1887            // Get shortcode from post meta
1888            $shortcode = get_post_meta( $id, "_g_feedback_shortcode_{$hash}", true );
1889
1890            // Format it
1891            if ( $shortcode !== '' && $shortcode !== false ) {
1892
1893                // Get attributes from post meta.
1894                $parameters = '';
1895                $attributes = get_post_meta( $id, "_g_feedback_shortcode_atts_{$hash}", true );
1896                if ( ! empty( $attributes ) && is_array( $attributes ) ) {
1897                    foreach ( array_filter( $attributes ) as $param => $value ) {
1898                        if ( is_scalar( $value ) ) {
1899                            $parameters .= " $param=\"$value\"";
1900                        }
1901                    }
1902                }
1903
1904                $shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
1905                do_shortcode( $shortcode );
1906
1907                // Recreate form
1908                $form = Contact_Form::$last;
1909            }
1910        }
1911
1912        if ( ! $form ) {
1913            return Form_Submission_Error::system_error( 'form_not_found', __( 'Form not found.', 'jetpack-forms' ) );
1914        }
1915
1916        if ( $form->has_errors() ) {
1917            return $form->errors;
1918        }
1919
1920        // Validate that the parent post/page where the form lives still exists and is not trashed/deleted (legacy submission path where we don't have a JWT)
1921        $validation_error = $this->validate_parent_post( $form );
1922        if ( $validation_error ) {
1923            return $validation_error;
1924        }
1925
1926        if ( ! empty( $form->attributes['salesforceData'] ) ) {
1927            Post_To_Url::init();
1928        }
1929
1930        // Deprecate postToUrl, migrate to webhooks in case someone put it to work.
1931        if ( ! empty( $form->attributes['postToUrl'] ) ) {
1932            // webhooks should be a collection.
1933            // Turn postToUrl into a collection and merge with existing webhooks.
1934            $form->attributes['webhooks'] = array_merge(
1935                $form->attributes['webhooks'] ?? array(),
1936                array( $form->attributes['postToUrl'] )
1937            );
1938        }
1939
1940        if ( ! empty( $form->attributes['webhooks'] ) ) {
1941            Form_Webhooks::init();
1942        }
1943
1944        // Process the form
1945        return $form->process_submission();
1946    }
1947
1948    /**
1949     * Handle the ajax request.
1950     *
1951     * @return never
1952     */
1953    public function ajax_request() {
1954        $submission_result = self::process_form_submission();
1955        $accepts_json      = isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ), 'application/json' );
1956        $is_system_error   = Form_Submission_Error::is_system_error( $submission_result );
1957
1958        if ( ! $submission_result || $is_system_error ) {
1959            $error_code    = $is_system_error ? $submission_result->get_error_code() : 'unknown';
1960            $error_details = $is_system_error ? $submission_result->get_error_message() : null;
1961
1962            /**
1963             * Action when we want to log a jetpack_forms event.
1964             *
1965             * @since 6.3.0
1966             *
1967             * @param string $log_message The log message.
1968             * @param string $error_code The error code (optional).
1969             * @param string $error_details The error details (optional).
1970             */
1971            do_action( 'jetpack_forms_log', 'submission_failed', $error_code, $error_details );
1972
1973            // Use a specific error message for invalid JWT tokens
1974            $error_message = ( 'invalid_jwt' === $error_code )
1975                ? __( 'An error occurred. Please reload the page and try again â€” data entered may be lost.', 'jetpack-forms' )
1976                : __( 'An error occurred. Please try again later.', 'jetpack-forms' );
1977
1978            $accepts_json && wp_send_json_error(
1979                array(
1980                    'error' => $error_message,
1981                    'code'  => $error_code,
1982                ),
1983                500,
1984                JSON_UNESCAPED_SLASHES
1985            );
1986
1987            // Non-JSON request, output the error message directly.
1988            header( 'HTTP/1.1 500 Server Error', true, 500 );
1989            echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
1990            echo esc_html( $error_message );
1991            echo '</li></ul></div>';
1992
1993            die();
1994        } elseif ( is_wp_error( $submission_result ) ) {
1995            do_action( 'jetpack_forms_log', $submission_result->get_error_message() );
1996
1997            $accepts_json && wp_send_json_error(
1998                array(
1999                    'error' => $submission_result->get_error_message(),
2000                ),
2001                400,
2002                JSON_UNESCAPED_SLASHES
2003            );
2004
2005            // Non-JSON request, output the error message directly.
2006            header( 'HTTP/1.1 400 Bad Request', true, 403 );
2007            echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
2008            echo esc_html( $submission_result->get_error_message() );
2009            echo '</li></ul></div>';
2010
2011            die();
2012        }
2013
2014        // Success case.
2015        echo '<h4>' . esc_html__( 'Your message has been sent', 'jetpack-forms' ) . '</h4>' . wp_kses(
2016            $submission_result,
2017            array(
2018                'br'         => array(),
2019                'blockquote' => array( 'class' => array() ),
2020                'p'          => array(),
2021            )
2022        );
2023        die();
2024    }
2025
2026    /**
2027     * Validates that the parent post/page where the form lives still exists and is not trashed/deleted.
2028     *
2029     * @param Contact_Form $form The contact form instance.
2030     * @return Form_Submission_Error|null Returns a Form_Submission_Error if validation fails, null otherwise.
2031     */
2032    private function validate_parent_post( Contact_Form $form ) {
2033        $source    = $form->get_source();
2034        $source_id = $source->get_id();
2035
2036        // Only check for regular posts/pages (numeric IDs), not widgets or templates
2037        if ( is_numeric( $source_id ) && $source_id > 0 ) {
2038            $parent_post = get_post( (int) $source_id );
2039
2040            // If the parent post doesn't exist or is not trashed/deleted, reject the submission
2041            if ( ! $parent_post || in_array( $parent_post->post_status, array( 'trash', 'auto-draft' ), true ) ) {
2042                /** This action is documented already in this file. */
2043                do_action( 'jetpack_forms_log', 'submission_rejected_parent_trashed_or_deleted' );
2044
2045                return Form_Submission_Error::system_error(
2046                    'form_unavailable',
2047                    __( 'This form is no longer available.', 'jetpack-forms' )
2048                );
2049            }
2050        }
2051
2052        return null;
2053    }
2054
2055    /**
2056     * Ensure the post author is always zero for contact-form feedbacks
2057     * Attached to `wp_insert_post_data`
2058     *
2059     * @see Contact_Form::process_submission()
2060     *
2061     * @param array $data the data to insert.
2062     * @param array $postarr the data sent to wp_insert_post().
2063     * @return array The filtered $data to insert.
2064     */
2065    public function insert_feedback_filter( $data, $postarr ) {
2066        if ( $data['post_type'] === 'feedback' && $postarr['post_type'] === 'feedback' ) {
2067            $data['post_author'] = 0;
2068        }
2069
2070        return $data;
2071    }
2072
2073    /**
2074     * Adds our contact-form shortcode
2075     * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
2076     */
2077    public function add_shortcode() {
2078        add_shortcode( 'contact-form', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'parse' ) );
2079        add_shortcode( 'contact-field', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'parse_contact_field' ) );
2080
2081        // We need 'contact-field-option' to be registered, so it's included to the get_shortcode_regex() method
2082        // But we don't need a callback because we're handling contact-field-option manually
2083        add_shortcode( 'contact-field-option', '__return_null' );
2084    }
2085
2086    /**
2087     * Tokenize the label.
2088     *
2089     * @param string $label - the label.
2090     *
2091     * @return string
2092     */
2093    public static function tokenize_label( $label ) {
2094        return '{' . trim( wp_strip_all_tags( preg_replace( '#^\d+_#', '', $label ) ) ) . '}';
2095    }
2096
2097    /**
2098     * Sanitizes the value of a field.
2099     *
2100     * @param string|array|null $value The value to sanitize.
2101     * @return string The sanitized value.
2102     */
2103    public static function sanitize_value( $value ) {
2104        if ( null === $value ) {
2105            return '';
2106        }
2107
2108        // If value is an array, convert it to a comma-separated string
2109        if ( is_array( $value ) ) {
2110            return implode( ', ', array_map( array( __CLASS__, 'sanitize_value' ), $value ) );
2111        }
2112
2113        return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', '', $value );
2114    }
2115
2116    /**
2117     * Sanitizes and formats values for display, ensuring arrays are properly converted to strings.
2118     *
2119     * @param mixed $value The value to format.
2120     * @return string|array The formatted value ready for display or file array for upload fields.
2121     */
2122    public static function format_value_for_display( $value ) {
2123        if ( is_array( $value ) ) {
2124            // Check if this is a file upload field
2125            if ( Contact_Form::is_file_upload_field( $value ) ) {
2126                // This is a file upload field, return as is to be handled by the proper renderer
2127                return $value;
2128            }
2129
2130            // Process each array element recursively and join with commas
2131            $formatted_values = array();
2132            foreach ( $value as $key => $item ) {
2133                $formatted_values[] = is_numeric( $key ) ? self::format_value_for_display( $item ) : "$key" . self::format_value_for_display( $item );
2134            }
2135            return implode( ', ', $formatted_values );
2136        }
2137
2138        // Simple value, just convert to string
2139        return (string) $value;
2140    }
2141
2142    /**
2143     * Replaces tokens like {city} or {City} (case insensitive) with the value
2144     * of an input field of that name
2145     *
2146     * @param string $subject - the subject.
2147     * @param array  $field_values Array with field label => field value associations.
2148     *
2149     * @return string The filtered $subject with the tokens replaced.
2150     */
2151    public function replace_tokens_with_input( $subject, $field_values ) {
2152        // Wrap labels into tokens (inside {})
2153        $wrapped_labels = array_map( array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
2154        // Sanitize all values
2155        $sanitized_values = array_map( array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
2156
2157        foreach ( $sanitized_values as $k => $sanitized_value ) {
2158            if ( is_array( $sanitized_value ) ) {
2159                $sanitized_values[ $k ] = implode( ', ', $sanitized_value );
2160            }
2161        }
2162
2163        // Search for all valid tokens (based on existing fields) and replace with the field's value
2164        $subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
2165        return $subject;
2166    }
2167
2168    /**
2169     * Tracks the widget currently being processed.
2170     * Attached to `dynamic_sidebar`
2171     *
2172     * @see $current_widget_id - the current widget ID.
2173     *
2174     * @param array $widget The widget data.
2175     */
2176    public function track_current_widget( $widget ) {
2177        $this->current_widget_id = $widget['id'] ?? '';
2178    }
2179
2180    /**
2181     * Tracks the sidebar currently being processed.
2182     * Attached to `dynamic_sidebar_before`
2183     *
2184     * @see $current_sidebar_id - the current sidebar ID.
2185     *
2186     * @param string $index The sidebar index.
2187     */
2188    public function track_current_widget_before( $index ) {
2189        $this->current_sidebar_id = $index;
2190    }
2191
2192    /**
2193     * Clear the current widget context.
2194     */
2195    public function track_current_widget_after() {
2196        $this->current_sidebar_id = '';
2197        $this->current_widget_id  = '';
2198    }
2199
2200    /**
2201     * Gets the current widget context.
2202     *
2203     * @return string The current widget context or false if not set.
2204     */
2205    public function get_current_widget_context() {
2206        // If we don't have a current widget ID or sidebar ID, we
2207        if ( empty( $this->current_widget_id ) || empty( $this->current_sidebar_id ) ) {
2208            return '';
2209        }
2210        return $this->current_widget_id . '-' . $this->current_sidebar_id;
2211    }
2212
2213    /**
2214     * Adds a "widget" attribute to every contact-form embedded in a text widget.
2215     * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
2216     * Attached to `widget_text`
2217     *
2218     * @param string $text The widget text.
2219     *
2220     * @return string The filtered widget text.
2221     */
2222    public function widget_atts( $text ) {
2223        Contact_Form::style( true );
2224
2225        return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
2226    }
2227
2228    /**
2229     * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
2230     * Attached to `widget_text`
2231     *
2232     * @param string $text The widget text.
2233     *
2234     * @return string The contact-form filtered widget text
2235     */
2236    public function widget_shortcode_hack( $text ) {
2237        if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
2238            return $text;
2239        }
2240
2241        $old = $GLOBALS['shortcode_tags'];
2242        remove_all_shortcodes();
2243        self::$using_contact_form_field = true;
2244        $this->add_shortcode();
2245
2246        $text = do_shortcode( $text );
2247
2248        self::$using_contact_form_field = false;
2249        $GLOBALS['shortcode_tags']      = $old; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
2250
2251        return $text;
2252    }
2253
2254    /**
2255     * Check if a submission matches the Comment Blocklist.
2256     * The Comment Blocklist is a means to moderate discussion, and contact
2257     * forms are 1:1 discussion forums, ripe for abuse by users who are being
2258     * removed from the public discussion.
2259     * Attached to `jetpack_contact_form_is_spam`
2260     *
2261     * @param bool  $is_spam - if the submission is spam.
2262     * @param array $form - the form data.
2263     * @return bool TRUE => spam, FALSE => not spam
2264     */
2265    public function is_spam_blocklist( $is_spam, $form = array() ) {
2266        if ( $is_spam ) {
2267            return $is_spam;
2268        }
2269
2270        return $this->is_in_disallowed_list( false, $form );
2271    }
2272
2273    /**
2274     * Check if a submission matches the comment disallowed list.
2275     * Attached to `jetpack_contact_form_in_comment_disallowed_list`.
2276     *
2277     * @param boolean $in_disallowed_list Whether the feedback is in the disallowed list.
2278     * @param array   $form The form array.
2279     * @return bool Returns true if the form submission matches the disallowed list and false if it doesn't.
2280     */
2281    public function is_in_disallowed_list( $in_disallowed_list, $form = array() ) {
2282        if ( $in_disallowed_list ) {
2283            return $in_disallowed_list;
2284        }
2285
2286        if (
2287            wp_check_comment_disallowed_list(
2288                $form['comment_author'],
2289                $form['comment_author_email'],
2290                $form['comment_author_url'],
2291                $form['comment_content'],
2292                $form['user_ip'],
2293                $form['user_agent']
2294            )
2295        ) {
2296            return true;
2297        }
2298
2299        return false;
2300    }
2301
2302    /**
2303     * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
2304     * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
2305     *
2306     * @param array $form - contact form feedback array.
2307     *
2308     * @return array feedback array with additional data ready for submission to Akismet.
2309     */
2310    public function prepare_for_akismet( $form ) {
2311        $form['comment_type']     = 'contact_form';
2312        $form['user_ip']          = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
2313        $form['user_agent']       = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
2314        $form['referrer']         = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
2315        $form['blog']             = get_option( 'home' );
2316        $form['blog_lang']        = get_bloginfo( 'language' );
2317        $form['comment_date_gmt'] = gmdate( DATE_ATOM, time() ); // ISO 8601. See https://www.php.net/manual/en/class.datetimeinterface.php#datetimeinterface.constants.types
2318
2319        foreach ( $_SERVER as $key => $value ) {
2320            if ( ! is_string( $value ) ) {
2321                continue;
2322            }
2323            if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ), true ) ) {
2324                // We don't care about cookies, and the UA and Referrer were caught above.
2325                continue;
2326            } elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ), true ) ) {
2327                // All three of these are relevant indicators and should be passed along.
2328                $form[ $key ] = $value;
2329            } elseif ( str_starts_with( $key, 'HTTP_' ) ) {
2330                // Any other HTTP header indicators.
2331                $form[ $key ] = $value;
2332            }
2333        }
2334
2335        /**
2336         * Filter the values that are sent to Akismet for the spam check.
2337         *
2338         * @module contact-form
2339         *
2340         * @since 10.2.0
2341         *
2342         * @param array $form The form values being sent to Akismet.
2343         */
2344        return apply_filters( 'jetpack_contact_form_akismet_values', $form );
2345    }
2346
2347    /**
2348     * Submit contact-form data to Akismet to check for spam.
2349     * If you're accepting a new item via $_POST, run it Contact_Form_Plugin::prepare_for_akismet() first
2350     * Attached to `jetpack_contact_form_is_spam`
2351     *
2352     * @param bool  $is_spam - if the submission is spam.
2353     * @param array $form - the form data.
2354     * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
2355     */
2356    public function is_spam_akismet( $is_spam, $form = array() ) {
2357        global $akismet_api_host, $akismet_api_port;
2358
2359        // The signature of this function changed from accepting just $form.
2360        // If something only sends an array, assume it's still using the old
2361        // signature and work around it.
2362        if ( empty( $form ) && is_array( $is_spam ) ) {
2363            $form    = $is_spam;
2364            $is_spam = false;
2365        }
2366
2367        // If a previous filter has alrady marked this as spam, trust that and move on.
2368        if ( $is_spam ) {
2369            return $is_spam;
2370        }
2371
2372        if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
2373            return false;
2374        }
2375
2376        $query_string = http_build_query( $form );
2377
2378        if ( method_exists( 'Akismet', 'http_post' ) ) {
2379            $response = \Akismet::http_post( $query_string, 'comment-check' );
2380        } else {
2381            $response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
2382        }
2383
2384        $result = false;
2385
2386        if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
2387            $result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack-forms' ) );
2388        } elseif ( isset( $response[1] ) && 'true' === trim( $response[1] ) ) { // 'true' is spam
2389            $result = true;
2390        }
2391
2392        /**
2393         * Filter the results returned by Akismet for each submitted contact form.
2394         *
2395         * @module contact-form
2396         *
2397         * @since 1.3.1
2398         *
2399         * @param WP_Error|bool $result Is the submitted feedback spam.
2400         * @param array|bool $form Submitted feedback.
2401         */
2402        return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
2403    }
2404
2405    /**
2406     * Submit a feedback as either spam or ham
2407     *
2408     * @param string $as - Either 'spam' or 'ham'.
2409     * @param array  $form - the contact-form data.
2410     *
2411     * @return bool|string
2412     */
2413    public function akismet_submit( $as, $form ) {
2414        global $akismet_api_host, $akismet_api_port;
2415
2416        if ( ! in_array( $as, array( 'ham', 'spam' ), true ) ) {
2417            return false;
2418        }
2419
2420        $query_string = '';
2421        if ( is_array( $form ) ) {
2422            $query_string = http_build_query( $form );
2423        }
2424        if ( method_exists( 'Akismet', 'http_post' ) ) {
2425            $response = \Akismet::http_post( $query_string, "submit-{$as}" );
2426        } else {
2427            $response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
2428        }
2429
2430        return trim( $response[1] );
2431    }
2432
2433    /**
2434     * Prints a dropdown of posts with forms.
2435     *
2436     * @param int $selected_id Currently selected post ID.
2437     * @return void
2438     */
2439    public static function form_posts_dropdown( $selected_id ) {
2440        ?>
2441        <select name="jetpack_form_parent_id">
2442            <option value="all"><?php esc_html_e( 'All sources', 'jetpack-forms' ); ?></option>
2443            <?php echo self::get_feedbacks_as_options( $selected_id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped in the function. ?>
2444        </select>
2445        <?php
2446    }
2447
2448    /**
2449     * Fetch post content for a post and extract just the comment.
2450     *
2451     * @param int $post_id The post id to fetch the content for.
2452     *
2453     * @return string Trimmed post comment.
2454     *
2455     * @codeCoverageIgnore
2456     */
2457    public function get_post_content_for_csv_export( $post_id ) {
2458        $post_content = get_post_field( 'post_content', $post_id );
2459        $content      = explode( '<!--more-->', $post_content );
2460
2461        return trim( $content[0] );
2462    }
2463
2464    /**
2465     * Get `_feedback_extra_fields` field from post meta data.
2466     *
2467     * @param int  $post_id Id of the post to fetch meta data for.
2468     * @param bool $has_json_data Whether the post has JSON data or not, defaults to false for backwards compatibility.
2469     *
2470     * @return mixed
2471     */
2472    public function get_post_meta_for_csv_export( $post_id, $has_json_data = false ) {
2473        $content_fields = self::parse_fields_from_content( $post_id );
2474        $all_fields     = $content_fields['_feedback_all_fields'] ?? array();
2475        $md             = $has_json_data
2476            ? array_diff_key( $all_fields, array_flip( array_keys( self::NON_PRINTABLE_FIELDS ) ) )
2477            : (array) get_post_meta( $post_id, '_feedback_extra_fields', true );
2478
2479        $md['-3_response_date'] = get_the_date( 'Y-m-d H:i:s', $post_id );
2480        $md['93_ip_address']    = $content_fields['_feedback_ip'] ?? 0;
2481
2482        // add the email_marketing_consent to the post meta.
2483        $md['90_consent'] = 0;
2484        if ( ! empty( $all_fields ) ) {
2485            // check if the email_marketing_consent field exists.
2486            if ( isset( $all_fields['email_marketing_consent'] ) ) {
2487                $md['90_consent'] = $all_fields['email_marketing_consent'];
2488            }
2489
2490            // check if the feedback entry has a title.
2491            if ( isset( $all_fields['entry_title'] ) ) {
2492                $md['-9_title'] = $all_fields['entry_title'];
2493            }
2494
2495            // check if the feedback entry has a permalink we can use.
2496            if ( ! empty( $all_fields['entry_permalink'] ) ) {
2497                $parsed          = wp_parse_url( $all_fields['entry_permalink'] );
2498                $md['-6_source'] = '';
2499                if ( $parsed && ! empty( $parsed['path'] ) && strpos( $parsed['path'], '/' ) === 0 ) {
2500                    $md['-6_source'] .= $parsed['path'];
2501                }
2502                if ( $parsed && ! empty( $parsed['query'] ) ) {
2503                    $md['-6_source'] .= '?' . $parsed['query'];
2504                }
2505            }
2506        }
2507
2508        // flatten and decode all values.
2509        $result = array();
2510        foreach ( $md as $key => $value ) {
2511            if ( is_array( $value ) ) {
2512                if ( Contact_Form::is_file_upload_field( $value ) ) {
2513                    $file_names = array();
2514                    foreach ( $value['files'] as $file ) {
2515                        $file_names[] = $file['name'];
2516                    }
2517                    $value = implode( ', ', $file_names );
2518                } else {
2519                    $value = implode( ', ', $value );
2520                }
2521            }
2522            $result[ $key ] = html_entity_decode( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
2523        }
2524
2525        return $result;
2526    }
2527
2528    /**
2529     * Get parsed feedback post fields.
2530     *
2531     * @param int $post_id Id of the post to fetch parsed contents for.
2532     *
2533     * @return array
2534     *
2535     * @codeCoverageIgnore - No need to be covered.
2536     */
2537    public function get_parsed_field_contents_of_post( $post_id ) {
2538        return self::parse_fields_from_content( $post_id );
2539    }
2540
2541    /**
2542     * Properly maps fields that are missing from the post meta data
2543     * to names, that are similar to those of the post meta.
2544     *
2545     * @param array $parsed_post_content Parsed post content.
2546     * @param bool  $use_main_comment Whether to use the main comment from the post_content or not.
2547     *                                Defaults to true for backwards compatibility. New JSON format
2548     *                                does not have a main comment and instead has all fields in the parsed content.
2549     *
2550     * @see parse_fields_from_content for how the input data is generated.
2551     *
2552     * @return array Mapped fields.
2553     */
2554    public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content, $use_main_comment = true ) {
2555
2556        $mapped_fields = array();
2557
2558        $field_mapping = array(
2559            // TODO: Commented out since we'll be re-introducing this after some other changes
2560            // '_feedback_subject'      => __( 'Contact Form', 'jetpack-forms' ),
2561            '_feedback_author'       => '1_Name',
2562            '_feedback_author_email' => '2_Email',
2563            '_feedback_author_url'   => '3_Website',
2564            '_feedback_ip'           => '93_ip_address',
2565        );
2566
2567        if ( $use_main_comment ) {
2568            $field_mapping['_feedback_main_comment'] = '4_Comment';
2569        }
2570
2571        foreach ( $field_mapping as $parsed_field_name => $field_name ) {
2572            if (
2573                isset( $parsed_post_content[ $parsed_field_name ] )
2574                && ! empty( $parsed_post_content[ $parsed_field_name ] )
2575            ) {
2576                $mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
2577            }
2578        }
2579
2580        return $mapped_fields;
2581    }
2582
2583    /**
2584     * Registers the personal data exporter.
2585     *
2586     * @since 6.1.1
2587     *
2588     * @param  array $exporters An array of personal data exporters.
2589     *
2590     * @return array $exporters An array of personal data exporters.
2591     */
2592    public function register_personal_data_exporter( $exporters ) {
2593        $exporters['jetpack-feedback'] = array(
2594            'exporter_friendly_name' => __( 'Feedback', 'jetpack-forms' ),
2595            'callback'               => array( $this, 'personal_data_exporter' ),
2596        );
2597
2598        return $exporters;
2599    }
2600
2601    /**
2602     * Registers the personal data eraser.
2603     *
2604     * @since 6.1.1
2605     *
2606     * @param  array $erasers An array of personal data erasers.
2607     *
2608     * @return array $erasers An array of personal data erasers.
2609     */
2610    public function register_personal_data_eraser( $erasers ) {
2611        $erasers['jetpack-feedback'] = array(
2612            'eraser_friendly_name' => __( 'Feedback', 'jetpack-forms' ),
2613            'callback'             => array( $this, 'personal_data_eraser' ),
2614        );
2615
2616        return $erasers;
2617    }
2618
2619    /**
2620     * Exports personal data.
2621     *
2622     * @since 6.1.1
2623     *
2624     * @param  string $email  Email address.
2625     * @param  int    $page   Page to export.
2626     *
2627     * @return array  $return Associative array with keys expected by core.
2628     */
2629    public function personal_data_exporter( $email, $page = 1 ) {
2630        return $this->internal_personal_data_exporter( $email, $page );
2631    }
2632
2633    /**
2634     * Internal method for exporting personal data.
2635     *
2636     * Allows us to have a different signature than core expects
2637     * while protecting against future core API changes.
2638     *
2639     * @internal
2640     * @since 6.5
2641     *
2642     * @param  string $email    Email address.
2643     * @param  int    $page     Page to export.
2644     * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing).
2645     *
2646     * @return array            Associative array with keys expected by core.
2647     */
2648    public function internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
2649        $post_ids = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
2650
2651        return array(
2652            'data' => $this->internal_personal_data_formater( $post_ids ),
2653            'done' => count( $post_ids ) < $per_page,
2654        );
2655    }
2656
2657    /**
2658     * Formats personal data for export.
2659     *
2660     * @param  array $post_ids Array of post IDs to format.
2661     *
2662     * @return array $export_data Formatted personal data for export.
2663     */
2664    public function internal_personal_data_formater( $post_ids ) {
2665        $export_data = array();
2666        foreach ( $post_ids as $post_id ) {
2667            $post_export_data = array();
2668            $feedback         = Feedback::get( $post_id );
2669            if ( ! $feedback ) {
2670                continue;
2671            }
2672            $fields             = $feedback->get_compiled_fields( 'personal_export', 'all' );
2673            $post_export_data[] = array(
2674                'name'  => __( 'Date', 'jetpack-forms' ),
2675                'value' => $feedback->get_time(),
2676            );
2677
2678            $post_export_data[] = array(
2679                'name'  => __( 'Source Title', 'jetpack-forms' ),
2680                'value' => $feedback->get_entry_title(),
2681            );
2682
2683            $post_export_data[] = array(
2684                'name'  => __( 'Source URL:', 'jetpack-forms' ),
2685                'value' => $feedback->get_entry_permalink(),
2686            );
2687
2688            foreach ( $fields as $field ) {
2689                $post_export_data[] = array(
2690                    'name'  => $field['label'],
2691                    'value' => $field['value'],
2692                );
2693            }
2694
2695            $post_export_data[] = array(
2696                'name'  => __( 'Consent', 'jetpack-forms' ),
2697                'value' => $feedback->has_consent() ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' ),
2698            );
2699
2700            $post_export_data[] = array(
2701                'name'  => __( 'IP Address', 'jetpack-forms' ),
2702                'value' => $feedback->get_ip_address() ?? '',
2703            );
2704
2705            $post_export_data[] = array(
2706                'name'  => __( 'Country code', 'jetpack-forms' ),
2707                'value' => $feedback->get_country_code() ?? '',
2708            );
2709
2710            $export_data[] = array(
2711                'group_id'    => 'feedback',
2712                'group_label' => __( 'Feedback', 'jetpack-forms' ),
2713                'item_id'     => 'feedback-' . $post_id,
2714                'data'        => $post_export_data,
2715            );
2716        }
2717
2718        return $export_data;
2719    }
2720
2721    /**
2722     * Erases personal data.
2723     *
2724     * @since 6.1.1
2725     *
2726     * @param  string $email Email address.
2727     * @param  int    $page  Page to erase.
2728     *
2729     * @return array         Associative array with keys expected by core.
2730     */
2731    public function personal_data_eraser( $email, $page = 1 ) {
2732        return $this->_internal_personal_data_eraser( $email, $page );
2733    }
2734
2735    /**
2736     * Internal method for erasing personal data.
2737     *
2738     * Allows us to have a different signature than core expects
2739     * while protecting against future core API changes.
2740     *
2741     * @internal
2742     * @since 6.5
2743     *
2744     * @param  string $email    Email address.
2745     * @param  int    $page     Page to erase.
2746     * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing).
2747     *
2748     * @return array            Associative array with keys expected by core.
2749     */
2750    public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- this is called in other files.
2751        $removed      = false;
2752        $retained     = false;
2753        $messages     = array();
2754        $option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
2755        $last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
2756        $post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
2757
2758        foreach ( $post_ids as $post_id ) {
2759            $last_post_id = $post_id;
2760
2761            /**
2762             * Filters whether to erase a particular Feedback post.
2763             *
2764             * @since 6.3.0
2765             *
2766             * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
2767             *                                        Custom prevention message (string). Default true.
2768             * @param int         $post_id            Feedback post ID.
2769             */
2770            $prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
2771
2772            if ( true !== $prevention_message ) {
2773                if ( $prevention_message && is_string( $prevention_message ) ) {
2774                    $messages[] = esc_html( $prevention_message );
2775                } else {
2776                    $messages[] = sprintf(
2777                    // translators: %d: Post ID.
2778                        __( 'Feedback ID %d could not be removed at this time.', 'jetpack-forms' ),
2779                        $post_id
2780                    );
2781                }
2782
2783                $retained = true;
2784
2785                continue;
2786            }
2787
2788            if ( wp_delete_post( $post_id, true ) ) {
2789                $removed = true;
2790            } else {
2791                $retained   = true;
2792                $messages[] = sprintf(
2793                // translators: %d: Post ID.
2794                    __( 'Feedback ID %d could not be removed at this time.', 'jetpack-forms' ),
2795                    $post_id
2796                );
2797            }
2798        }
2799
2800        $done = count( $post_ids ) < $per_page;
2801
2802        if ( $done ) {
2803            delete_option( $option_name );
2804        } else {
2805            update_option( $option_name, (int) $last_post_id );
2806        }
2807
2808        return array(
2809            'items_removed'  => $removed,
2810            'items_retained' => $retained,
2811            'messages'       => $messages,
2812            'done'           => $done,
2813        );
2814    }
2815
2816    /**
2817     * Queries personal data by email address.
2818     *
2819     * @since 6.1.1
2820     *
2821     * @param  string $email        Email address.
2822     * @param  int    $per_page     Post IDs per page. Default is `250`.
2823     * @param  int    $page         Page to query. Default is `1`.
2824     * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
2825     *
2826     * @return array An array of post IDs.
2827     */
2828    public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
2829        add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
2830
2831        $this->pde_last_post_id_erased = $last_post_id;
2832        $this->set_pde_email_address( $email );
2833
2834        $post_ids = get_posts(
2835            array(
2836                'post_type'        => 'feedback',
2837                'post_status'      => 'publish',
2838                // This search parameter gets overwritten in ->personal_data_search_filter()
2839                's'                => '..PDE..AUTHOR EMAIL:..PDE..',
2840                'sentence'         => true,
2841                'order'            => 'ASC',
2842                'orderby'          => 'ID',
2843                'fields'           => 'ids',
2844                'posts_per_page'   => $per_page,
2845                'paged'            => $last_post_id ? 1 : $page,
2846                'suppress_filters' => false,
2847            )
2848        );
2849
2850        $this->pde_last_post_id_erased = 0;
2851        $this->pde_email_address       = '';
2852
2853        remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
2854
2855        return $post_ids;
2856    }
2857
2858    /**
2859     * Sets the email address to filter searches by.
2860     * Helper for tests.
2861     *
2862     * @since 6.1.1
2863     *
2864     * @param  string $email Email address.
2865     */
2866    public function set_pde_email_address( $email ) {
2867        $this->pde_email_address = $email;
2868    }
2869
2870    /**
2871     * Filters searches by email address.
2872     *
2873     * @since 6.1.1
2874     *
2875     * @param  string $search SQL where clause.
2876     *
2877     * @return string         Filtered SQL where clause.
2878     */
2879    public function personal_data_search_filter( $search ) {
2880        global $wpdb;
2881
2882        /*
2883         * Searches for email addresses in feedback post_content across all storage formats:
2884         * - Legacy format: AUTHOR EMAIL on its own line
2885         * - V2/V3 format: JSON with email in field values
2886         */
2887        if ( $this->pde_email_address && str_contains( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
2888            // Build search patterns for all formats
2889            $patterns = array(
2890                // Pattern 1 & 2: Legacy format - AUTHOR EMAIL on its own line
2891                // `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
2892                '%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
2893                '%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%',
2894
2895                // Pattern 3 & 4: V2/V3 format - JSON field value with escaped quotes
2896                // Handles both storage variants:
2897                // - Pattern 3: double-escaped quotes (e.g. stored as \"value\":\" in JSON-encoded content).
2898                // - Pattern 4: single-escaped quotes (e.g. stored as "value":" after one level of unescaping).
2899                '%\\"value\\":\\"' . $wpdb->esc_like( $this->pde_email_address ) . '%',
2900                '%\"value\":\"' . $wpdb->esc_like( $this->pde_email_address ) . '%',
2901            );
2902
2903            // V2 has a bug where emojis become malformed: ðŸŽ‰ becomes ud83cudf89 instead of \ud83c\udf89.
2904            // Here we deliberately reproduce that corruption so we can still match feedback saved by V2:
2905            // - wp_json_encode( '🎉' ) produces the JSON string "\"\ud83c\udf89\"" (note the backslashes).
2906            // - trim( ..., '"' ) removes the surrounding JSON quotes, giving "\ud83c\udf89".
2907            // - stripslashes() then removes the backslashes from the escape sequence, yielding "ud83cudf89",
2908            // which is exactly how V2 stored the corrupted value in post_content.
2909            // If the email contains unicode, also search for the V2 corrupted version generated this way.
2910            $v2_corrupted_email = stripslashes( trim( wp_json_encode( $this->pde_email_address, JSON_UNESCAPED_SLASHES ), '"' ) );
2911            if ( $v2_corrupted_email !== $this->pde_email_address ) {
2912                // Email contains unicode - add pattern for V2's corrupted format.
2913                $patterns[] = '%\"value\":\"' . $wpdb->esc_like( $v2_corrupted_email ) . '%';
2914            }
2915
2916            // Build SQL with all patterns
2917            $placeholders = implode( ' OR ', array_fill( 0, count( $patterns ), "{$wpdb->posts}.post_content LIKE %s" ) );
2918
2919            // Validate that the number of placeholders matches the number of pattern values
2920            $placeholder_count = substr_count( $placeholders, '%s' );
2921            if ( $placeholder_count !== count( $patterns ) ) {
2922                return $search;
2923            }
2924
2925            $search = (string) $wpdb->prepare(
2926                ' AND ( ' . $placeholders . ' )', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
2927                ...$patterns
2928            );
2929
2930            if ( $this->pde_last_post_id_erased ) {
2931                $search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
2932            }
2933        }
2934
2935        return $search;
2936    }
2937
2938    /**
2939     * Returns an array of feedback data for export.
2940     *
2941     * @param array $feedback_ids           Array of feedback IDs to fetch the data for.
2942     * @param bool  $include_test_responses Whether to include feedback that was submitted
2943     *                                      from form preview. Defaults to false, meaning
2944     *                                      preview/test responses are excluded from the export.
2945     *
2946     * @return array
2947     */
2948    public function get_export_feedback_data( $feedback_ids, $include_test_responses = false ) {
2949        $feedback_data   = array();
2950        $all_field_names = array();
2951
2952        // Collect all feedback responses and their compiled fields
2953        foreach ( $feedback_ids as $feedback_id ) {
2954            $response = Feedback::get( $feedback_id );
2955            if ( ! $response instanceof Feedback ) {
2956                continue; // Skip if the feedback is not an instance of Feedback.
2957            }
2958
2959            // Skip test responses from form preview unless explicitly requested.
2960            if ( ! $include_test_responses && $response->is_test() ) {
2961                continue;
2962            }
2963
2964            // Get fields with automatic duplicate handling (label-value shape includes counts)
2965            $compiled_fields = $response->get_compiled_fields( 'csv', 'label-value' );
2966
2967            $feedback_data[ $feedback_id ] = array(
2968                'response' => $response,
2969                'fields'   => $compiled_fields,
2970            );
2971
2972            // Collect all unique field names across all responses
2973            $all_field_names = array_merge( $all_field_names, array_keys( $compiled_fields ) );
2974        }
2975
2976        // Get unique field names (this preserves the incremented labels like "Name (2)")
2977        $all_field_names = array_unique( $all_field_names );
2978
2979        return $this->format_feedback_data_for_csv( $feedback_data, $all_field_names );
2980    }
2981
2982    /**
2983     * Returns an array of feedback data for CSV export.
2984     *
2985     * @param array $feedback_data Array of feedback data with 'response' and 'fields' keys.
2986     * @param array $field_names   Array of field names to include in the results.
2987     *
2988     * @return array
2989     */
2990    private function format_feedback_data_for_csv( $feedback_data, $field_names ) {
2991        $results            = array();
2992        $prefix_meta_fields = ' '; // Prefix all meta fields with a space to ensure that they don't clash with form field names.
2993        foreach ( $feedback_data as $feedback_id => $data ) {
2994
2995            $feedback        = $data['response'];
2996            $compiled_fields = $data['fields'];
2997
2998            if ( ! $feedback instanceof Feedback ) {
2999                continue; // Skip if the feedback is not an instance of Feedback.
3000            }
3001
3002            $results[ $prefix_meta_fields . __( 'ID', 'jetpack-forms' ) ][]     = $feedback_id;
3003            $results[ $prefix_meta_fields . __( 'Date', 'jetpack-forms' ) ][]   = $feedback->get_time();
3004            $results[ $prefix_meta_fields . __( 'Title', 'jetpack-forms' ) ][]  = $feedback->get_entry_title();
3005            $results[ $prefix_meta_fields . __( 'Source', 'jetpack-forms' ) ][] = $feedback->get_entry_short_permalink();
3006            /**
3007             * Go through all the possible fields and check if the field is available
3008             * in the current feedback.
3009             *
3010             * If it is - add the data as a value.
3011             * If it is not - add an empty string, which is just a placeholder in the CSV.
3012             */
3013            foreach ( $field_names as $single_field_name ) {
3014                $trimmed_field_name = trim( $single_field_name );
3015                if ( ! isset( $results[ $trimmed_field_name ] ) ) {
3016                    $results[ $trimmed_field_name ] = array();
3017                }
3018                // Use the compiled fields directly (which already have incremented labels)
3019                $results[ $trimmed_field_name ][] = $compiled_fields[ $trimmed_field_name ] ?? '';
3020            }
3021
3022            $results[ $prefix_meta_fields . __( 'Consent', 'jetpack-forms' ) ][] = $feedback->has_consent() ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' );
3023
3024            // Convert null values to empty strings for proper CSV/export formatting.
3025            $results[ $prefix_meta_fields . __( 'IP Address', 'jetpack-forms' ) ][]   = $feedback->get_ip_address() ?? '';
3026            $results[ $prefix_meta_fields . __( 'Country code', 'jetpack-forms' ) ][] = $feedback->get_country_code() ?? '';
3027            $results[ $prefix_meta_fields . __( 'Browser', 'jetpack-forms' ) ][]      = $feedback->get_browser() ?? '';
3028
3029        }
3030        return $results;
3031    }
3032
3033    /**
3034     * Prepares feedback post data for CSV export.
3035     *
3036     * @deprecated since 5.1.0
3037     *
3038     * @see get_export_feedback_data()
3039     * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
3040     *
3041     * @return array
3042     */
3043    public function get_export_data_for_posts( $post_ids ) {
3044        _deprecated_function( __METHOD__, 'package-5.1.0', 'Contact_Form_Plugin::get_export_feedback_data()' );
3045        return $this->get_export_feedback_data( $post_ids );
3046    }
3047
3048    /**
3049     * Returns an array of [prefixed column name] => [translated column name], used on export.
3050     * Prefix indicates the position in which the column will be rendered:
3051     * - Negative numbers render BEFORE any form field/value column: -5, -3, -1...
3052     * - Positive values render AFTER any form field/value column: 1, 30, 93...
3053     *   Mind using high numbering on these ones as the prefix is used on regular inputs: 1_Name, 2_Email, etc
3054     *
3055     * @deprecated since 5.1.0
3056     *
3057     * @return array
3058     */
3059    public function get_well_known_column_names() {
3060        _deprecated_function( __METHOD__, 'package-5.1.0', 'Contact_Form_Plugin::get_export_column_names()' );
3061        return array(
3062            '-9_title'         => __( 'Title', 'jetpack-forms' ),
3063            '-6_source'        => __( 'Source', 'jetpack-forms' ),
3064            '-3_response_date' => __( 'Response Date', 'jetpack-forms' ),
3065            '90_consent'       => _x( 'Consent', 'noun', 'jetpack-forms' ),
3066            '93_ip_address'    => __( 'IP Address', 'jetpack-forms' ),
3067            '94_country_code'  => __( 'Country code', 'jetpack-forms' ),
3068            '95_browser'       => __( 'Browser', 'jetpack-forms' ),
3069        );
3070    }
3071
3072    /**
3073     * Extracts feedback entries based on POST data.
3074     */
3075    public function get_feedback_entries_from_post() {
3076        if ( empty( $_POST['feedback_export_nonce_csv'] ) && empty( $_POST['feedback_export_nonce_gdrive'] ) ) {
3077            return;
3078        } elseif ( ! empty( $_POST['feedback_export_nonce_csv'] ) ) {
3079            check_admin_referer( 'feedback_export', 'feedback_export_nonce_csv' );
3080        } elseif ( ! empty( $_POST['feedback_export_nonce_gdrive'] ) ) {
3081            check_admin_referer( 'feedback_export', 'feedback_export_nonce_gdrive' );
3082        }
3083
3084        if ( ! current_user_can( 'export' ) ) {
3085            return;
3086        }
3087
3088        $args = array(
3089            'posts_per_page'   => -1,
3090            'post_type'        => Feedback::POST_TYPE,
3091            'post_status'      => array( 'publish', 'draft' ),
3092            'order'            => 'ASC',
3093            'fields'           => 'ids',
3094            'suppress_filters' => false,
3095            'date_query'       => array(),
3096        );
3097
3098        // Check if we want to download all the feedbacks or just a certain contact form
3099        if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
3100            $args['post_parent'] = (int) $_POST['post'];
3101        }
3102
3103        if ( ! empty( $_POST['status'] ) && in_array( $_POST['status'], array( 'spam', 'trash' ), true ) ) {
3104            $args['post_status'] = sanitize_text_field( wp_unslash( $_POST['status'] ) );
3105        }
3106
3107        if ( ! empty( $_POST['search'] ) ) {
3108            $args['s'] = sanitize_text_field( wp_unslash( $_POST['search'] ) );
3109        }
3110
3111        if ( ! empty( $_POST['after'] ) && ! empty( $_POST['before'] ) ) {
3112            $before = strtotime( sanitize_text_field( wp_unslash( $_POST['before'] ) ) );
3113            $after  = strtotime( sanitize_text_field( wp_unslash( $_POST['after'] ) ) );
3114            if ( $before && $after && $after < $before ) {
3115                // date_query expects date strings/arrays, not timestamps.
3116                $args['date_query']['after']  = gmdate( 'Y-m-d H:i:s', $after );
3117                $args['date_query']['before'] = gmdate( 'Y-m-d H:i:s', $before );
3118            }
3119        }
3120
3121        $has_explicit_selection = ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] );
3122        if ( $has_explicit_selection ) {
3123            $args['include'] = array_filter(
3124                array_map(
3125                    function ( $selected ) {
3126                        return intval( $selected );
3127                    },
3128                    $_POST['selected'] // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
3129                )
3130            );
3131        }
3132
3133        $source_id = ! empty( $_POST['source'] ) ? absint( $_POST['source'] ) : 0;
3134        $join_cb   = null;
3135        $where_cb  = null;
3136        $feedbacks = array();
3137
3138        if ( $source_id > 0 ) {
3139            $source_sql = Feedback::get_source_filter_sql( $source_id );
3140
3141            $join_cb  = function ( $join, $query ) use ( $source_sql ) {
3142                if ( Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
3143                    return $join;
3144                }
3145                return $join . $source_sql['join'];
3146            };
3147            $where_cb = function ( $where, $query ) use ( $source_sql ) {
3148                if ( Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
3149                    return $where;
3150                }
3151                return $where . ' AND ' . $source_sql['where'];
3152            };
3153
3154            add_filter( 'posts_join', $join_cb, 10, 2 );
3155            add_filter( 'posts_where', $where_cb, 10, 2 );
3156        }
3157
3158        try {
3159            $feedbacks = get_posts( $args );
3160        } finally {
3161            if ( is_callable( $join_cb ) ) {
3162                remove_filter( 'posts_join', $join_cb, 10 );
3163            }
3164            if ( is_callable( $where_cb ) ) {
3165                remove_filter( 'posts_where', $where_cb, 10 );
3166            }
3167        }
3168
3169        // Test responses from form preview are excluded from bulk exports by
3170        // default. When the user has explicitly picked specific rows (via the
3171        // dashboard selection UI), we trust their selection and include any
3172        // test responses that landed in it.
3173        return $this->get_export_feedback_data( $feedbacks, $has_explicit_selection );
3174    }
3175
3176    /**
3177     * Download exported data as CSV
3178     */
3179    public function download_feedback_as_csv() {
3180        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verification is done on get_feedback_entries_from_post function
3181        $post_data = wp_unslash( $_POST );
3182        $data      = $this->get_feedback_entries_from_post();
3183
3184        if ( empty( $data ) ) {
3185            return;
3186        }
3187
3188        // Check if we want to download all the feedbacks or just a certain contact form
3189        if ( ! empty( $post_data['post'] ) && $post_data['post'] !== 'all' ) {
3190            $filename = sprintf(
3191                '%s - %s.csv',
3192                Util::get_export_filename( get_the_title( (int) $post_data['post'] ) ),
3193                gmdate( 'Y-m-d H:i' )
3194            );
3195        } else {
3196            $filename = sprintf(
3197                '%s - %s.csv',
3198                Util::get_export_filename(),
3199                gmdate( 'Y-m-d H:i' )
3200            );
3201        }
3202
3203        /**
3204         * Extract field names from `$data` for later use.
3205         */
3206        $fields = array_keys( $data );
3207
3208        /**
3209         * Count how many rows will be exported.
3210         */
3211        $row_count = count( reset( $data ) );
3212
3213        // Forces the download of the CSV instead of echoing
3214        header( 'Content-Disposition: attachment; filename=' . $filename );
3215        header( 'Pragma: no-cache' );
3216        header( 'Expires: 0' );
3217        header( 'Content-Type: text/csv; charset=utf-8' );
3218
3219        $output = fopen( 'php://output', 'w' );
3220
3221        /**
3222         * Print CSV headers
3223         */
3224        // @todo When we drop support for PHP <7.4, consider passing empty-string for `$escape` here for better spec compatibility.
3225        fputcsv( $output, $fields, ',', '"', '\\' );
3226
3227        /**
3228         * Print rows to the output.
3229         */
3230        for ( $i = 0; $i < $row_count; $i++ ) {
3231
3232            $current_row = array();
3233
3234            /**
3235             * Put all the fields in `$current_row` array.
3236             */
3237            foreach ( $fields as $single_field_name ) {
3238                $current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
3239            }
3240
3241            /**
3242             * Output the complete CSV row
3243             */
3244            // @todo When we drop support for PHP <7.4, consider passing empty-string for `$escape` here for better spec compatibility.
3245            fputcsv( $output, $current_row, ',', '"', '\\' );
3246        }
3247
3248        fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
3249
3250        $this->record_tracks_event( 'forms_export_responses', array( 'format' => 'csv' ) );
3251        exit( 0 );
3252    }
3253
3254    /**
3255     * Create a new page with a Form block
3256     */
3257    public function create_new_form() {
3258        if ( ! isset( $_POST['newFormNonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['newFormNonce'] ) ), 'create_new_form' ) ) {
3259            wp_send_json_error(
3260                __( 'Invalid nonce', 'jetpack-forms' ),
3261                403,
3262                JSON_UNESCAPED_SLASHES
3263            );
3264        }
3265
3266        if ( ! current_user_can( 'edit_pages' ) ) {
3267            wp_send_json_error(
3268                __( 'You do not have permission to create pages', 'jetpack-forms' ),
3269                403,
3270                JSON_UNESCAPED_SLASHES
3271            );
3272        }
3273
3274        $pattern_name = isset( $_POST['pattern'] ) ? sanitize_text_field( wp_unslash( $_POST['pattern'] ) ) : null;
3275
3276        if ( $pattern_name && WP_Block_Patterns_Registry::get_instance()->is_registered( $pattern_name ) ) {
3277            $pattern         = WP_Block_Patterns_Registry::get_instance()->get_registered( $pattern_name );
3278            $pattern_content = $pattern['content'] ?? '';
3279        }
3280
3281        // If no pattern found or specified, use a default form block
3282        if ( empty( $pattern_content ) ) {
3283            $pattern_content = '<!-- wp:jetpack/contact-form -->
3284                                                        <div class="wp-block-jetpack-contact-form"></div>
3285                                                    <!-- /wp:jetpack/contact-form -->';
3286        }
3287
3288        $post_id = wp_insert_post(
3289            array(
3290                'post_type'    => 'page',
3291                'post_title'   => '',
3292                'post_content' => $pattern_content,
3293            )
3294        );
3295
3296        if ( is_wp_error( $post_id ) ) {
3297            wp_send_json_error(
3298                $post_id->get_error_message(),
3299                500,
3300                JSON_UNESCAPED_SLASHES
3301            );
3302        } else {
3303            wp_send_json(
3304                array(
3305                    'post_url' => admin_url( 'post.php?post=' . intval( $post_id ) . '&action=edit' ),
3306                ),
3307                null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
3308                JSON_UNESCAPED_SLASHES
3309            );
3310        }
3311    }
3312
3313    /**
3314     * Send an event to Tracks
3315     *
3316     * @param string $event_name - the name of the event.
3317     * @param array  $event_props - event properties to send.
3318     *
3319     * @return null|void
3320     */
3321    public function record_tracks_event( $event_name, $event_props ) {
3322        /*
3323         * Event details.
3324         */
3325        $event_user = wp_get_current_user();
3326
3327        /*
3328         * Record event.
3329         * We use different libs on wpcom and Jetpack.
3330         */
3331        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
3332            $event_name             = 'wpcom_' . $event_name;
3333            $event_props['blog_id'] = get_current_blog_id();
3334            // logged out visitor, record event with blog owner.
3335            if ( empty( $event_user->ID ) ) {
3336                $event_user_id = wpcom_get_blog_owner( $event_props['blog_id'] );
3337                $event_user    = get_userdata( $event_user_id );
3338            }
3339
3340            require_lib( 'tracks/client' );
3341            tracks_record_event( $event_user, $event_name, $event_props );
3342        } else {
3343            $user_connected = ( new \Automattic\Jetpack\Connection\Manager( 'jetpack-forms' ) )->is_user_connected( get_current_user_id() );
3344            if ( ! $user_connected ) {
3345                return;
3346            }
3347            // logged out visitor, record event with Jetpack master user.
3348            if ( empty( $event_user->ID ) ) {
3349                $master_user_id = Jetpack_Options::get_option( 'master_user' );
3350                if ( ! empty( $master_user_id ) ) {
3351                    $event_user = get_userdata( $master_user_id );
3352                }
3353            }
3354
3355            $tracking = new Tracking();
3356            $tracking->record_user_event( $event_name, $event_props, $event_user );
3357        }
3358    }
3359
3360    /**
3361     * Escape a string to be used in a CSV context
3362     *
3363     * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
3364     * disclosure of sensitive information.
3365     *
3366     * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
3367     *
3368     * @see https://www.contextis.com/en/blog/comma-separated-vulnerabilities
3369     *
3370     * @param string $field - the CSV field.
3371     *
3372     * @return string
3373     */
3374    public function esc_csv( $field ) {
3375        $active_content_triggers = array( '=', '+', '-', '@' );
3376
3377        if ( $field && in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
3378            $field = "'" . $field;
3379        }
3380
3381        return $field;
3382    }
3383
3384    /**
3385     * Returns an array of parent post IDs for the user.
3386     * The parent posts are those posts where forms have been published.
3387     *
3388     * @param array $query_args A WP_Query compatible array of query args.
3389     *
3390     * @return array The array of post IDs
3391     */
3392    public static function get_all_parent_post_ids( $query_args = array() ) {
3393        $default_query_args = array(
3394            'fields'           => 'id=>parent',
3395            'posts_per_page'   => 100000, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
3396            'post_type'        => 'feedback',
3397            'post_status'      => 'publish',
3398            'suppress_filters' => false,
3399        );
3400        $args               = array_merge( $default_query_args, $query_args );
3401        // Get the feedbacks' parents' post IDs
3402        $feedbacks = get_posts( $args );
3403        return array_values( array_unique( array_values( $feedbacks ) ) );
3404    }
3405
3406    /**
3407     * Returns a string of HTML <option> items from an array of posts
3408     *
3409     * @param int $selected_id Currently selected post ID.
3410     * @return string a string of HTML <option> items
3411     */
3412    protected static function get_feedbacks_as_options( $selected_id = 0 ) {
3413        $options    = '';
3414        $parent_ids = self::get_all_parent_post_ids();
3415
3416        // creates the string of <option> elements
3417        foreach ( $parent_ids as $parent_id ) {
3418            $parent_url = get_permalink( $parent_id );
3419            $parsed_url = wp_parse_url( $parent_url );
3420
3421            $options .= sprintf(
3422                '<option value="%s" %s>/%s</option>',
3423                esc_attr( $parent_id ),
3424                $selected_id === $parent_id ? 'selected' : '',
3425                esc_html( basename( $parsed_url['path'] ) )
3426            );
3427        }
3428
3429        return $options;
3430    }
3431
3432    /**
3433     * Get the names of all the form's fields
3434     *
3435     * @param array|int $posts the post we want the fields of.
3436     *
3437     * @return array     the array of fields
3438     *
3439     * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
3440     */
3441    protected function get_field_names( $posts ) {
3442        $posts      = (array) $posts;
3443        $all_fields = array();
3444
3445        foreach ( $posts as $post ) {
3446            $fields = self::parse_fields_from_content( $post );
3447
3448            if ( isset( $fields['_feedback_all_fields'] ) ) {
3449                $extra_fields = array_keys( $fields['_feedback_all_fields'] );
3450                $all_fields   = array_merge( $all_fields, $extra_fields );
3451            }
3452        }
3453
3454        $all_fields = array_unique( $all_fields );
3455
3456        return $all_fields;
3457    }
3458
3459    /**
3460     * Returns if the feedback post has JSON data
3461     *
3462     * @param int $post_id The feedback post ID to check.
3463     * @return bool
3464     */
3465    public function has_json_data( $post_id ) {
3466        $post_content = get_post_field( 'post_content', $post_id );
3467        $content      = explode( "\nJSON_DATA", $post_content );
3468        if ( empty( $content[1] ) ) {
3469            return false;
3470        }
3471        $json_data = json_decode( $content[1], true );
3472        return is_array( $json_data ) && ! empty( $json_data );
3473    }
3474
3475    /**
3476     * Helper function to parse the post content.
3477     *
3478     * @param string $post_content The post content to parse.
3479     * @return array Parsed fields.
3480     *
3481     * @codeCoverageIgnore - No need to be covered.
3482     * @deprecated since 5.3.0
3483     */
3484    public static function parse_feedback_content( $post_content ) {
3485        $all_values = array();
3486
3487        $content = explode( '<!--more-->', $post_content );
3488        $lines   = array();
3489
3490        if ( count( $content ) > 1 ) {
3491            $content = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
3492            if ( str_contains( $content, 'JSON_DATA' ) ) {
3493                $chunks     = explode( "\nJSON_DATA", $content );
3494                $all_values = json_decode( $chunks[1], true );
3495                if ( $all_values === null ) {
3496                    // If JSON decoding fails, try to decode the second try with stripslashes and trim.
3497                    // This is a workaround for some cases where the JSON data is not properly formatted.
3498                    $all_values = json_decode( stripslashes( trim( $chunks[1] ) ), true );
3499                }
3500                $lines = array_filter( explode( "\n", $chunks[0] ) );
3501            } else {
3502                $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $content );
3503
3504                // This line of code is used to parse a string containing key-value pairs formatted as [Key] => Value and extract the keys and values into an array.
3505                // The regular expression ensures that each key-value pair is correctly identified and captured.
3506                // Given an input string
3507                // [Key1] => Value1
3508                // [Key2] => Value2
3509                // it  $matches[1]: The keys (e.g., Key1, Key2 ).
3510                // and $matches[2]: The values (e.g., Value1, Value2 ).
3511                preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
3512
3513                if ( count( $matches ) > 1 ) {
3514                    $all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
3515                }
3516
3517                $lines = array_filter( explode( "\n", $content ) );
3518            }
3519        }
3520
3521        $var_map = array(
3522            'AUTHOR'       => '_feedback_author',
3523            'AUTHOR EMAIL' => '_feedback_author_email',
3524            'AUTHOR URL'   => '_feedback_author_url',
3525            'SUBJECT'      => '_feedback_subject',
3526            'IP'           => '_feedback_ip',
3527        );
3528
3529        $fields = array();
3530
3531        foreach ( $lines as $line ) {
3532            $vars = explode( ': ', $line, 2 );
3533            if ( ! empty( $vars ) ) {
3534                if ( isset( $var_map[ $vars[0] ] ) ) {
3535                    $fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
3536                }
3537            }
3538        }
3539        // All fields should always be an array, even if empty.
3540        if ( ! is_array( $all_values ) ) {
3541            $all_values = array();
3542        }
3543        $fields['_feedback_all_fields'] = array();
3544        foreach ( $all_values as $key => $value ) {
3545            $fields['_feedback_all_fields'][ wp_strip_all_tags( $key ) ] = $value;
3546        }
3547
3548        return $fields;
3549    }
3550
3551    /**
3552     * Parse the contact form fields.
3553     *
3554     * @param int $post_id - the post ID.
3555     * @return array Fields.
3556     */
3557    public static function parse_fields_from_content( $post_id ) {
3558        $response = Feedback::get( $post_id );
3559
3560        if ( $response instanceof Feedback ) {
3561            return $response->get_all_legacy_values();
3562        }
3563
3564        return array();
3565    }
3566
3567    /**
3568     * Creates a valid csv row from a post id
3569     *
3570     * @param int   $post_id The id of the post.
3571     * @param array $fields  An array containing the names of all the fields of the csv.
3572     *
3573     * @return String The csv row
3574     *
3575     * @deprecated This is no longer needed, as of the CSV export rewrite.
3576     */
3577    protected static function make_csv_row_from_feedback( $post_id, $fields ) {
3578        $content_fields = self::parse_fields_from_content( $post_id );
3579        $all_fields     = array();
3580
3581        if ( isset( $content_fields['_feedback_all_fields'] ) ) {
3582            $all_fields = $content_fields['_feedback_all_fields'];
3583        }
3584
3585        // Overwrite the parsed content with the content we stored in post_meta in a better format.
3586        $extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
3587        foreach ( $extra_fields as $extra_field => $extra_value ) {
3588            $all_fields[ $extra_field ] = $extra_value;
3589        }
3590
3591        // The first element in all of the exports will be the subject
3592        $row_items   = array();
3593        $row_items[] = $content_fields['_feedback_subject'];
3594
3595        // Loop the fields array in order to fill the $row_items array correctly
3596        foreach ( $fields as $field ) {
3597            if ( $field === __( 'Contact Form', 'jetpack-forms' ) ) { // the first field will ever be the contact form, so we can continue
3598                continue;
3599            } elseif ( array_key_exists( $field, $all_fields ) ) {
3600                $row_items[] = $all_fields[ $field ];
3601            } else {
3602                $row_items[] = '';
3603            }
3604        }
3605
3606        return $row_items;
3607    }
3608
3609    /**
3610     * Get the IP address.
3611     *
3612     * @return string|null IP address.
3613     */
3614    public static function get_ip_address() {
3615        return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : null;
3616    }
3617
3618    /**
3619     * Control Block Editor usage for form-related post types.
3620     *
3621     * Disables the Block Editor for feedback (form responses) and
3622     * forces it on for jetpack_form (form definitions), even if the
3623     * Classic Editor plugin is active.
3624     *
3625     * @param bool   $can_edit Whether the post type can be edited or not.
3626     * @param string $post_type The post type being checked.
3627     * @return bool
3628     */
3629    public function use_block_editor_for_post_type( $can_edit, $post_type ) {
3630        if ( 'feedback' === $post_type ) {
3631            return false;
3632        }
3633
3634        if ( Contact_Form::POST_TYPE === $post_type ) {
3635            return true;
3636        }
3637
3638        return $can_edit;
3639    }
3640
3641    /**
3642     * Force the Block Editor for jetpack_form posts, even if the
3643     * Classic Editor plugin is active.
3644     *
3645     * @param bool    $can_edit Whether the post can be edited or not.
3646     * @param WP_Post $post    The post being checked.
3647     * @return bool
3648     */
3649    public function use_block_editor_for_post( $can_edit, $post ) {
3650        if ( Contact_Form::POST_TYPE === get_post_type( $post ) ) {
3651            return true;
3652        }
3653
3654        return $can_edit;
3655    }
3656
3657    /**
3658     * Restrict comments on feedback posts to logged-in users only.
3659     * Hooks into comment permissions to enforce authentication requirement.
3660     *
3661     * For feedback posts, we override the comment_status field (which we use
3662     * for read/unread tracking) and always allow comments for logged-in users.
3663     *
3664     * @param bool $open    Whether comments are open.
3665     * @param int  $post_id Post ID.
3666     * @return bool Whether comments are open for this post.
3667     */
3668    public function restrict_feedback_comments_to_logged_in( $open, $post_id ) {
3669        $post = get_post( $post_id );
3670
3671        if ( ! $post || 'feedback' !== $post->post_type ) {
3672            return $open;
3673        }
3674
3675        // For feedback posts, comments are always open for users that can read pages.
3676        // regardless of comment_status (which we use for read/unread tracking).
3677        return current_user_can( 'edit_pages' );
3678    }
3679
3680    /**
3681     * Kludge method: reverses the output of a standard print_r( $array ).
3682     * Sort of what unserialize does to a serialized object.
3683     * This is here while we work on a better data storage inside the posts. See:
3684     * - p1675781140892129-slack-C01CSBEN0QZ
3685     * - https://www.php.net/manual/en/function.print-r.php#93529
3686     *
3687     * @param string $print_r_output The array string to be reverted. Needs to being with 'Array'.
3688     * @param bool   $parse_html Whether to run html_entity_decode on each line.
3689     *                           As strings are stored right now, they are all escaped, so '=>' are '&gt;'.
3690     * @return array|string Array when successfully reconstructed, string otherwise. Output will always be esc_html'd.
3691     */
3692    public static function reverse_that_print( $print_r_output, $parse_html = false ) {
3693        $lines = explode( "\n", trim( $print_r_output ) );
3694        if ( $parse_html ) {
3695            $lines = array_map( 'html_entity_decode', $lines );
3696        }
3697
3698        if ( trim( $lines[0] ) !== 'Array' ) {
3699            // bottomed out to something that isn't an array, escape it and be done
3700            return esc_html( $print_r_output );
3701        } else {
3702            // this is an array, lets parse it
3703            if ( preg_match( '/(\s{5,})\(/', $lines[1], $match ) ) {
3704                // this is a tested array/recursive call to this function
3705                // take a set of spaces off the beginning
3706                $spaces        = $match[1];
3707                $spaces_length = strlen( $spaces );
3708                $lines_total   = count( $lines );
3709
3710                for ( $i = 0; $i < $lines_total; $i++ ) {
3711                    if ( substr( $lines[ $i ], 0, $spaces_length ) === $spaces ) {
3712                        $lines[ $i ] = substr( $lines[ $i ], $spaces_length );
3713                    }
3714                }
3715            }
3716
3717            array_shift( $lines ); // Array
3718            array_shift( $lines ); // (
3719            array_pop( $lines ); // )
3720            $print_r_output = implode( "\n", $lines );
3721
3722            // make sure we only match stuff with 4 preceding spaces (stuff for this array and not a nested one
3723            preg_match_all( '/^\s{4}\[(.+?)\] \=\> /m', $print_r_output, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
3724
3725            $pos          = array();
3726            $previous_key = '';
3727            $in_length    = strlen( $print_r_output );
3728
3729            // store the following in $pos:
3730            // array with key = key of the parsed array's item
3731            // value = array(start position in $print_r_output, $end position in $print_r_output)
3732            foreach ( $matches as $match ) {
3733                $key         = $match[1][0];
3734                $start       = $match[0][1] + strlen( $match[0][0] );
3735                $pos[ $key ] = array( $start, $in_length );
3736
3737                if ( $previous_key !== '' ) {
3738                    $pos[ $previous_key ][1] = $match[0][1] - 1;
3739                }
3740
3741                $previous_key = $key;
3742            }
3743
3744            $ret = array();
3745
3746            foreach ( $pos as $key => $where ) {
3747                // recursively see if the parsed out value is an array too
3748                $ret[ $key ] = self::reverse_that_print( substr( $print_r_output, $where[0], $where[1] - $where[0] ), $parse_html );
3749            }
3750
3751            return $ret;
3752        }
3753    }
3754
3755    /**
3756     * Method untrash_feedback_status_handler
3757     * wp_untrash_post filter handler.
3758     *
3759     * @param string $current_status   The status to be set.
3760     * @param int    $post_id          The post ID.
3761     * @param string $previous_status  The previous status.
3762     */
3763    public function untrash_feedback_status_handler( $current_status, $post_id, $previous_status ) {
3764        $post = get_post( $post_id );
3765        if ( 'feedback' === $post->post_type ) {
3766            if ( in_array( $previous_status, array( 'spam', 'publish' ), true ) ) {
3767                return $previous_status;
3768            }
3769            return 'publish';
3770        }
3771        return $current_status;
3772    }
3773
3774    /**
3775     * Tracks when a feedback post status changes to 'spam' and stores the timestamp.
3776     * This allows us to accurately determine when spam was marked, independent of other post updates.
3777     *
3778     * @param string       $new_status The new post status.
3779     * @param string       $old_status The old post status.
3780     * @param WP_Post|null $post       The post object, when available.
3781     *
3782     * @deprecated since 7.5.0
3783     */
3784    public function track_spam_status_change( $new_status, $old_status, ?WP_Post $post = null ) {
3785        _deprecated_function( __METHOD__, 'package-jetpack-forms-7.5.0' );
3786
3787        if ( ! $post instanceof WP_Post ) {
3788            // Some callers fire the action without a populated post object (e.g. failed get_post lookups).
3789            return;
3790        }
3791
3792        // Only track for feedback posts
3793        if ( 'feedback' !== $post->post_type ) {
3794            return;
3795        }
3796
3797        $this->track_spam_status( $new_status, $old_status, $post->ID );
3798    }
3799
3800    /**
3801     * Tracks when a feedback post status changes and triggers related handlers.
3802     * Used to handle spam meta tracking and unread count recalculation for feedback posts.
3803     *
3804     * @param string       $new_status The new post status.
3805     * @param string       $old_status The old post status.
3806     * @param WP_Post|null $post       The post object, when available.
3807     */
3808    public function track_feedback_status_change( $new_status, $old_status, ?WP_Post $post = null ) {
3809        if ( ! $post instanceof WP_Post ) {
3810            // Some callers fire the action without a populated post object (e.g. failed get_post lookups).
3811            return;
3812        }
3813
3814        // Only track for feedback posts
3815        if ( 'feedback' !== $post->post_type ) {
3816            return;
3817        }
3818        $this->track_spam_status( $new_status, $old_status, $post->ID );
3819        $this->track_recount_unread( $new_status, $old_status, $post );
3820    }
3821
3822    /**
3823     * Purges the edge cache when a jetpack_form post is published, updated while published, or unpublished.
3824     *
3825     * @param string       $new_status The new post status.
3826     * @param string       $old_status The old post status.
3827     * @param WP_Post|null $post       The post object, when available.
3828     */
3829    public function purge_edge_cache_on_form_status_change( $new_status, $old_status, ?WP_Post $post = null ) {
3830        if ( ! $post instanceof WP_Post ) {
3831            return;
3832        }
3833
3834        if ( Contact_Form::POST_TYPE !== $post->post_type ) {
3835            return;
3836        }
3837
3838        if ( 'publish' === $new_status || 'publish' === $old_status ) {
3839            /**
3840             * Fires when the edge cache for the entire domain should be purged.
3841             *
3842             * This action is handled by the WordPress.com hosting platform
3843             * and has no effect in self-hosted WordPress environments.
3844             */
3845            do_action( 'edge_cache_purge_domain' );
3846        }
3847    }
3848
3849    /**
3850     * Tracks when a feedback post status changes to 'spam' and stores the timestamp.
3851     * This allows us to accurately determine when spam was marked, independent of other post updates.
3852     *
3853     * @param string $new_status The new post status.
3854     * @param string $old_status The old post status.
3855     * @param int    $post_id    The post ID.
3856     */
3857    private function track_spam_status( $new_status, $old_status, $post_id ) {
3858        // Only track when status changes TO spam (not from spam to something else)
3859        if ( 'spam' === $new_status && 'spam' !== $old_status ) {
3860            // Store the current GMT timestamp when status changes to spam
3861            update_post_meta( $post_id, '_spam_status_changed_gmt', current_time( 'mysql', 1 ) );
3862        } elseif ( 'spam' === $old_status && 'spam' !== $new_status ) {
3863            // Remove the meta when post is no longer spam
3864            delete_post_meta( $post_id, '_spam_status_changed_gmt' );
3865        }
3866    }
3867
3868    /**
3869     * Tracks when a feedback post status changes to or from 'publish' and triggers unread count recalculation.
3870     *
3871     * @param string  $new_status The new post status.
3872     * @param string  $old_status The old post status.
3873     * @param WP_Post $post       The post object.
3874     */
3875    private function track_recount_unread( $new_status, $old_status, WP_Post $post ) {
3876        // If the feedback is already marked as read, it doesn't matter if its status changes.
3877        if ( $post->comment_status === Feedback::STATUS_READ ) {
3878            return;
3879        }
3880
3881        // If the status changed to or from 'publish', we need to recount unread feedbacks.
3882        if ( ( 'publish' === $new_status && 'publish' !== $old_status ) ||
3883            ( 'publish' === $old_status && 'publish' !== $new_status ) ) {
3884            add_action( 'shutdown', array( __CLASS__, 'recalculate_unread_count' ) );
3885        }
3886    }
3887
3888    /**
3889     * Returns whether we are in condition to track and use
3890     * analytics functionality like Tracks.
3891     *
3892     * @return bool Returns true if we can track analytics, else false.
3893     */
3894    public static function can_use_analytics() {
3895        $is_wpcom               = defined( 'IS_WPCOM' ) && IS_WPCOM;
3896        $status                 = new Status();
3897        $connection             = new Connection_Manager();
3898        $tracking               = new Tracking( 'jetpack', $connection );
3899        $should_enable_tracking = $tracking->should_enable_tracking( new Terms_Of_Service(), $status );
3900
3901        return $is_wpcom || $should_enable_tracking;
3902    }
3903
3904    /**
3905     * Jetpack menu item might have a count badge when there are updates available.
3906     * This method parses that information, removes the associated markup and adds it to the response.
3907     * Copied verbatim from WPCOM_REST_API_V2_Endpoint_Admin_Menu::prepare_menu_item.
3908     *
3909     * Also sanitizes the titles from remaining unexpected markup.
3910     *
3911     * @param string $title Title to parse.
3912     * @return array
3913     */
3914    private function parse_menu_item( $title ) {
3915        $item = array();
3916
3917        if (
3918            str_contains( $title, 'count-' )
3919            && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
3920        ) {
3921
3922            $count = (int) ( $matches[1] );
3923            if ( $count > 0 ) {
3924                // Keep the counter in the item array.
3925                $item['count'] = $count;
3926            }
3927
3928            // Finally remove the markup.
3929            $title = trim( str_replace( $matches[0], '', $title ) );
3930        }
3931
3932        if (
3933            str_contains( $title, 'inline-text' )
3934            && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
3935        ) {
3936
3937            $text = $matches[1];
3938            if ( $text ) {
3939                // Keep the text in the item array.
3940                $item['inlineText'] = $text;
3941            }
3942
3943            // Finally remove the markup.
3944            $title = trim( str_replace( $matches[0], '', $title ) );
3945        }
3946
3947        if (
3948            str_contains( $title, 'awaiting-mod' )
3949            && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
3950        ) {
3951
3952            $text = $matches[1];
3953            if ( $text ) {
3954                // Keep the text in the item array.
3955                $item['badge'] = $text;
3956            }
3957
3958            // Finally remove the markup.
3959            $title = trim( str_replace( $matches[0], '', $title ) );
3960        }
3961
3962        // It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
3963        // We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
3964        $item['title'] = ucfirst( wp_strip_all_tags( $title ) );
3965
3966        return $item;
3967    }
3968
3969    /**
3970     * Render the rating field.
3971     *
3972     * @param array    $atts - the block attributes.
3973     * @param string   $content - html content.
3974     * @param WP_Block $block - the block instance object.
3975     *
3976     * @return string HTML for the contact form field.
3977     */
3978    public static function gutenblock_render_field_rating( $atts, $content, $block ) {
3979        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'rating', $block );
3980        return Contact_Form::parse_contact_field( $atts, $content, $block );
3981    }
3982
3983    /**
3984     * Render the slider field.
3985     *
3986     * @param array    $atts - the block attributes.
3987     * @param string   $content - html content.
3988     * @param WP_Block $block - the block instance object.
3989     *
3990     * @return string HTML for the contact form field.
3991     */
3992    public static function gutenblock_render_field_slider( $atts, $content, $block ) {
3993        // Get min, max, and default from the parent block's attributes.
3994        $parent_attrs     = $block->parsed_block['attrs'] ?? array();
3995        $atts['min']      = $parent_attrs['min'] ?? 0;
3996        $atts['max']      = $parent_attrs['max'] ?? 100;
3997        $atts['default']  = $parent_attrs['default'] ?? 0;
3998        $atts['step']     = $parent_attrs['step'] ?? 1;
3999        $atts['minLabel'] = $parent_attrs['minLabel'] ?? '';
4000        $atts['maxLabel'] = $parent_attrs['maxLabel'] ?? '';
4001
4002        $atts = self::block_attributes_to_shortcode_attributes( $atts, 'slider', $block );
4003        return Contact_Form::parse_contact_field( $atts, $content, $block );
4004    }
4005
4006    /**
4007     * Redirect users from the edit-feedback and edit-jetpack_form screens to the Jetpack Forms admin page.
4008     *
4009     * This method is hooked to 'current_screen' and redirects:
4010     * - edit-jetpack_form: to #/forms (legacy) or &p=/forms (wp-build)
4011     * - edit-feedback: to #/responses?status=inbox (legacy) or &p=/responses/inbox (wp-build)
4012     *
4013     * @since 6.0.0
4014     */
4015    public function redirect_edit_feedback_to_jetpack_forms() {
4016        if ( ! function_exists( 'get_current_screen' ) ) {
4017            return;
4018        }
4019
4020        $screen = get_current_screen();
4021
4022        if ( ! $screen || ! isset( $screen->id ) ) {
4023            return;
4024        }
4025
4026        // Don't redirect if we're already on the Forms admin page (prevents redirect loop).
4027        if ( Dashboard::is_jetpack_forms_admin_page() ) {
4028            return;
4029        }
4030
4031        $redirect = null;
4032
4033        if ( 'edit-jetpack_form' === $screen->id ) {
4034            $redirect = Dashboard::get_forms_admin_url( 'forms' );
4035        } elseif ( 'edit-feedback' === $screen->id ) {
4036            $redirect = Dashboard::get_forms_admin_url( 'inbox' );
4037        }
4038
4039        if ( $redirect ) {
4040            wp_safe_redirect( $redirect );
4041            exit;
4042        }
4043    }
4044
4045    /**
4046     * Validates the export to Google Drive request.
4047     *
4048     * @param array $post_data The POST data to validate.
4049     * @return bool True if the request is valid, false otherwise.
4050     */
4051    public function validate_export_to_gdrive_request( $post_data ) {
4052        if ( ! current_user_can( 'export' ) ) {
4053            return false;
4054        }
4055
4056        if ( empty( $post_data[ $this->export_nonce_field_gdrive ] ) ) {
4057            return false;
4058        }
4059
4060        $nonce = sanitize_text_field( $post_data[ $this->export_nonce_field_gdrive ] );
4061        if ( ! wp_verify_nonce( $nonce, 'feedback_export' ) ) {
4062            return false;
4063        }
4064
4065        return true;
4066    }
4067
4068    /**
4069     * Ajax handler for wp_ajax_grunion_export_to_gdrive.
4070     * Exports data to Google Drive, based on POST data.
4071     *
4072     * @see Contact_Form_Plugin::get_feedback_entries_from_post
4073     */
4074    public function export_to_gdrive() {
4075        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verification is done on validate_export_to_gdrive_request function
4076        $post_data = wp_unslash( $_POST );
4077
4078        if ( ! $this->validate_export_to_gdrive_request( $post_data ) ) {
4079            wp_send_json_error(
4080                __( 'You aren\'t authorized to do that.', 'jetpack-forms' ),
4081                403,
4082                JSON_UNESCAPED_SLASHES
4083            );
4084            return;
4085        }
4086
4087        $grunion     = self::init();
4088        $export_data = $grunion->get_feedback_entries_from_post();
4089
4090        $fields    = is_array( $export_data ) ? array_keys( $export_data ) : array();
4091        $row_count = ! is_array( $export_data ) || empty( $export_data ) ? 0 : count( reset( $export_data ) );
4092
4093        $sheet_data = array( $fields );
4094
4095        for ( $i = 0; $i < $row_count; $i++ ) {
4096
4097            $current_row = array();
4098
4099            /**
4100             * Put all the fields in `$current_row` array.
4101             */
4102            foreach ( $fields as $single_field_name ) {
4103                if ( isset( $export_data[ $single_field_name ][ $i ] ) ) {
4104                    $current_row[] = $this->esc_csv( $export_data[ $single_field_name ][ $i ] );
4105                } else {
4106                    $current_row[] = '';
4107                }
4108            }
4109
4110            $sheet_data[] = $current_row;
4111        }
4112
4113        $user_id = (int) get_current_user_id();
4114
4115        if ( ! empty( $post_data['post'] ) && $post_data['post'] !== 'all' ) {
4116            $spreadsheet_title = sprintf(
4117                '%1$s - %2$s',
4118                Util::get_export_filename( get_the_title( (int) $post_data['post'] ) ),
4119                gmdate( 'Y-m-d H:i' )
4120            );
4121        } else {
4122            $spreadsheet_title = sprintf( '%s - %s', Util::get_export_filename(), gmdate( 'Y-m-d H:i' ) );
4123        }
4124
4125        $sheet = Google_Drive::create_sheet( $user_id, $spreadsheet_title, $sheet_data );
4126
4127        $grunion->record_tracks_event( 'forms_export_responses', array( 'format' => 'gsheets' ) );
4128
4129        wp_send_json(
4130            array(
4131                'success' => ! is_wp_error( $sheet ),
4132                'data'    => $sheet,
4133            ),
4134            is_wp_error( $sheet ) ? 500 : 200,
4135            JSON_UNESCAPED_SLASHES
4136        );
4137    }
4138}