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