Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.77% covered (success)
95.77%
680 / 710
80.25% covered (warning)
80.25%
65 / 81
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feedback
95.77% covered (success)
95.77%
680 / 710
80.25% covered (warning)
80.25%
65 / 81
243
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 clear_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_from_post
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
9
 from_submission
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 set_source
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_from_submission
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 get_field_value
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
8.50
 process_file_field_value
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 process_image_select_field_value
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 get_field_value_by_label
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_first_field_of_type
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_field_type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_entry_values
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 get_all_values
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_form_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_legacy_extra_values
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
11
 get_all_legacy_values
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_compiled_fields
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
14
 get_feedback_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_time
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_akismet_vars
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 get_author
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_first_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_last_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_avatar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_comment_content
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ip_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user_agent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_country_code
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_form_fill_duration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_country_flag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 country_code_to_emoji_flag
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 get_country_code_from_ip
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
12.99
 geolocate_via_api
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 get_browser
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_subject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_notification_recipients
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_consent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_file
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_unread
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mark_as_read
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 mark_as_unread
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_unread_count
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_files
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 get_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_permalink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_edit_form_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_entry_short_permalink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 serialize
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 parse_content
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parse_content_v2
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
8.09
 parse_content_v3
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 parse_legacy_content
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 fix_malformed_json
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
2
 split_legacy_content
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 extract_legacy_values
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 extract_legacy_lines
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parse_json_data
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 parse_array_format
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 process_legacy_lines
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
5
 is_legacy_file_upload
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 process_legacy_values
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 add_comment_content_field
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 extract_label_from_key
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get_computed_fields
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 get_computed_subject
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_comment_content
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_consent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_notification_recipients
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate_notification_recipients
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 get_field_by_form_field_id
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_field_value_by_form_field_id
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Feedback class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Device_Detection\User_Agent_Info;
12use WP_Post;
13/**
14 * Handles the response for a contact form submission.
15 *
16 * Feedback objects are there to help us interact with the form response data.
17 */
18class Feedback {
19
20    const POST_TYPE = 'feedback';
21
22    /**
23     * Comment status for unread feedback.
24     *
25     * @var string
26     */
27    public const STATUS_UNREAD = 'open';
28
29    /**
30     * Comment status for read feedback.
31     *
32     * @var string
33     */
34    public const STATUS_READ = 'closed';
35
36    /**
37     * The form field values.
38     *
39     * @var array
40     */
41    protected $fields = array();
42
43    /**
44     * Static cache for feedback fields.
45     *
46     * This is used to avoid recomputing the feedback fields for the same post ID.
47     *
48     * @var array
49     */
50    private static $feedback_fields = array();
51
52    /**
53     * Does the response have files attached to it?
54     *
55     * @var bool
56     */
57    protected $has_file = false;
58
59    /**
60     * The status of the feedback entry.
61     *
62     * @var string
63     */
64    protected $status = 'publish'; // Default status is 'publish' or other statuses as needed.
65
66    /**
67     * The IP address of the user who submitted the feedback.
68     *
69     * This is only available on form submissions, and might not be available when retrieving existing feedback posts in case the site admin decides to not store the IP address.
70     *
71     * @var string|null
72     */
73    protected $ip_address = null;
74
75    /**
76     * The user agent of the user who submitted the feedback.
77     *
78     * This is only available on form submissions, and might not be available when retrieving existing feedback posts.
79     *
80     * @var string|null
81     */
82    protected $user_agent = null;
83
84    /**
85     * The country code derived from the IP address.
86     *
87     * This is derived from the IP address and stored for easier display.
88     *
89     * @var string|null
90     */
91    protected $country_code = null;
92
93    /**
94     * The form fill duration in seconds.
95     *
96     * Tracks how long the user spent filling out the form (from first interaction to submission).
97     *
98     * @var int|null
99     */
100    protected $form_fill_duration = null;
101
102    /**
103     * The subject of the feedback entry.
104     *
105     * @var string
106     */
107    protected $subject = '';
108
109    /**
110     * Feedback ID of the feedback entry.
111     *
112     * Marked as legacy because it is not used in the new feedback system.
113     *
114     * @var string
115     */
116    protected $legacy_feedback_id = '';
117
118    /**
119     * The title of the feedback entry.
120     *
121     * Marked as legacy because it is not used in the new feedback system.
122     *
123     * @var string
124     */
125    protected $legacy_feedback_title = '';
126
127    /**
128     * The time of the feedback entry.
129     *
130     * This is used to store the title of the feedback entry.
131     *
132     * @var string
133     */
134    protected $feedback_time = '';
135
136    /**
137     * The Feedback_Author of the feedback entry.
138     *
139     * @var Feedback_Author
140     */
141    protected $author_data;
142
143    /**
144     * The comment content of the feedback entry.
145     *
146     * @var string
147     */
148    protected $comment_content = '';
149
150    /**
151     * Whether the user has given consent for data processing.
152     *
153     * @var bool
154     */
155    protected $has_consent = false;
156
157    /**
158     * Whether the feedback entry is unread.
159     *
160     * @var bool
161     */
162    protected $is_unread = true;
163
164    /**
165     * The post ID of the feedback entry.
166     *
167     * @var int|null
168     */
169    protected $post_id = null;
170
171    /**
172     * The entry object of the post that the feedback was submitted from.
173     *
174     * This is used to store the entry object of the post that the feedback was submitted from.
175     *
176     * @var Feedback_Source
177     */
178    protected $source;
179
180    /**
181     * The notification recipients of the feedback entry.
182     *
183     * @var array
184     */
185    protected $notification_recipients = array();
186
187    /**
188     * The jetpack_form post ID associated with this feedback, when available.
189     *
190     * @var int|null
191     */
192    protected $form_id = null;
193
194    /**
195     * Create a response object from a feedback post ID.
196     *
197     * @param int $feedback_post_id The ID of the feedback post.
198     * @return static|null
199     */
200    public static function get( $feedback_post_id ) {
201        $feedback_post = get_post( $feedback_post_id );
202        if ( ! $feedback_post || self::POST_TYPE !== $feedback_post->post_type ) {
203            return null;
204        }
205
206        if ( isset( self::$feedback_fields[ $feedback_post->ID ] ) ) {
207            return self::$feedback_fields[ $feedback_post->ID ];
208        }
209
210        $instance = new self();
211        $instance->load_from_post( $feedback_post );
212        self::$feedback_fields[ $feedback_post->ID ] = $instance;
213        return $instance;
214    }
215
216    /**
217     * Clear the internal cache of feedback objects.
218     *
219     * Useful for testing or when feedback data needs to be reloaded fresh.
220     *
221     * @since 6.10.0
222     */
223    public static function clear_cache() {
224        self::$feedback_fields = array();
225    }
226
227    /**
228     * Create a Feedback object from a feedback post.
229     *
230     * @param WP_Post $feedback_post The feedback post object.
231     */
232    private function load_from_post( WP_Post $feedback_post ) {
233
234        $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type );
235
236        $this->post_id            = $feedback_post->ID;
237        $this->status             = $feedback_post->post_status;
238        $this->legacy_feedback_id = $feedback_post->post_name;
239        $this->feedback_time      = $feedback_post->post_date;
240        $this->is_unread          = $feedback_post->comment_status === self::STATUS_UNREAD;
241
242        $this->fields = $parsed_content['fields'] ?? array();
243
244        // Check if post_parent is a jetpack_form post
245        $potential_form_id = $feedback_post->post_parent;
246        if ( $potential_form_id > 0 ) {
247            $parent_post = get_post( $potential_form_id );
248            if ( $parent_post && $parent_post->post_type === 'jetpack_form' ) {
249                // New data: post_parent is form ID
250                $this->form_id = $potential_form_id;
251            }
252        }
253
254        // Determine the source ID for this feedback.
255        // Prefer the explicit source_id from parsed content when available,
256        // otherwise fall back to the legacy behavior where post_parent was
257        // used as the source post ID, but only when no explicit form_id exists.
258        $source_id = 0;
259        if ( isset( $parsed_content['source_id'] ) && null !== $parsed_content['source_id'] ) {
260            $source_id = (int) $parsed_content['source_id'];
261        } elseif ( $feedback_post->post_parent && ! $this->form_id ) {
262            $source_id = (int) $feedback_post->post_parent;
263        }
264
265        $this->source = new Feedback_Source(
266            $source_id,
267            $parsed_content['entry_title'] ?? '',
268            $parsed_content['entry_page'] ?? 1,
269            $parsed_content['source_type'] ?? 'single',
270            $parsed_content['request_url'] ?? ''
271        );
272
273        $this->ip_address         = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
274        $this->country_code       = $parsed_content['country_code'] ?? null;
275        $this->user_agent         = $parsed_content['user_agent'] ?? null;
276        $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null;
277        $this->subject            = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
278
279        $this->notification_recipients = $parsed_content['notification_recipients'] ?? array();
280
281        $this->author_data = new Feedback_Author(
282            $this->get_first_field_of_type( 'name', 'pre_comment_author_name' ),
283            $this->get_first_field_of_type( 'email', 'pre_comment_author_email' ),
284            $this->get_first_field_of_type( 'url', 'pre_comment_author_url' ),
285            $this->get_field_value_by_form_field_id( 'first-name' ),
286            $this->get_field_value_by_form_field_id( 'last-name' )
287        );
288
289        $this->comment_content = $this->get_first_field_of_type( 'textarea' );
290        $this->has_consent     = (bool) $this->get_first_field_of_type( 'consent' );
291
292        $this->legacy_feedback_title = $feedback_post->post_title ? $feedback_post->post_title : $this->get_author() . ' - ' . $feedback_post->post_date;
293    }
294
295    /**
296     * Create a response object from a form submission.
297     *
298     * @param array        $post_data Typically $_POST.
299     * @param Contact_Form $form      The form object.
300     * @param WP_Post|null $current_post The current post object, if available.
301     * @param int          $current_page_number The current page number associated with the current post object entry.
302     *
303     * @return static
304     */
305    public static function from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
306        $instance = new self();
307        $instance->load_from_submission( $post_data, $form, $current_post, $current_page_number );
308        return $instance;
309    }
310
311    /**
312     * Set the source of the feedback entry.
313     *
314     * @param Feedback_Source $source The source object.
315     */
316    public function set_source( $source ) {
317        $this->source = $source;
318    }
319
320    /**
321     * Load from Form Submission.
322     *
323     * @param array        $post_data The $_POST received during the form submission.
324     * @param Contact_Form $form  The form object.
325     * @param WP_Post|null $current_post The current post object, if available.
326     * @param int          $current_page_number The current page number associated with the current post object entry.
327     */
328    private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
329
330        $this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
331
332        // Extract and validate form ID from POST data or ref attribute
333        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification happens in process_form_submission()
334        $form_id_attribute = $post_data['contact-form-ref'] ?? $form->get_attribute( 'ref' );
335        $form_id_attribute = is_numeric( $form_id_attribute ) ? absint( $form_id_attribute ) : 0;
336        $this->form_id     = $form_id_attribute > 0 ? $form_id_attribute : null;
337
338        // If post_data is provided, use it to populate fields.
339        $this->fields             = $this->get_computed_fields( $post_data, $form );
340        $this->ip_address         = Contact_Form_Plugin::get_ip_address();
341        $this->country_code       = $this->get_country_code_from_ip( $this->ip_address );
342        $this->user_agent         = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
343        $this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null;
344        $this->subject            = $this->get_computed_subject( $post_data, $form );
345        $this->author_data        = Feedback_Author::from_submission( $post_data, $form );
346        $this->comment_content    = $this->get_computed_comment_content( $post_data, $form );
347        $this->has_consent        = $this->get_computed_consent( $post_data, $form );
348
349        $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );
350
351        $this->feedback_time         = current_time( 'mysql' );
352        $this->legacy_feedback_title = "{$this->get_author()} - {$this->feedback_time}";
353        $this->legacy_feedback_id    = md5( $this->legacy_feedback_title );
354    }
355
356    /**
357     * Get a sanitized value from the post data.
358     *
359     * @param string      $key The key to look for in the post data.
360     * @param array       $post_data The post data array, typically $_POST.
361     * @param string|null $type The type of the field, if applicable (e.g., 'file').
362     *
363     * @return string|array The sanitized value, or an empty string if the key is not found.
364     */
365    private function get_field_value( $key, $post_data, $type = null ) {
366        if ( $type === 'file' ) {
367            if ( isset( $post_data[ $key ] ) ) {
368                return self::process_file_field_value( $post_data[ $key ] );
369            }
370            return array( 'files' => array() );
371        }
372
373        if ( $type === 'image-select' ) {
374            if ( isset( $post_data[ $key ] ) ) {
375                return self::process_image_select_field_value( $post_data[ $key ] );
376            }
377
378            return array(
379                'type'    => 'image-select',
380                'choices' => array(),
381            );
382        }
383
384        if ( isset( $post_data[ $key ] ) ) {
385            if ( is_array( $post_data[ $key ] ) ) {
386                return array_map( 'sanitize_textarea_field', wp_unslash( $post_data[ $key ] ) );
387            } else {
388                return sanitize_textarea_field( wp_unslash( $post_data[ $key ] ) );
389            }
390        }
391        return '';
392    }
393
394    /**
395     * Process the file field value.
396     *
397     * @param array $raw_data The raw post data from the file field.
398     *
399     * @return array The processed file data.
400     */
401    public static function process_file_field_value( $raw_data ) {
402        $file_data_array = is_array( $raw_data )
403            ? array_map(
404                function ( $json_str ) {
405                    $decoded = json_decode( stripslashes( $json_str ), true );
406                    return array(
407                        'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
408                        'name'    => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
409                        'size'    => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
410                        'type'    => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '',
411                    );
412                },
413                $raw_data
414            ) : array();
415
416        if ( empty( $file_data_array ) ) {
417            return array(
418                'files' => array(),
419            );
420        }
421
422        return array(
423            'files' => $file_data_array,
424        );
425    }
426
427    /**
428     * Process the image select field value.
429     *
430     * @param array $raw_data The raw post data from the image select field.
431     *
432     * @return array The processed image select data.
433     */
434    public static function process_image_select_field_value( $raw_data ) {
435        $value = array(
436            'type'    => 'image-select',
437            'choices' => array(),
438        );
439
440        $selection_data_array = is_array( $raw_data )
441            ? array_map(
442                function ( $json_str ) {
443                    return json_decode( stripslashes( $json_str ), true );
444                },
445                $raw_data
446            ) : array( json_decode( stripslashes( $raw_data ), true ) );
447
448        if ( ! empty( $selection_data_array ) ) {
449            $value['choices'] = $selection_data_array;
450        }
451
452        return $value;
453    }
454
455    /**
456     * Get the computed fields from the post data.
457     *
458     * @param string $label The label of the field to look for.
459     * @param string $context The context in which the value is being rendered (default is 'default').
460     *
461     * @return string The Value of the field.
462     */
463    public function get_field_value_by_label( $label, $context = 'default' ) {
464        // This method is used to get the value of a field by its label.
465        foreach ( $this->fields as $field ) {
466            if ( $field->get_label( $context ) === $label ) {
467                return $field->get_render_value( $context );
468            }
469        }
470        return '';
471    }
472
473    /**
474     * Get the value of the field based on the first type found.
475     *
476     * @param string      $type The type of the field to look for.
477     * @param string|null $filter Optional filter to apply to the value.
478     * @param string      $context The context in which the value is being rendered (default is 'default').
479     *
480     * @return string The value of the first field of the specified type, or an empty string if not found.
481     */
482    private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) {
483        // This method is used to get the first field of a specific type.
484        foreach ( $this->fields as $field ) {
485            if ( $field->get_type() === $type ) {
486                if ( $filter ) {
487                    return Contact_Form_Plugin::strip_tags(
488                        stripslashes(
489                            /** This filter is already documented in core/wp-includes/comment-functions.php */
490                            \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) )
491                        )
492                    );
493                }
494                return $field->get_render_value( $context );
495            }
496        }
497        return '';
498    }
499
500    /**
501     * Get all the fields of the response.
502     */
503    public function get_fields() {
504        return $this->fields;
505    }
506
507    /**
508     * Check whether this feedback contains at least one field of a given type.
509     *
510     * @param string $type Field type to check for (e.g. 'consent', 'email', 'textarea').
511     * @return bool True if a field of the given type exists; false otherwise.
512     */
513    public function has_field_type( $type ) {
514        foreach ( $this->fields as $field ) {
515            if ( $field->get_type() === $type ) {
516                return true;
517            }
518        }
519        return false;
520    }
521
522    /**
523     * Get the values related to where the form was submitted from.
524     *
525     * @return array An array of entry values.
526     */
527    public function get_entry_values() {
528        // This is a convenience method to get the entry values in a simple array format.
529        $entry_values = array(
530            'email_marketing_consent' => (string) $this->has_consent ? 'yes' : 'no',
531            'entry_title'             => $this->source->get_title(),
532            'entry_permalink'         => $this->source->get_permalink(),
533            'feedback_id'             => $this->legacy_feedback_id,
534        );
535
536        if ( $this->source->get_page_number() > 1 ) {
537            $entry_values['entry_page'] = $this->source->get_page_number();
538        }
539        return $entry_values;
540    }
541
542    /**
543     * Get all values of the response.
544     *
545     * @param string $context The context in which the values are being retrieved.
546     *
547     * @return array An array of all values, including fields and entry values.
548     */
549    public function get_all_values( $context = 'default' ) {
550        // This is a legacy method to maintain compatibility with older code.
551        return array_merge( $this->get_compiled_fields( $context, 'key-value' ), $this->get_entry_values() );
552    }
553
554    /**
555     * Get the jetpack_form post ID associated with this feedback.
556     *
557     * @return int|null The form ID, or null if not submitted via reusable form.
558     */
559    public function get_form_id() {
560        return $this->form_id;
561    }
562
563    /**
564     * Get extra values.
565     * This is a legacy method to maintain compatibility with older code.
566     *
567     * @param string $context The context in which the values are being retrieved.
568     *
569     * @return array An array of extra values, including entry values
570     */
571    public function get_legacy_extra_values( $context = 'default' ) {
572        $count            = 1;
573        $_extra_fields    = array();
574        $special_fields   = array();
575        $non_extra_fields = array( 'email', 'name', 'url', 'subject', 'textarea', 'ip' );
576
577        // Create a map of special fields to check agains their values.
578        foreach ( $this->fields as $field ) {
579            if ( in_array( $field->get_type(), $non_extra_fields, true ) && $field->get_render_value( $context ) ) {
580                $special_fields[ $field->get_render_value( $context ) ] = true;
581            }
582        }
583
584        foreach ( $this->fields as $field ) {
585            if ( $field->compile_field( 'default' ) ) {
586                continue;
587            }
588            if ( $field->get_type() === 'basic' && isset( $special_fields[ $field->get_render_value() ] ) ) {
589                ++$count;
590                continue; // Skip fields that are already present in the non-extra fields.
591            }
592            $_extra_fields[] = $field;
593            ++$count; // Increment count to ensure unique keys for extra values.
594        }
595        $extra_values       = array();
596        $extra_fields_count = $count;
597        $is_present         = array(); // Used to store the value only once.
598
599        foreach ( $_extra_fields as $field ) {
600            if ( ! in_array( $field->get_type(), $non_extra_fields, true ) || isset( $is_present[ $field->get_type() ] ) ) {
601                $extra_values[ $extra_fields_count . '_' . $field->get_label() ] = $field->get_render_value( $context );
602                ++$extra_fields_count; // Increment count to ensure unique keys for extra values.
603            } else {
604                $is_present[ $field->get_type() ] = true;
605            }
606        }
607        return $extra_values;
608    }
609
610    /**
611     * Get all values of the response.
612     *
613     * @return array An array of all values, including fields and entry values.
614     */
615    public function get_all_legacy_values() {
616        return array(
617            '_feedback_author'       => $this->get_author(),
618            '_feedback_author_email' => $this->get_author_email(),
619            '_feedback_author_url'   => $this->get_author_url(),
620            '_feedback_subject'      => $this->get_subject(),
621            '_feedback_ip'           => $this->get_ip_address(),
622            '_feedback_all_fields'   => $this->get_all_values(),
623        );
624    }
625    /**
626     * Return the compiled fields for the given context.
627     *
628     * @param string $context The context in which the fields are compiled.
629     * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'.
630     *
631     * @return array An array of compiled fields with labels and values.
632     */
633    public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) {
634        $compiled_fields = array();
635
636        $count_field_labels = array();
637        foreach ( $this->fields as $field ) {
638            if ( $field->compile_field( $context ) ) {
639                continue; // Skip fields that are not meant to be rendered.
640            }
641
642            // Don't show the hidden fields in the user context.
643            if ( in_array( $context, array( 'web', 'ajax' ), true ) ) {
644                if ( $field->is_of_type( 'hidden' ) ) {
645                    continue;
646                }
647            }
648
649            $label = $field->get_label( $context );
650
651            if ( ! isset( $count_field_labels[ $label ] ) ) {
652                $count_field_labels[ $label ] = 1;
653            } else {
654                ++$count_field_labels[ $label ];
655            }
656
657            // Compile the field based on the requested shape.
658            switch ( $array_shape ) {
659                case 'default':
660                case 'all':
661                    $compiled_fields[ $field->get_key() ] = array(
662                        'label' => $label,
663                        'value' => $field->get_render_value( $context ),
664                    );
665                    break;
666                case 'label|value':
667                    $compiled_fields[] = array(
668                        'label' => $label,
669                        'value' => $field->get_render_value( $context ),
670                    );
671                    break;
672                case 'value':
673                    $compiled_fields[] = $field->get_render_value( $context );
674                    break;
675                case 'label':
676                    $compiled_fields[] = $label;
677                    break;
678                case 'key-value':
679                    $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context );
680                    break;
681                case 'label-value':
682                    $compiled_fields[ $field->get_label( $context, $count_field_labels[ $label ] ) ] = $field->get_render_value( $context );
683                    break;
684                case 'id-value':
685                    $compiled_fields[ $field->get_form_field_id() ] = $field->get_render_value( $context );
686                    break;
687            }
688        }
689
690        return $compiled_fields;
691    }
692
693    /**
694     * Get the feedback ID of the response.
695     * Which is the same as the post name for feedback entries.
696     * Please note that this is not the same as the feedback post ID.
697     *
698     * @return string
699     */
700    public function get_feedback_id() {
701        return $this->legacy_feedback_id;
702    }
703
704    /**
705     * Get the feedback title of the response.
706     *
707     * This is mostly used for legacy reasons.
708     *
709     * @return string
710     */
711    public function get_title() {
712        return $this->legacy_feedback_title;
713    }
714
715    /**
716     * Get the time of the feedback entry.
717     *
718     * @return string
719     */
720    public function get_time() {
721        return $this->feedback_time;
722    }
723
724    /**
725     * Get the askimet vars that are used to check for spam.
726     *
727     * These are the variables that are sent to Akismet to check if the feedback is spam or not.
728     *
729     * @return array
730     */
731    public function get_akismet_vars() {
732        $akismet_vars = array(
733            'comment_author'       => $this->author_data->get_name(),
734            'comment_author_email' => $this->author_data->get_email(),
735            'comment_author_url'   => $this->author_data->get_url(),
736            'contact_form_subject' => $this->get_subject(),
737            'comment_author_ip'    => $this->get_ip_address(),
738            'comment_content'      => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(),
739        );
740
741        foreach ( $this->fields as $field ) {
742
743            // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
744            // from a spam-filtering point of view.
745            if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file', 'image-select' ), true ) ) {
746                continue;
747            }
748
749            // Normalize the label into a slug.
750            $field_slug = trim( // Strip all leading/trailing dashes.
751                preg_replace(   // Normalize everything to a-z0-9_-
752                    '/[^a-z0-9_]+/',
753                    '-',
754                    strtolower( $field->get_label() ) // Lowercase
755                ),
756                '-'
757            );
758
759            $field_value = $field->get_render_value( 'akismet' );
760
761            // Skip any values that are already in the array we're sending.
762            if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) {
763                continue;
764            }
765
766            $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
767        }
768
769        return $akismet_vars;
770    }
771
772    /**
773     * Get the author name of the feedback entry.
774     * If the author is not provided we will use the email instead.
775     *
776     * @return string
777     */
778    public function get_author() {
779        return $this->author_data->get_display_name();
780    }
781
782    /**
783     * Get the author name of a feedback entry.
784     *
785     * @return string
786     */
787    public function get_author_name() {
788        return $this->author_data->get_name();
789    }
790
791    /**
792     * Get the author's first name of a feedback entry.
793     *
794     * @return string
795     */
796    public function get_author_first_name() {
797        return $this->author_data->get_first_name();
798    }
799
800    /**
801     * Get the author's last name of a feedback entry.
802     *
803     * @return string
804     */
805    public function get_author_last_name() {
806        return $this->author_data->get_last_name();
807    }
808
809    /**
810     * Get the author email of a feedback entry.
811     *
812     * @return string
813     */
814    public function get_author_email() {
815        return $this->author_data->get_email();
816    }
817
818    /**
819     * Get the author's gravatar URL.
820     *
821     * This is a convenience method to get the author's gravatar URL.
822     *
823     * @return string
824     */
825    public function get_author_avatar() {
826        return $this->author_data->get_avatar_url();
827    }
828
829    /**
830     * Get the author url of a feedback entry.
831     *
832     * @return string
833     */
834    public function get_author_url() {
835        return $this->author_data->get_url();
836    }
837
838    /**
839     * Get the comment content of a feedback entry.
840     *
841     * @return string
842     */
843    public function get_comment_content() {
844        return $this->comment_content;
845    }
846
847    /**
848     * Get the IP address of the submitted feedback request.
849     *
850     * @return string|null
851     */
852    public function get_ip_address() {
853        return $this->ip_address;
854    }
855
856    /**
857     * Get the user agent of the submitted feedback request.
858     *
859     * @return string|null
860     */
861    public function get_user_agent() {
862        return $this->user_agent;
863    }
864
865    /**
866     * Get the country code derived from the IP address.
867     *
868     * @return string|null
869     */
870    public function get_country_code() {
871        return $this->country_code;
872    }
873
874    /**
875     * Get the form fill duration in seconds.
876     *
877     * Represents the time from first user interaction to form submission.
878     *
879     * @return int|null
880     */
881    public function get_form_fill_duration() {
882        return $this->form_fill_duration;
883    }
884
885    /**
886     * Get the emoji flag for the country.
887     *
888     * @return string The emoji flag for the country code, or empty string if unavailable.
889     */
890    public function get_country_flag() {
891        return self::country_code_to_emoji_flag( $this->country_code );
892    }
893
894    /**
895     * Convert a country code to an emoji flag.
896     *
897     * Country codes should already be uppercase as they're stored that way by get_country_code_from_ip().
898     *
899     * @param string $country_code - the two-letter country code (e.g., 'US', 'GB', 'DE').
900     *
901     * @return string The emoji flag for the country code, or empty string if invalid.
902     */
903    private static function country_code_to_emoji_flag( $country_code ) {
904        if ( empty( $country_code ) || strlen( $country_code ) !== 2 ) {
905            return '';
906        }
907
908        // Convert each letter to a regional indicator symbol
909        // Regional indicator symbols start at Unicode code point 127462 (🇦)
910        // and correspond to A-Z (ASCII 65-90)
911        $flag = '';
912        for ( $i = 0; $i < 2; $i++ ) {
913            $char = $country_code[ $i ];
914
915            // Check if the character is a valid uppercase letter (A-Z)
916            if ( ord( $char ) < 65 || ord( $char ) > 90 ) {
917                return '';
918            }
919
920            $code_point = 127462 + ( ord( $char ) - 65 );
921
922            // Convert code point to UTF-8 encoded character
923            $flag .= mb_chr( $code_point, 'UTF-8' );
924        }
925
926        return $flag;
927    }
928
929    /**
930     * Get country code from IP address.
931     *
932     * This method uses a filter to allow custom implementations of GeoIP lookup.
933     * The filter should return a country code (e.g., 'US', 'GB', 'DE') or null.
934     *
935     * @param string|null $ip_address The IP address.
936     * @return string|null The country code or null if unavailable.
937     */
938    private function get_country_code_from_ip( $ip_address ) {
939        if ( ! $ip_address ) {
940            return null;
941        }
942        // This filter allows site owners to disable IP address storage entirely as well as GeoIP lookups.
943        // This filter is documented in src/contact-form/class-contact-form-plugin.php
944        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false ) ) {
945            return null;
946        }
947
948        /**
949         * Filter to get country code from IP address.
950         *
951         * @since $$NEXT_VERSION$$
952         *
953         * @param string|null $country The country code (e.g., 'US', 'GB', 'DE') or null.
954         * @param string      $ip_address The IP address to look up.
955         * @param string      $context The context for the geolocation request.
956         */
957        $country = apply_filters( 'jetpack_get_country_from_ip', null, $ip_address, 'form-response' );
958        if ( is_string( $country ) ) {
959            return strtoupper( $country );
960        }
961
962        $headers = array(
963            'MM_COUNTRY_CODE',
964            'GEOIP_COUNTRY_CODE',
965            'HTTP_CF_IPCOUNTRY',
966            'HTTP_X_COUNTRY_CODE',
967            'HTTP_X_APPENGINE_COUNTRY',
968            'HTTP_X_FORWARDED_FOR_COUNTRY',
969            'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
970        );
971
972        // Check for headers from the server.
973        foreach ( $headers as $header ) {
974            if ( isset( $_SERVER[ $header ] ) ) {
975                $country = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
976                if ( ! empty( $country ) ) {
977                    return strtoupper( $country );
978                }
979            }
980        }
981
982        if ( function_exists( 'geoip_country_code_by_name' ) ) {
983            $country = geoip_country_code_by_name( $ip_address );
984            if ( ! empty( $country ) ) {
985                return strtoupper( $country );
986            }
987        }
988
989        $country = self::geolocate_via_api( $ip_address );
990        if ( ! empty( $country ) ) {
991            return strtoupper( $country );
992        }
993
994        return null;
995    }
996
997    /**
998     * Use APIs to Geolocate the IP address.
999     *
1000     * @param  string $ip_address IP address.
1001     * @return string
1002     */
1003    private static function geolocate_via_api( $ip_address ) {
1004        $country_code = \get_transient( 'geoip_' . $ip_address );
1005        if ( false === $country_code ) {
1006            $response = Client::wpcom_json_api_request_as_blog(
1007                '/ip-to-geo/' . $ip_address,
1008                '2',
1009                array( 'method' => 'GET' ),
1010                null,
1011                'wpcom'
1012            );
1013
1014            if ( ! is_wp_error( $response ) && ! empty( $response['body'] ) ) {
1015                $data         = json_decode( $response['body'] );
1016                $country_code = $data->country_short ?? '';
1017                $country_code = \sanitize_text_field( $country_code );
1018                // Share the transient with woocommerce to avoid multiple lookups.
1019                \set_transient( 'geoip_' . $ip_address, $country_code, DAY_IN_SECONDS );
1020            }
1021        }
1022        return $country_code;
1023    }
1024
1025    /**
1026     * Get the browser information from the user agent.
1027     *
1028     * Returns a formatted string like "Chrome (Desktop)" or "Safari (Mobile)".
1029     *
1030     * @return string|null Browser information or null if user agent is not available.
1031     */
1032    public function get_browser() {
1033        if ( empty( $this->user_agent ) ) {
1034            return null;
1035        }
1036
1037        // Use Jetpack Device Detection to parse the user agent.
1038        $ua_info = new User_Agent_Info( $this->user_agent );
1039
1040        // Get browser name.
1041        $browser_name = $ua_info->get_browser_display_name();
1042
1043        if ( $browser_name === User_Agent_Info::OTHER ) {
1044            return __( 'Unknown browser', 'jetpack-forms' );
1045        }
1046
1047        // Determine platform type (Mobile, Tablet, or Desktop).
1048        $platform_type = 'Desktop';
1049        if ( $ua_info->is_tablet() ) {
1050            $platform_type = 'Tablet';
1051        } elseif ( $ua_info->get_platform() ) {
1052            // If there's a mobile platform detected (not false), it's mobile.
1053            $platform_type = 'Mobile';
1054        }
1055
1056        return sprintf( '%s (%s)', $browser_name, $platform_type );
1057    }
1058
1059    /**
1060     * Get the email subject.
1061     *
1062     * @return string
1063     */
1064    public function get_subject() {
1065        return $this->subject;
1066    }
1067
1068    /**
1069     * Gets the notification recipients of the feedback entry.
1070     *
1071     * @return array
1072     */
1073    public function get_notification_recipients() {
1074        return $this->notification_recipients;
1075    }
1076
1077    /**
1078     * Gets the value of the consent field.
1079     *
1080     * @return bool
1081     */
1082    public function has_consent() {
1083        return $this->has_consent;
1084    }
1085
1086    /**
1087     * Gets the value of the consent field.
1088     *
1089     * @return bool
1090     */
1091    public function has_file() {
1092        return $this->has_file;
1093    }
1094
1095    /**
1096     * Check if the feedback is unread.
1097     *
1098     * @return bool
1099     */
1100    public function is_unread() {
1101        return $this->is_unread;
1102    }
1103
1104    /**
1105     * Mark the feedback as read.
1106     *
1107     * @return bool True on success, false on failure.
1108     */
1109    public function mark_as_read() {
1110        if ( ! $this->post_id ) {
1111            return false;
1112        }
1113
1114        $updated = wp_update_post(
1115            array(
1116                'ID'             => $this->post_id,
1117                'comment_status' => self::STATUS_READ,
1118            )
1119        );
1120
1121        if ( ! is_wp_error( $updated ) && $updated ) {
1122            $this->is_unread = false;
1123            return true;
1124        }
1125
1126        return false;
1127    }
1128
1129    /**
1130     * Mark the feedback as unread.
1131     *
1132     * @return bool True on success, false on failure.
1133     */
1134    public function mark_as_unread() {
1135        if ( ! $this->post_id ) {
1136            return false;
1137        }
1138
1139        $updated = wp_update_post(
1140            array(
1141                'ID'             => $this->post_id,
1142                'comment_status' => self::STATUS_UNREAD,
1143            )
1144        );
1145
1146        if ( ! is_wp_error( $updated ) && $updated ) {
1147            $this->is_unread = true;
1148            return true;
1149        }
1150
1151        return false;
1152    }
1153
1154    /**
1155     * Get the count of unread feedback entries.
1156     *
1157     * @return int
1158     */
1159    public static function get_unread_count() {
1160        $query = new \WP_Query(
1161            array(
1162                'post_type'      => self::POST_TYPE,
1163                'post_status'    => 'publish',
1164                'comment_status' => self::STATUS_UNREAD,
1165                'posts_per_page' => -1,
1166                'fields'         => 'ids',
1167            )
1168        );
1169        return (int) $query->found_posts;
1170    }
1171
1172    /**
1173     * Get the uploaded files from the feedback entry.
1174     *
1175     * @return array
1176     */
1177    public function get_files() {
1178        $files = array();
1179        foreach ( $this->fields as $field ) {
1180            if ( $field->get_type() === 'file' ) {
1181                $field_value = $field->get_value();
1182                if ( ! empty( $field_value['files'] ) && is_array( $field_value['files'] ) ) {
1183                    $field_value['files'] = array_filter(
1184                        $field_value['files'],
1185                        function ( $file ) {
1186                            if ( empty( $file['file_id'] ) ) {
1187                                return false;
1188                            }
1189                            if ( empty( $file['name'] ) ) {
1190                                return false;
1191                            }
1192                            if ( empty( $file['size'] ) ) {
1193                                return false;
1194                            }
1195                            if ( empty( $file['type'] ) ) {
1196                                return false;
1197                            }
1198                            return true;
1199                        }
1200                    );
1201
1202                    $files = array_merge( $files, $field_value['files'] );
1203                }
1204            }
1205        }
1206        return $files;
1207    }
1208
1209    /**
1210     * Get the feedback status. For example 'publish', 'spam' or 'trash'.
1211     *
1212     * @return string
1213     */
1214    public function get_status() {
1215        return $this->status;
1216    }
1217
1218    /**
1219     * Sets the status of the feedback.
1220     *
1221     * @param string $status The status to set for the feedback entry.
1222     * @return void
1223     */
1224    public function set_status( $status ) {
1225        $this->status = $status;
1226    }
1227
1228    /**
1229     * Get the entry ID of the post that the feedback was submitted from.
1230     *
1231     * This is the post ID of the post or page that the feedback was submitted from.
1232     *
1233     * @return int|string
1234     */
1235    public function get_entry_id() {
1236        return $this->source->get_id();
1237    }
1238
1239    /**
1240     * Get the entry title of the post that the feedback was submitted from.
1241     *
1242     * This is the title of the post or page that the feedback was submitted from.
1243     *
1244     * @return string
1245     */
1246    public function get_entry_title() {
1247        return $this->source->get_title();
1248    }
1249
1250    /**
1251     * Get the permalink of the post or page that the feedback was submitted from.
1252     * This includes the page number if the feedback was submitted from a paginated form.
1253     *
1254     * @return string
1255     */
1256    public function get_entry_permalink() {
1257        return $this->source->get_permalink();
1258    }
1259
1260    /**
1261     * Get the editor URL where the user can edit the form.
1262     *
1263     * @return string
1264     */
1265    public function get_edit_form_url() {
1266        if ( ! empty( $this->form_id ) ) {
1267            return \get_edit_post_link( (int) $this->form_id, 'url' );
1268        }
1269        return $this->source->get_edit_form_url();
1270    }
1271    /**
1272     * Get the short permalink of a post.
1273     *
1274     * @return string
1275     */
1276    public function get_entry_short_permalink() {
1277        return $this->source->get_relative_permalink();
1278    }
1279    /**
1280     * Save the feedback entry to the database.
1281     *
1282     * @return int
1283     */
1284    public function save() {
1285        $post_id = wp_insert_post(
1286            array(
1287                'post_type'      => self::POST_TYPE,
1288                'post_status'    => $this->status,
1289                'post_title'     => $this->legacy_feedback_title,
1290                'post_date'      => $this->feedback_time,
1291                'post_name'      => $this->legacy_feedback_id,
1292                'post_content'   => $this->serialize(), // In V3 we started to addslashes.
1293                'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
1294                'post_parent'    => $this->form_id ?? $this->source->get_id(),
1295                'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
1296            )
1297        );
1298
1299        $feedback_post = get_post( $post_id );
1300        return $feedback_post ?? 0;
1301    }
1302
1303    /**
1304     * Serialize the fields to JSON format.
1305     *
1306     * @return string
1307     */
1308    public function serialize() {
1309
1310        $fields_to_serialize = array_merge(
1311            array(
1312                'subject'                 => $this->subject,
1313                'ip'                      => $this->ip_address,
1314                'country_code'            => $this->country_code,
1315                'user_agent'              => $this->user_agent,
1316                'form_fill_duration'      => $this->form_fill_duration,
1317                'notification_recipients' => $this->notification_recipients,
1318            ),
1319            $this->source->serialize()
1320        );
1321
1322        $fields_to_serialize['fields'] = array();
1323        foreach ( $this->fields as $field ) {
1324            $fields_to_serialize['fields'][] = $field->serialize();
1325        }
1326
1327        // Check if the IP and country_code should be included.
1328        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) {
1329            $fields_to_serialize['ip']           = null;
1330            $fields_to_serialize['country_code'] = null;
1331        }
1332
1333        return addslashes( wp_json_encode( $fields_to_serialize, JSON_UNESCAPED_SLASHES ) );
1334    }
1335
1336    /**
1337     * Helper function to parse the post content.
1338     *
1339     * @param string      $post_content The post content to parse.
1340     * @param string|null $version The version of the content format.
1341     * @return array Parsed fields.
1342     */
1343    private function parse_content( $post_content = '', $version = null ) {
1344        if ( $version === 'v3' ) {
1345            return $this->parse_content_v3( $post_content );
1346        }
1347        if ( $version === 'v2' ) {
1348            return $this->parse_content_v2( $post_content );
1349        }
1350        return $this->parse_legacy_content( $post_content );
1351    }
1352
1353    /**
1354     * Parse the content in the v2 format.
1355     *
1356     * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1357     *
1358     * @param string $post_content The post content to parse.
1359     *
1360     * @return array Parsed fields.
1361     */
1362    private function parse_content_v2( $post_content = '' ) {
1363        $decoded_content = json_decode( $post_content, true );
1364        if ( $decoded_content === null ) {
1365            // If JSON decoding still fails, try with stripslashes and trim as a fallback
1366            // This is a workaround for some cases where the JSON data is not properly formatted
1367            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1368        }
1369
1370        if ( $decoded_content === null ) {
1371            // Final fallback: attempt to fix malformed JSON with unescaped quotes
1372            // Apply stripslashes first, then fix remaining issues
1373            $stripped_content = stripslashes( trim( $post_content ) );
1374            $fixed_content    = self::fix_malformed_json( $stripped_content );
1375            $decoded_content  = json_decode( $fixed_content, true );
1376        }
1377
1378        if ( $decoded_content === null ) {
1379            return array();
1380        }
1381        $fields = array();
1382        foreach ( $decoded_content['fields'] as $field ) {
1383            $feedback_field = Feedback_Field::from_serialized_v2( $field );
1384            if ( $feedback_field instanceof Feedback_Field ) {
1385                $fields[ $feedback_field->get_key() ] = $feedback_field;
1386                if ( ! $this->has_file && $feedback_field->has_file() ) {
1387                    $this->has_file = true;
1388                }
1389            }
1390        }
1391        $decoded_content['fields'] = $fields;
1392        return $decoded_content;
1393    }
1394
1395    /**
1396     * Parse the content in the v3 format.
1397     *
1398     * @param string $post_content The post content to parse.
1399     *
1400     * @return array Parsed fields.
1401     */
1402    private function parse_content_v3( $post_content = '' ) {
1403        $decoded_content = json_decode( $post_content, true );
1404        if ( $decoded_content === null ) {
1405            // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1406            // This is a workaround for some cases where the JSON data is not properly formatted.
1407            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1408        }
1409        if ( $decoded_content === null ) {
1410            return array();
1411        }
1412        $fields = array();
1413        foreach ( $decoded_content['fields'] as $field ) {
1414            $feedback_field = Feedback_Field::from_serialized( $field );
1415            if ( $feedback_field instanceof Feedback_Field ) {
1416                $fields[ $feedback_field->get_key() ] = $feedback_field;
1417                if ( ! $this->has_file && $feedback_field->has_file() ) {
1418                    $this->has_file = true;
1419                }
1420            }
1421        }
1422        $decoded_content['fields'] = $fields;
1423        return $decoded_content;
1424    }
1425
1426    /**
1427     * Parse the legacy content format.
1428     *
1429     * @param string $post_content The post content to parse.
1430     *
1431     * @return array Parsed fields.
1432     */
1433    private function parse_legacy_content( $post_content = '' ) {
1434        $content_parts   = $this->split_legacy_content( $post_content );
1435        $comment_content = $content_parts['comment_content'];
1436        $field_content   = $content_parts['field_content'];
1437
1438        $all_values = $this->extract_legacy_values( $field_content );
1439        $lines      = $this->extract_legacy_lines( $field_content );
1440
1441        $decoded_fields           = array();
1442        $decoded_fields['fields'] = array();
1443
1444        // Process lines for specific field types
1445        $this->process_legacy_lines( $lines, $decoded_fields );
1446
1447        // Process all other values
1448        $this->process_legacy_values( $all_values, $decoded_fields );
1449
1450        // Add comment content field
1451        $this->add_comment_content_field( $comment_content, $decoded_fields );
1452
1453        return $decoded_fields;
1454    }
1455
1456    /**
1457     * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1458     *
1459     * This method handles cases where JSON contains unescaped quotes within string values,
1460     * which causes json_decode to fail.
1461     *
1462     * @param string $json malformed JSON string.
1463     * @return string The JSON string with escaped quotes.
1464     */
1465    public static function fix_malformed_json( $json ) {
1466
1467        $find    = array();
1468        $replace = array();
1469
1470        // Start of JSON object
1471        $find[]    = '{\"';
1472        $replace[] = '{"';
1473
1474        // Key-value separator
1475        $find[]    = '\":\"';
1476        $replace[] = '":"';
1477
1478        $find[]    = '\\\"';
1479        $replace[] = '\"';
1480
1481        $find[]    = '\":[\"';
1482        $replace[] = '":["';
1483
1484        $find[]    = '\"],';
1485        $replace[] = '"],';
1486
1487        $find[]    = ',[\"';
1488        $replace[] = ',["';
1489
1490        $find[]    = '\",\"';
1491        $replace[] = '","';
1492
1493        $find[]    = ',\"';
1494        $replace[] = ',"';
1495
1496        $find[]    = '\", \"';
1497        $replace[] = '", "';
1498
1499        $find[]    = '\"],\"';
1500        $replace[] = '"],"';
1501
1502        $find[]    = '\"],"';
1503        $replace[] = '"],"';
1504
1505        $find[]    = '\":[]';
1506        $replace[] = '":[]';
1507
1508        $find[]    = '\"]}';
1509        $replace[] = '"]}';
1510
1511        $find[]    = '\":[';
1512        $replace[] = '":[';
1513
1514        $find[]    = '\":{';
1515        $replace[] = '":{';
1516
1517        $find[]    = '\":true';
1518        $replace[] = '":true';
1519
1520        $find[]    = '\":false';
1521        $replace[] = '":false';
1522
1523        $find[]    = '\":null';
1524        $replace[] = '":null';
1525
1526        for ( $i = 0; $i <= 9; $i++ ) {
1527            $find[]    = '\":' . $i;
1528            $replace[] = '":' . $i;
1529
1530            $find[]    = '\",' . $i;
1531            $replace[] = '",' . $i;
1532        }
1533
1534        $find[]    = '\",true';
1535        $replace[] = '",true';
1536
1537        $find[]    = '\",false';
1538        $replace[] = '",false';
1539
1540        $find[]    = '\",null';
1541        $replace[] = '",null';
1542
1543        $find[]    = "\'";
1544        $replace[] = "'";
1545
1546        // End of Json object
1547        $find[]    = '\"}';
1548        $replace[] = '"}';
1549
1550        // Remove any slashes that are there to start a new string.
1551        return str_replace( $find, $replace, addslashes( $json ) );
1552    }
1553
1554    /**
1555     * Split legacy content into comment and field sections.
1556     *
1557     * @param string $post_content The post content to parse.
1558     * @return array Array with 'comment_content' and 'field_content' keys.
1559     */
1560    private function split_legacy_content( $post_content ) {
1561        $content         = explode( '<!--more-->', $post_content );
1562        $comment_content = '';
1563        $field_content   = '';
1564
1565        if ( count( $content ) > 1 ) {
1566            $comment_content = $content[0];
1567            $field_content   = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1568        }
1569
1570        return array(
1571            'comment_content' => $comment_content,
1572            'field_content'   => $field_content,
1573        );
1574    }
1575
1576    /**
1577     * Extract values from legacy field content.
1578     *
1579     * @param string $field_content The field content to parse.
1580     * @return array Extracted values.
1581     */
1582    private function extract_legacy_values( $field_content ) {
1583        $all_values = array();
1584
1585        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1586            $all_values = $this->parse_json_data( $field_content );
1587        } else {
1588            $all_values = $this->parse_array_format( $field_content );
1589        }
1590
1591        // Ensure all_values is always an array
1592        if ( ! is_array( $all_values ) ) {
1593            $all_values = array();
1594        }
1595
1596        return $all_values;
1597    }
1598
1599    /**
1600     * Extract lines from legacy field content.
1601     *
1602     * @param string $field_content The field content to parse.
1603     * @return array Filtered lines.
1604     */
1605    private function extract_legacy_lines( $field_content ) {
1606        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1607            $chunks = explode( "\nJSON_DATA", $field_content );
1608            return array_filter( explode( "\n", $chunks[0] ) );
1609        } else {
1610            return array_filter( explode( "\n", $field_content ) );
1611        }
1612    }
1613
1614    /**
1615     * Parse JSON data from field content.
1616     *
1617     * @param string $field_content The field content containing JSON data.
1618     * @return array Parsed JSON data.
1619     */
1620    private function parse_json_data( $field_content ) {
1621        $chunks = explode( "\nJSON_DATA", $field_content );
1622
1623        if ( ! isset( $chunks[1] ) ) {
1624            // Try with 'JSON_DATA' without the newline as a fallback.
1625            $chunks = explode( 'JSON_DATA', $field_content );
1626            if ( ! isset( $chunks[1] ) ) {
1627                // If JSON_DATA is still not found, return an empty array.
1628                return array();
1629            }
1630        }
1631
1632        $json_data = $chunks[1];
1633
1634        $all_values = json_decode( $json_data, true );
1635
1636        if ( $all_values === null ) {
1637            // Fallback for improperly formatted JSON
1638            $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
1639        }
1640
1641        return $all_values === null ? array() : $all_values;
1642    }
1643
1644    /**
1645     * Parse array format from field content.
1646     *
1647     * @param string $field_content The field content in array format.
1648     * @return array Parsed array data.
1649     */
1650    private function parse_array_format( $field_content ) {
1651        $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
1652
1653        // Parse key-value pairs formatted as [Key] => Value
1654        preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
1655
1656        if ( count( $matches ) > 1 ) {
1657            return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1658        }
1659
1660        return array();
1661    }
1662
1663    /**
1664     * Process legacy lines into field objects.
1665     *
1666     * We do this so that we can extract specific fields but we don't display the values in the UI.
1667     *
1668     * @param array $lines The lines to process.
1669     * @param array &$decoded_fields Reference to the decoded fields array.
1670     */
1671    private function process_legacy_lines( $lines, &$decoded_fields ) {
1672        $var_map = array(
1673            'AUTHOR'       => array(
1674                'type'  => 'name',
1675                'label' => 'Author',
1676            ),
1677            'AUTHOR EMAIL' => array(
1678                'type'  => 'email',
1679                'label' => 'Email',
1680            ),
1681            'AUTHOR URL'   => array(
1682                'type'  => 'url',
1683                'label' => 'Url',
1684            ),
1685            'SUBJECT'      => array(
1686                'type'  => 'subject',
1687                'label' => 'Subject',
1688            ),
1689            'IP'           => array(
1690                'type'  => 'ip',
1691                'label' => 'IP',
1692            ),
1693        );
1694
1695        foreach ( $lines as $line ) {
1696            $line_parts = explode( ': ', $line, 2 );
1697
1698            if ( count( $line_parts ) !== 2 ) {
1699                continue;
1700            }
1701
1702            list( $key, $value ) = $line_parts;
1703
1704            if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
1705                $map_to_field = $var_map[ $key ];
1706                $value        = Contact_Form_Plugin::strip_tags( trim( $value ) );
1707
1708                $decoded_fields['fields'][ $key ] = new Feedback_Field(
1709                    $key,
1710                    $map_to_field['label'],
1711                    $value,
1712                    $map_to_field['type'],
1713                    array( 'render' => false )
1714                );
1715            }
1716        }
1717    }
1718
1719    /**
1720     * Check if the field is a legacy file upload.
1721     *
1722     * @param array $field The field to check.
1723     *
1724     * @return bool True if it's a legacy file upload, false otherwise.
1725     */
1726    private function is_legacy_file_upload( $field ) {
1727        return (
1728            is_array( $field ) &&
1729            ! empty( $field['field_id'] ) &&
1730            isset( $field['files'] ) &&
1731            is_array( $field['files'] )
1732        );
1733    }
1734
1735    /**
1736     * Process legacy values into field objects.
1737     *
1738     * @param array $all_values The values to process.
1739     * @param array &$decoded_fields Reference to the decoded fields array.
1740     */
1741    private function process_legacy_values( $all_values, &$decoded_fields ) {
1742        $non_user_fields = array(
1743            'email_marketing_consent',
1744            'entry_title',
1745            'entry_permalink',
1746            'entry_page',
1747            'feedback_id',
1748        );
1749
1750        foreach ( $all_values as $key => $value ) {
1751            $key   = wp_strip_all_tags( $key );
1752            $label = self::extract_label_from_key( $key );
1753
1754            if ( in_array( $key, $non_user_fields, true ) ) {
1755                if ( $key === 'email_marketing_consent' ) {
1756                    $decoded_fields['fields'][ $key ] = new Feedback_Field(
1757                        $key,
1758                        $label,
1759                        $value,
1760                        'consent',
1761                        array( 'render' => false )
1762                    );
1763                    continue;
1764                }
1765                $decoded_fields[ $key ] = $value;
1766                continue;
1767            }
1768
1769            // check for file upload data and then set it as a file type field.
1770            if ( $this->is_legacy_file_upload( $value ) ) {
1771                // If the value is a file upload, we need to handle it differently.
1772                $decoded_fields['fields'][ $key ] = new Feedback_Field(
1773                    $key,
1774                    $label,
1775                    $value,
1776                    'file'
1777                );
1778                $this->has_file                   = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
1779            } else {
1780                $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
1781            }
1782        }
1783    }
1784
1785    /**
1786     * Add comment content as a field.
1787     *
1788     * @param string $comment_content The comment content.
1789     * @param array  &$decoded_fields Reference to the decoded fields array.
1790     */
1791    private function add_comment_content_field( $comment_content, &$decoded_fields ) {
1792        $decoded_fields['fields']['comment_content'] = new Feedback_Field(
1793            'comment_content',
1794            'Comment Content',
1795            trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
1796            'textarea',
1797            array( 'render' => false )
1798        );
1799    }
1800
1801    /**
1802     * Extract the label from a key that might be in the format "1_label".
1803     *
1804     * @param string $key The key to extract the label from.
1805     * @return string The extracted label.
1806     */
1807    private static function extract_label_from_key( $key ) {
1808        // Check if the key starts with a number followed by underscore and has content after underscore
1809        if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
1810            return $matches[1];
1811        }
1812        // If the key is just a number followed by underscore (like "2_"), return empty string
1813        if ( preg_match( '/^\d+_$/', $key ) ) {
1814            return '';
1815        }
1816        // If the key doesn't start with a number followed by underscore, return the key as is
1817        return $key;
1818    }
1819
1820    /**
1821     * Get all the fields of the response, computed from the post data.
1822     *
1823     * @param array        $post_data The post data from the form submission.
1824     * @param Contact_Form $form The form object.
1825     * @return array An array of Feedback_Field objects.
1826     */
1827    private function get_computed_fields( $post_data, $form ) {
1828
1829        $fields = array();
1830
1831        $field_ids = $form->get_field_ids();
1832        // For all fields, grab label and value
1833        $i = 1;
1834        foreach ( $field_ids['all'] as $field_id ) {
1835            $field = $form->fields[ $field_id ];
1836            $type  = $field->get_attribute( 'type' );
1837            if ( ! $field->is_field_renderable( $type ) ) {
1838                continue;
1839            }
1840
1841            $value = $this->get_field_value( $field_id, $post_data, $type );
1842            $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
1843            $key   = $i . '_' . $label;
1844
1845            $meta           = array();
1846            $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
1847            if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
1848                $this->has_file = true;
1849            }
1850            ++$i; // Increment prefix counter for the next field.
1851        }
1852
1853        return $fields;
1854    }
1855
1856    /**
1857     * Gets the computed subject.
1858     *
1859     * @param array        $post_data The post data from the form submission.
1860     * @param Contact_Form $form The form object.
1861     * @return string
1862     */
1863    private function get_computed_subject( $post_data, $form ) {
1864
1865        $contact_form_subject = $form->get_attribute( 'subject' );
1866        $field_ids            = $form->get_field_ids();
1867
1868        if ( isset( $field_ids['subject'] ) ) {
1869            $value = $this->get_field_value( $field_ids['subject'], $post_data );
1870            if ( ! empty( $value ) ) {
1871                $contact_form_subject = $value;
1872            }
1873        }
1874
1875        return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
1876    }
1877
1878    /**
1879     * Gets the computed comment content.
1880     *
1881     * @param array        $post_data The post data from the form submission.
1882     * @param Contact_Form $form The form object.
1883     * @return string
1884     */
1885    private function get_computed_comment_content( $post_data, $form ) {
1886        $field_ids = $form->get_field_ids();
1887        if ( isset( $field_ids['textarea'] ) ) {
1888            $value = $this->get_field_value( $field_ids['textarea'], $post_data );
1889            if ( is_string( $value ) ) {
1890                return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
1891            }
1892        }
1893        return '';
1894    }
1895
1896    /**
1897     * Gets the computed consent.
1898     *
1899     * @param array        $post_data The post data from the form submission.
1900     * @param Contact_Form $form The form object.
1901     * @return bool
1902     */
1903    private function get_computed_consent( $post_data, $form ) {
1904        $field_ids = $form->get_field_ids();
1905
1906        if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
1907            return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
1908        }
1909
1910        return false;
1911    }
1912
1913    /**
1914     * Gets the computed notification recipients.
1915     *
1916     * @since 6.10.0
1917     *
1918     * @param array        $post_data The post data from the form submission.
1919     * @param Contact_Form $form The form object.
1920     * @return array
1921     */
1922    private function get_computed_notification_recipients( $post_data, $form ) {
1923        $notification_recipients = $form->get_attribute( 'notificationRecipients' );
1924        return $this->validate_notification_recipients( $notification_recipients );
1925    }
1926
1927    /**
1928     * Validates notification recipients have proper capabilities.
1929     *
1930     * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
1931     * Filters out invalid or unauthorized user IDs.
1932     *
1933     * @since 6.10.0
1934     *
1935     * @param array $recipients Array of user IDs.
1936     * @return array Array of validated user IDs.
1937     */
1938    private function validate_notification_recipients( $recipients ) {
1939        if ( ! is_array( $recipients ) ) {
1940            return array();
1941        }
1942
1943        $valid_recipients = array();
1944        foreach ( $recipients as $user_id ) {
1945            $user = get_userdata( $user_id );
1946            // Only allow users with edit_posts or edit_pages capability
1947            if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
1948                $valid_recipients[] = $user_id;
1949            }
1950        }
1951
1952        return $valid_recipients;
1953    }
1954
1955    /**
1956     * Get a field by its original form ID.
1957     *
1958     * @since 5.5.0
1959     *
1960     * @param string $id Original form field ID.
1961     * @return Feedback_Field|null
1962     */
1963    public function get_field_by_form_field_id( $id ) {
1964        if ( ! is_string( $id ) || $id === '' ) {
1965            return null;
1966        }
1967        foreach ( $this->fields as $field ) {
1968            if ( $field->get_form_field_id() === $id ) {
1969                return $field;
1970            }
1971        }
1972        return null;
1973    }
1974
1975    /**
1976     * Get a field render value by its original form ID.
1977     *
1978     * @since 5.5.0
1979     *
1980     * @param string $id Original form field ID.
1981     * @param string $context Render context.
1982     * @return string
1983     */
1984    public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
1985        $field = $this->get_field_by_form_field_id( $id );
1986        if ( ! $field ) {
1987            return '';
1988        }
1989        return (string) $field->get_render_value( $context );
1990    }
1991}