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