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