Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.55% covered (success)
94.55%
833 / 881
75.27% covered (warning)
75.27%
70 / 93
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feedback
94.55% covered (success)
94.55%
833 / 881
75.27% covered (warning)
75.27%
70 / 93
317.85
0.00% covered (danger)
0.00%
0 / 1
 get_all_source_post_ids
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 get_source_filter_sql
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 invalidate_source_ids_cache_on_delete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_backfill_source_meta
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 clear_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_from_post
100.00% covered (success)
100.00%
42 / 42
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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 without_hidden_answers
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 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
 process_radio_field_value
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
12.72
 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
 uses_structured_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
14
 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%
51 / 51
100.00% covered (success)
100.00%
1 / 1
15
 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
96.00% covered (success)
96.00%
24 / 25
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
 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_logged_in_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
 is_test
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mark_as_test
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 save
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 serialize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 parse_content
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 is_json
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 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_field_meta
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 get_computed_fields
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
9
 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_form_fill_duration
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 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 Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
13use WP_Post;
14/**
15 * Handles the response for a contact form submission.
16 *
17 * Feedback objects are there to help us interact with the form response data.
18 */
19class Feedback {
20    use Country_Code_Utils;
21
22    const POST_TYPE = 'feedback';
23
24    /**
25     * Comment status for unread feedback.
26     *
27     * @var string
28     */
29    public const STATUS_UNREAD = 'open';
30
31    /**
32     * Comment status for read feedback.
33     *
34     * @var string
35     */
36    public const STATUS_READ = 'closed';
37
38    /**
39     * Meta key used to store the source post ID on feedback posts.
40     *
41     * @var string
42     */
43    public const SOURCE_META_KEY = '_feedback_source_post_id';
44
45    /**
46     * Post meta key flagging a feedback entry as a test submission (from a
47     * form preview). Stored as `1` when `Feedback_Source::is_test()` is true
48     * so collections can filter test responses at the database level without
49     * parsing the serialized source.
50     *
51     * @var string
52     */
53    public const IS_TEST_META_KEY = '_feedback_is_test';
54
55    /**
56     * Name of the hidden POST field carrying the form fill duration.
57     *
58     * Prefixed because submitted fields share one flat POST namespace with author-defined
59     * fields, whose names a site owner can set by hand. An unprefixed `form_fill_duration`
60     * field would silently overwrite this one.
61     *
62     * @since 7.24.0
63     *
64     * @var string
65     */
66    public const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration';
67
68    /**
69     * Cache key for the source post IDs list.
70     *
71     * @var string
72     */
73    private const SOURCE_IDS_CACHE_KEY = 'jetpack_forms_source_post_ids';
74
75    /**
76     * Cache group for forms data.
77     *
78     * @var string
79     */
80    private const CACHE_GROUP = 'jetpack_forms';
81
82    /**
83     * Returns all distinct source post IDs for feedback entries.
84     *
85     * Uses the _feedback_source_post_id meta for new feedback, with a fallback
86     * to post_parent for old feedback that doesn't have the meta yet (excluding
87     * jetpack_form parents).
88     *
89     * @return array Array of unique source post IDs.
90     */
91    public static function get_all_source_post_ids() {
92        $source_ids = wp_cache_get( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
93
94        if ( false !== $source_ids ) {
95            return $source_ids;
96        }
97
98        global $wpdb;
99
100        $meta_key     = self::SOURCE_META_KEY;
101        $statuses     = array( 'draft', 'publish', 'spam', 'trash' );
102        $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) );
103
104        $post_type = self::POST_TYPE;
105
106        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
107        $source_ids = $wpdb->get_col(
108            $wpdb->prepare(
109                "SELECT DISTINCT source_id FROM (
110                    SELECT CAST(pm.meta_value AS UNSIGNED) AS source_id
111                    FROM {$wpdb->postmeta} pm
112                    INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
113                    WHERE pm.meta_key = %s
114                    AND p.post_type = %s
115                    AND p.post_status IN ({$placeholders})
116                    AND pm.meta_value != '0' AND pm.meta_value != ''
117                UNION
118                    SELECT p.post_parent AS source_id
119                    FROM {$wpdb->posts} p
120                    LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
121                    LEFT JOIN {$wpdb->posts} parent_post ON parent_post.ID = p.post_parent
122                    WHERE p.post_type = %s
123                    AND p.post_status IN ({$placeholders})
124                    AND p.post_parent > 0
125                    AND pm.meta_id IS NULL
126                    AND (parent_post.post_type IS NULL OR parent_post.post_type != %s)
127                ) AS combined_sources",
128                array_merge(
129                    array( $meta_key, $post_type ),
130                    $statuses,
131                    array( $meta_key, $post_type ),
132                    $statuses,
133                    array( Contact_Form::POST_TYPE )
134                )
135            )
136        );
137        // phpcs:enable
138
139        $source_ids = array_map( 'intval', $source_ids );
140        wp_cache_set( self::SOURCE_IDS_CACHE_KEY, $source_ids, self::CACHE_GROUP, HOUR_IN_SECONDS );
141
142        return $source_ids;
143    }
144
145    /**
146     * Returns the JOIN and WHERE SQL fragments for filtering feedback posts by source post ID.
147     *
148     * Matches feedback with the _feedback_source_post_id meta set, or falls back
149     * to post_parent for old feedback that doesn't have the meta yet.
150     *
151     * @since 7.19.0
152     *
153     * @param int $source_id The source post ID to filter by.
154     * @return array{join: string, where: string} SQL fragments.
155     */
156    public static function get_source_filter_sql( $source_id ) {
157        global $wpdb;
158        $meta_key  = esc_sql( self::SOURCE_META_KEY );
159        $source_id = (int) $source_id;
160        return array(
161            'join'  => " LEFT JOIN {$wpdb->postmeta} AS source_meta ON ({$wpdb->posts}.ID = source_meta.post_id AND source_meta.meta_key = '{$meta_key}')",
162            'where' => $wpdb->prepare(
163                "(source_meta.meta_value = %s OR (source_meta.meta_id IS NULL AND {$wpdb->posts}.post_parent = %d))",
164                (string) $source_id,
165                $source_id
166            ),
167        );
168    }
169
170    /**
171     * Invalidates the source post IDs cache when a feedback post is deleted.
172     *
173     * @param int      $post_id The deleted post ID.
174     * @param \WP_Post $post    The deleted post object.
175     */
176    public static function invalidate_source_ids_cache_on_delete( $post_id, $post ) {
177        if ( $post->post_type === self::POST_TYPE ) {
178            wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
179        }
180    }
181
182    /**
183     * Backfills the source post ID meta from the feedback object's resolved source.
184     *
185     * For old feedback parented to a jetpack_form that doesn't have
186     * _feedback_source_post_id set yet, this writes the meta so future
187     * queries can filter by source without the post_parent fallback.
188     *
189     * @param int      $post_id  The feedback post ID.
190     * @param Feedback $feedback The feedback object (already has source resolved from parsed content).
191     */
192    public static function maybe_backfill_source_meta( $post_id, $feedback ) {
193        $existing = get_post_meta( $post_id, self::SOURCE_META_KEY, true );
194        if ( $existing ) {
195            return;
196        }
197
198        $source_id = $feedback->get_entry_id();
199        if ( is_numeric( $source_id ) && (int) $source_id > 0 ) {
200            $meta_added = add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
201            if ( $meta_added ) {
202                wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
203            }
204        }
205    }
206
207    /**
208     * The form field values.
209     *
210     * @var array
211     */
212    protected $fields = array();
213
214    /**
215     * Static cache for feedback fields.
216     *
217     * This is used to avoid recomputing the feedback fields for the same post ID.
218     *
219     * @var array
220     */
221    private static $feedback_fields = array();
222
223    /**
224     * Does the response have files attached to it?
225     *
226     * @var bool
227     */
228    protected $has_file = false;
229
230    /**
231     * The status of the feedback entry.
232     *
233     * @var string
234     */
235    protected $status = 'publish'; // Default status is 'publish' or other statuses as needed.
236
237    /**
238     * The IP address of the user who submitted the feedback.
239     *
240     * 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.
241     *
242     * @var string|null
243     */
244    protected $ip_address = null;
245
246    /**
247     * The user agent of the user who submitted the feedback.
248     *
249     * This is only available on form submissions, and might not be available when retrieving existing feedback posts.
250     *
251     * @var string|null
252     */
253    protected $user_agent = null;
254
255    /**
256     * The country code derived from the IP address.
257     *
258     * This is derived from the IP address and stored for easier display.
259     *
260     * @var string|null
261     */
262    protected $country_code = null;
263
264    /**
265     * The form fill duration in seconds.
266     *
267     * Tracks how long the user spent filling out the form (from first interaction to submission).
268     *
269     * @var int|null
270     */
271    protected $form_fill_duration = null;
272
273    /**
274     * The subject of the feedback entry.
275     *
276     * @var string
277     */
278    protected $subject = '';
279
280    /**
281     * Feedback ID of the feedback entry.
282     *
283     * Marked as legacy because it is not used in the new feedback system.
284     *
285     * @var string
286     */
287    protected $legacy_feedback_id = '';
288
289    /**
290     * The title of the feedback entry.
291     *
292     * Marked as legacy because it is not used in the new feedback system.
293     *
294     * @var string
295     */
296    protected $legacy_feedback_title = '';
297
298    /**
299     * The time of the feedback entry.
300     *
301     * This is used to store the title of the feedback entry.
302     *
303     * @var string
304     */
305    protected $feedback_time = '';
306
307    /**
308     * The Feedback_Author of the feedback entry.
309     *
310     * @var Feedback_Author
311     */
312    protected $author_data;
313
314    /**
315     * The comment content of the feedback entry.
316     *
317     * @var string
318     */
319    protected $comment_content = '';
320
321    /**
322     * Whether the user has given consent for data processing.
323     *
324     * @var bool
325     */
326    protected $has_consent = false;
327
328    /**
329     * Whether this response was loaded from structured feedback data.
330     *
331     * @var bool
332     */
333    protected $uses_structured_fields = false;
334
335    /**
336     * Whether the feedback entry is unread.
337     *
338     * @var bool
339     */
340    protected $is_unread = true;
341
342    /**
343     * The post ID of the feedback entry.
344     *
345     * @var int|null
346     */
347    protected $post_id = null;
348
349    /**
350     * The entry object of the post that the feedback was submitted from.
351     *
352     * This is used to store the entry object of the post that the feedback was submitted from.
353     *
354     * @var Feedback_Source
355     */
356    protected $source;
357
358    /**
359     * The notification recipients of the feedback entry.
360     *
361     * @var array
362     */
363    protected $notification_recipients = array();
364
365    /**
366     * The jetpack_form post ID associated with this feedback, when available.
367     *
368     * @var int|null
369     */
370    protected $form_id = null;
371
372    /**
373     * The logged-in user who submitted the feedback, if any.
374     *
375     * @var array|null Array with 'display_name' and 'id' keys, or null if not logged in.
376     */
377    protected $logged_in_user = null;
378
379    /**
380     * Create a response object from a feedback post ID.
381     *
382     * @param int $feedback_post_id The ID of the feedback post.
383     * @return static|null
384     */
385    public static function get( $feedback_post_id ) {
386        $feedback_post = get_post( $feedback_post_id );
387        if ( ! $feedback_post || self::POST_TYPE !== $feedback_post->post_type ) {
388            return null;
389        }
390
391        if ( isset( self::$feedback_fields[ $feedback_post->ID ] ) ) {
392            return self::$feedback_fields[ $feedback_post->ID ];
393        }
394
395        $instance = new self();
396        $instance->load_from_post( $feedback_post );
397        self::$feedback_fields[ $feedback_post->ID ] = $instance;
398        return $instance;
399    }
400
401    /**
402     * Clear the internal cache of feedback objects.
403     *
404     * Useful for testing or when feedback data needs to be reloaded fresh.
405     *
406     * @since 6.10.0
407     */
408    public static function clear_cache() {
409        self::$feedback_fields = array();
410    }
411
412    /**
413     * Create a Feedback object from a feedback post.
414     *
415     * @param WP_Post $feedback_post The feedback post object.
416     */
417    private function load_from_post( WP_Post $feedback_post ) {
418
419        $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type );
420
421        $this->post_id            = $feedback_post->ID;
422        $this->status             = $feedback_post->post_status;
423        $this->legacy_feedback_id = $feedback_post->post_name;
424        $this->feedback_time      = $feedback_post->post_date;
425        $this->is_unread          = $feedback_post->comment_status === self::STATUS_UNREAD;
426
427        $this->fields = $parsed_content['fields'] ?? array();
428
429        // Check if post_parent is a jetpack_form post
430        $potential_form_id = $feedback_post->post_parent;
431        if ( $potential_form_id > 0 ) {
432            $parent_post = get_post( $potential_form_id );
433            if ( $parent_post && $parent_post->post_type === 'jetpack_form' ) {
434                // New data: post_parent is form ID
435                $this->form_id = $potential_form_id;
436            }
437        }
438
439        // Determine the source ID for this feedback.
440        // Prefer the explicit source_id from parsed content when available,
441        // otherwise fall back to the legacy behavior where post_parent was
442        // used as the source post ID, but only when no explicit form_id exists.
443        $source_id = 0;
444        if ( isset( $parsed_content['source_id'] ) && null !== $parsed_content['source_id'] ) {
445            $source_id = (int) $parsed_content['source_id'];
446        } elseif ( $feedback_post->post_parent && ! $this->form_id ) {
447            $source_id = (int) $feedback_post->post_parent;
448        }
449
450        $this->source = new Feedback_Source(
451            $source_id,
452            $parsed_content['entry_title'] ?? '',
453            $parsed_content['entry_page'] ?? 1,
454            $parsed_content['source_type'] ?? 'single',
455            $parsed_content['request_url'] ?? '',
456            ! empty( $parsed_content['is_test'] )
457        );
458
459        $this->ip_address         = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
460        $this->country_code       = $parsed_content['country_code'] ?? null;
461        $this->user_agent         = $parsed_content['user_agent'] ?? null;
462        $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null;
463        $this->subject            = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
464
465        $this->notification_recipients = $parsed_content['notification_recipients'] ?? array();
466        $this->logged_in_user          = $parsed_content['logged_in_user'] ?? null;
467
468        $this->author_data = new Feedback_Author(
469            $this->get_first_field_of_type( 'name', 'pre_comment_author_name' ),
470            $this->get_first_field_of_type( 'email', 'pre_comment_author_email' ),
471            $this->get_first_field_of_type( 'url', 'pre_comment_author_url' ),
472            $this->get_field_value_by_form_field_id( 'first-name' ),
473            $this->get_field_value_by_form_field_id( 'last-name' )
474        );
475
476        $this->comment_content = $this->get_first_field_of_type( 'textarea' );
477        $this->has_consent     = (bool) $this->get_first_field_of_type( 'consent' );
478
479        $this->legacy_feedback_title = $feedback_post->post_title ? $feedback_post->post_title : $this->get_author() . ' - ' . $feedback_post->post_date;
480    }
481
482    /**
483     * Create a response object from a form submission.
484     *
485     * @param array        $post_data Typically $_POST.
486     * @param Contact_Form $form      The form object.
487     * @param WP_Post|null $current_post The current post object, if available.
488     * @param int          $current_page_number The current page number associated with the current post object entry.
489     *
490     * @return static
491     */
492    public static function from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
493        $instance = new self();
494        $instance->load_from_submission( $post_data, $form, $current_post, $current_page_number );
495        return $instance;
496    }
497
498    /**
499     * Set the source of the feedback entry.
500     *
501     * @param Feedback_Source $source The source object.
502     */
503    public function set_source( $source ) {
504        $this->source = $source;
505    }
506
507    /**
508     * Load from Form Submission.
509     *
510     * @param array        $post_data The $_POST received during the form submission.
511     * @param Contact_Form $form  The form object.
512     * @param WP_Post|null $current_post The current post object, if available.
513     * @param int          $current_page_number The current page number associated with the current post object entry.
514     */
515    private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
516
517        // Drop the answers to fields conditional logic hid, once, before anything reads them.
518        //
519        // get_computed_fields() already skips hidden fields for the stored response, but the
520        // comment content, the consent flag, the author details and the notification
521        // recipients all read $post_data directly and were still seeing them. Stripping here
522        // is what makes "a hidden field was never answered" true for every consumer instead
523        // of just the one.
524        $post_data = self::without_hidden_answers( $post_data, $form );
525
526        $this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
527
528        // Use the form's ref attribute as the authoritative form ID.
529        // The ref is set server-side (from the JWT or shortcode attributes) and cannot be tampered with.
530        $form_id_attribute = $form->get_attribute( 'ref' );
531        $form_id_attribute = is_numeric( $form_id_attribute ) ? absint( $form_id_attribute ) : 0;
532        $this->form_id     = $form_id_attribute > 0 ? $form_id_attribute : null;
533
534        // If post_data is provided, use it to populate fields.
535        $this->fields             = $this->get_computed_fields( $post_data, $form );
536        $this->ip_address         = Contact_Form_Plugin::get_ip_address();
537        $this->country_code       = $this->get_country_code_from_ip( $this->ip_address );
538        $this->user_agent         = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
539        $this->form_fill_duration = $this->get_computed_form_fill_duration( $post_data );
540        $this->subject            = $this->get_computed_subject( $post_data, $form );
541        $this->author_data        = Feedback_Author::from_submission( $post_data, $form );
542        $this->comment_content    = $this->get_computed_comment_content( $post_data, $form );
543        $this->has_consent        = $this->get_computed_consent( $post_data, $form );
544
545        $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );
546
547        $this->feedback_time         = current_time( 'mysql' );
548        $this->legacy_feedback_title = "{$this->get_author()} - {$this->feedback_time}";
549        $this->legacy_feedback_id    = md5( $this->legacy_feedback_title );
550
551        // Capture logged-in user info at submission time.
552        if ( is_user_logged_in() ) {
553            $current_user         = wp_get_current_user();
554            $this->logged_in_user = array(
555                'display_name' => $current_user->display_name,
556                'username'     => $current_user->user_login,
557                'id'           => $current_user->ID,
558            );
559        }
560    }
561
562    /**
563     * Remove submitted values belonging to fields conditional logic resolved as hidden.
564     *
565     * The form owns the resolution and caches it, so this asks rather than resolving again --
566     * a second resolution over a different value source is exactly what let validation and
567     * storage disagree about a prefilled consent field.
568     *
569     * @param array        $post_data The post data from the form submission.
570     * @param Contact_Form $form      The form object.
571     * @return array The post data, less any hidden field's answer.
572     */
573    private static function without_hidden_answers( $post_data, $form ) {
574        if ( ! is_array( $post_data ) ) {
575            return $post_data;
576        }
577
578        // Empty when the feature is off, so this is a no-op then.
579        foreach ( $form->get_resolved_field_visibility() as $field_id => $is_visible ) {
580            if ( false === $is_visible ) {
581                unset( $post_data[ $field_id ] );
582            }
583        }
584
585        return $post_data;
586    }
587
588    /**
589     * Get a sanitized value from the post data.
590     *
591     * @param string      $key The key to look for in the post data.
592     * @param array       $post_data The post data array, typically $_POST.
593     * @param string|null $type The type of the field, if applicable (e.g., 'file').
594     *
595     * @return string|array The sanitized value, or an empty string if the key is not found.
596     */
597    private function get_field_value( $key, $post_data, $type = null ) {
598        if ( $type === 'file' ) {
599            if ( isset( $post_data[ $key ] ) ) {
600                return self::process_file_field_value( $post_data[ $key ] );
601            }
602            return array( 'files' => array() );
603        }
604
605        if ( $type === 'image-select' ) {
606            if ( isset( $post_data[ $key ] ) ) {
607                return self::process_image_select_field_value( $post_data[ $key ] );
608            }
609
610            return array(
611                'type'    => 'image-select',
612                'choices' => array(),
613            );
614        }
615
616        if ( isset( $post_data[ $key ] ) ) {
617            if ( is_array( $post_data[ $key ] ) ) {
618                return array_map( 'sanitize_textarea_field', wp_unslash( $post_data[ $key ] ) );
619            } else {
620                return sanitize_textarea_field( wp_unslash( $post_data[ $key ] ) );
621            }
622        }
623        return '';
624    }
625
626    /**
627     * Process the file field value.
628     *
629     * @param array $raw_data The raw post data from the file field.
630     *
631     * @return array The processed file data.
632     */
633    public static function process_file_field_value( $raw_data ) {
634        $file_data_array = is_array( $raw_data )
635            ? array_map(
636                function ( $json_str ) {
637                    $decoded = json_decode( stripslashes( $json_str ), true );
638                    return array(
639                        'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
640                        'name'    => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
641                        'size'    => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
642                        'type'    => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '',
643                    );
644                },
645                $raw_data
646            ) : array();
647
648        if ( empty( $file_data_array ) ) {
649            return array(
650                'files' => array(),
651            );
652        }
653
654        return array(
655            'files' => $file_data_array,
656        );
657    }
658
659    /**
660     * Process the image select field value.
661     *
662     * @param array $raw_data The raw post data from the image select field.
663     *
664     * @return array The processed image select data.
665     */
666    public static function process_image_select_field_value( $raw_data ) {
667        $value = array(
668            'type'    => 'image-select',
669            'choices' => array(),
670        );
671
672        $selection_data_array = is_array( $raw_data )
673            ? array_map(
674                function ( $json_str ) {
675                    return json_decode( stripslashes( $json_str ), true );
676                },
677                $raw_data
678            ) : array( json_decode( stripslashes( $raw_data ), true ) );
679
680        if ( ! empty( $selection_data_array ) ) {
681            $value['choices'] = $selection_data_array;
682        }
683
684        return $value;
685    }
686
687    /**
688     * Process a radio field value to detect and extract "Other" option metadata.
689     *
690     * This method checks if a radio field value matches the "Other" pattern and combines
691     * it with the corresponding text input value if present.
692     *
693     * @param string $value     The raw field value from the submission.
694     * @param object $field     The field object from the form.
695     * @param string $field_id  The field ID.
696     * @param array  $post_data The POST data from the submission.
697     *
698     * @return array An array with 'value' and 'meta' keys.
699     */
700    private function process_radio_field_value( $value, $field, $field_id, $post_data ) {
701        $meta        = array();
702        $allow_other = $field->get_attribute( 'allowother' );
703
704        if ( ! $allow_other || ! is_string( $value ) ) {
705            return array(
706                'value' => $value,
707                'meta'  => $meta,
708            );
709        }
710
711        $options_data = $field->get_attribute( 'optionsdata' );
712        $other_label  = null;
713
714        if ( ! empty( $options_data ) && is_array( $options_data ) ) {
715            foreach ( $options_data as $option ) {
716                if ( ! empty( $option['isOther'] ) ) {
717                    $other_label = Contact_Form_Plugin::strip_tags( $option['label'] );
718                    break;
719                }
720            }
721        }
722
723        if ( empty( $other_label ) ) {
724            return array(
725                'value' => $value,
726                'meta'  => $meta,
727            );
728        }
729
730        if ( $value === $other_label ) {
731            $other_text_key = $field_id . '-other-text';
732            $custom_text    = '';
733
734            if ( isset( $post_data[ $other_text_key ] ) ) {
735                $custom_text = sanitize_textarea_field( wp_unslash( $post_data[ $other_text_key ] ) );
736            }
737
738            $meta['is_other_option']  = true;
739            $meta['other_label']      = $other_label;
740            $meta['other_user_value'] = $custom_text;
741
742            if ( ! empty( $custom_text ) ) {
743                $value = $other_label . ': ' . $custom_text;
744            }
745        }
746
747        return array(
748            'value' => $value,
749            'meta'  => $meta,
750        );
751    }
752
753    /**
754     * Get the computed fields from the post data.
755     *
756     * @param string $label The label of the field to look for.
757     * @param string $context The context in which the value is being rendered (default is 'default').
758     *
759     * @return string The Value of the field.
760     */
761    public function get_field_value_by_label( $label, $context = 'default' ) {
762        // This method is used to get the value of a field by its label.
763        foreach ( $this->fields as $field ) {
764            if ( $field->get_label( $context ) === $label ) {
765                return $field->get_render_value( $context );
766            }
767        }
768        return '';
769    }
770
771    /**
772     * Get the value of the field based on the first type found.
773     *
774     * @param string      $type The type of the field to look for.
775     * @param string|null $filter Optional filter to apply to the value.
776     * @param string      $context The context in which the value is being rendered (default is 'default').
777     *
778     * @return string The value of the first field of the specified type, or an empty string if not found.
779     */
780    private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) {
781        // This method is used to get the first field of a specific type.
782        foreach ( $this->fields as $field ) {
783            if ( $field->get_type() === $type ) {
784                if ( $filter ) {
785                    return Contact_Form_Plugin::strip_tags(
786                        stripslashes(
787                            /** This filter is already documented in core/wp-includes/comment-functions.php */
788                            \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) )
789                        )
790                    );
791                }
792                return $field->get_render_value( $context );
793            }
794        }
795        return '';
796    }
797
798    /**
799     * Get all the fields of the response.
800     */
801    public function get_fields() {
802        return $this->fields;
803    }
804
805    /**
806     * Check whether this feedback contains at least one field of a given type.
807     *
808     * @param string $type Field type to check for (e.g. 'consent', 'email', 'textarea').
809     * @return bool True if a field of the given type exists; false otherwise.
810     */
811    public function has_field_type( $type ) {
812        foreach ( $this->fields as $field ) {
813            if ( $field->get_type() === $type ) {
814                return true;
815            }
816        }
817        return false;
818    }
819
820    /**
821     * Whether this response uses structured feedback fields.
822     *
823     * @return bool
824     */
825    public function uses_structured_fields() {
826        return $this->uses_structured_fields;
827    }
828
829    /**
830     * Get the values related to where the form was submitted from.
831     *
832     * @return array An array of entry values.
833     */
834    public function get_entry_values() {
835        // This is a convenience method to get the entry values in a simple array format.
836        $entry_values = array(
837            'email_marketing_consent' => (string) $this->has_consent ? 'yes' : 'no',
838            'entry_title'             => $this->source->get_title(),
839            'entry_permalink'         => $this->source->get_permalink(),
840            'feedback_id'             => $this->legacy_feedback_id,
841        );
842
843        if ( $this->source->get_page_number() > 1 ) {
844            $entry_values['entry_page'] = $this->source->get_page_number();
845        }
846        return $entry_values;
847    }
848
849    /**
850     * Get all values of the response.
851     *
852     * @param string $context The context in which the values are being retrieved.
853     *
854     * @return array An array of all values, including fields and entry values.
855     */
856    public function get_all_values( $context = 'default' ) {
857        // This is a legacy method to maintain compatibility with older code.
858        return array_merge( $this->get_compiled_fields( $context, 'key-value' ), $this->get_entry_values() );
859    }
860
861    /**
862     * Get the jetpack_form post ID associated with this feedback.
863     *
864     * @return int|null The form ID, or null if not submitted via reusable form.
865     */
866    public function get_form_id() {
867        return $this->form_id;
868    }
869
870    /**
871     * Get extra values.
872     * This is a legacy method to maintain compatibility with older code.
873     *
874     * @param string $context The context in which the values are being retrieved.
875     *
876     * @return array An array of extra values, including entry values
877     */
878    public function get_legacy_extra_values( $context = 'default' ) {
879        $count            = 1;
880        $_extra_fields    = array();
881        $special_fields   = array();
882        $non_extra_fields = array( 'email', 'name', 'url', 'subject', 'textarea', 'ip' );
883
884        // Create a map of special fields to check against their values.
885        foreach ( $this->fields as $field ) {
886            if ( in_array( $field->get_type(), $non_extra_fields, true ) ) {
887                $value = $field->get_render_value( $context );
888                if ( is_array( $value ) ) {
889                    $value = reset( $value );
890                }
891                if ( $value ) {
892                    $special_fields[ $value ] = true;
893                }
894            }
895        }
896
897        foreach ( $this->fields as $field ) {
898            if ( $field->compile_field( 'default' ) ) {
899                continue;
900            }
901            $render_value = $field->get_render_value();
902            if ( is_array( $render_value ) ) {
903                $render_value = reset( $render_value );
904            }
905            if ( $field->get_type() === 'basic' && $render_value && isset( $special_fields[ $render_value ] ) ) {
906                ++$count;
907                continue; // Skip fields that are already present in the non-extra fields.
908            }
909            $_extra_fields[] = $field;
910            ++$count; // Increment count to ensure unique keys for extra values.
911        }
912        $extra_values       = array();
913        $extra_fields_count = $count;
914        $is_present         = array(); // Used to store the value only once.
915
916        foreach ( $_extra_fields as $field ) {
917            if ( ! in_array( $field->get_type(), $non_extra_fields, true ) || isset( $is_present[ $field->get_type() ] ) ) {
918                $extra_values[ $extra_fields_count . '_' . $field->get_label() ] = $field->get_render_value( $context );
919                ++$extra_fields_count; // Increment count to ensure unique keys for extra values.
920            } else {
921                $is_present[ $field->get_type() ] = true;
922            }
923        }
924        return $extra_values;
925    }
926
927    /**
928     * Get all values of the response.
929     *
930     * @return array An array of all values, including fields and entry values.
931     */
932    public function get_all_legacy_values() {
933        return array(
934            '_feedback_author'       => $this->get_author(),
935            '_feedback_author_email' => $this->get_author_email(),
936            '_feedback_author_url'   => $this->get_author_url(),
937            '_feedback_subject'      => $this->get_subject(),
938            '_feedback_ip'           => $this->get_ip_address(),
939            '_feedback_all_fields'   => $this->get_all_values(),
940        );
941    }
942    /**
943     * Return the compiled fields for the given context.
944     *
945     * @param string $context The context in which the fields are compiled.
946     * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'.
947     *
948     * @return array An array of compiled fields with labels and values.
949     */
950    public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) {
951        $compiled_fields = array();
952
953        $count_field_labels = array();
954        foreach ( $this->fields as $field ) {
955            if ( $field->compile_field( $context ) ) {
956                continue; // Skip fields that are not meant to be rendered.
957            }
958
959            // Don't show the hidden fields in the user context.
960            if ( in_array( $context, array( 'web', 'ajax' ), true ) ) {
961                if ( $field->is_of_type( 'hidden' ) ) {
962                    continue;
963                }
964            }
965
966            $label = $field->get_label( $context );
967
968            if ( ! isset( $count_field_labels[ $label ] ) ) {
969                $count_field_labels[ $label ] = 1;
970            } else {
971                ++$count_field_labels[ $label ];
972            }
973
974            // Compile the field based on the requested shape.
975            switch ( $array_shape ) {
976                case 'default':
977                case 'all':
978                    $compiled_fields[ $field->get_key() ] = array(
979                        'label' => $label,
980                        'value' => $field->get_render_value( $context ),
981                    );
982                    break;
983                case 'label|value':
984                    $compiled_fields[] = array(
985                        'label' => $label,
986                        'value' => $field->get_render_value( $context ),
987                    );
988                    break;
989                case 'value':
990                    $compiled_fields[] = $field->get_render_value( $context );
991                    break;
992                case 'label':
993                    $compiled_fields[] = $label;
994                    break;
995                case 'key-value':
996                    $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context );
997                    break;
998                case 'label-value':
999                    $compiled_fields[ $field->get_label( $context, $count_field_labels[ $label ] ) ] = $field->get_render_value( $context );
1000                    break;
1001                case 'id-value':
1002                    $compiled_fields[ $field->get_form_field_id() ] = $field->get_render_value( $context );
1003                    break;
1004                case 'collection':
1005                    $compiled_fields[] = array(
1006                        'label' => $label,
1007                        'value' => $field->get_render_value( $context ),
1008                        'type'  => $field->get_type(),
1009                        'id'    => $field->get_form_field_id(),
1010                        'key'   => $field->get_key(),
1011                        'meta'  => $field->get_meta(),
1012                    );
1013                    break;
1014            }
1015        }
1016
1017        return $compiled_fields;
1018    }
1019
1020    /**
1021     * Get the feedback ID of the response.
1022     * Which is the same as the post name for feedback entries.
1023     * Please note that this is not the same as the feedback post ID.
1024     *
1025     * @return string
1026     */
1027    public function get_feedback_id() {
1028        return $this->legacy_feedback_id;
1029    }
1030
1031    /**
1032     * Get the feedback title of the response.
1033     *
1034     * This is mostly used for legacy reasons.
1035     *
1036     * @return string
1037     */
1038    public function get_title() {
1039        return $this->legacy_feedback_title;
1040    }
1041
1042    /**
1043     * Get the time of the feedback entry.
1044     *
1045     * @return string
1046     */
1047    public function get_time() {
1048        return $this->feedback_time;
1049    }
1050
1051    /**
1052     * Get the askimet vars that are used to check for spam.
1053     *
1054     * These are the variables that are sent to Akismet to check if the feedback is spam or not.
1055     *
1056     * @return array
1057     */
1058    public function get_akismet_vars() {
1059        $akismet_vars = array(
1060            'comment_author'       => $this->author_data->get_name(),
1061            'comment_author_email' => $this->author_data->get_email(),
1062            'comment_author_url'   => $this->author_data->get_url(),
1063            'contact_form_subject' => $this->get_subject(),
1064            'comment_author_ip'    => $this->get_ip_address(),
1065            'comment_content'      => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(),
1066            'permalink'            => $this->get_entry_permalink(),
1067        );
1068
1069        foreach ( $this->fields as $field ) {
1070
1071            // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
1072            // from a spam-filtering point of view.
1073            if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file', 'image-select' ), true ) ) {
1074                continue;
1075            }
1076
1077            // Normalize the label into a slug.
1078            $field_slug = trim( // Strip all leading/trailing dashes.
1079                preg_replace(   // Normalize everything to a-z0-9_-
1080                    '/[^a-z0-9_]+/',
1081                    '-',
1082                    strtolower( $field->get_label() ) // Lowercase
1083                ),
1084                '-'
1085            );
1086
1087            $field_value = $field->get_render_value( 'akismet' );
1088
1089            // Skip any values that are already in the array we're sending.
1090            if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) {
1091                continue;
1092            }
1093
1094            $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
1095        }
1096
1097        return $akismet_vars;
1098    }
1099
1100    /**
1101     * Get the author name of the feedback entry.
1102     * If the author is not provided we will use the email instead.
1103     *
1104     * @return string
1105     */
1106    public function get_author() {
1107        return $this->author_data->get_display_name();
1108    }
1109
1110    /**
1111     * Get the author name of a feedback entry.
1112     *
1113     * @return string
1114     */
1115    public function get_author_name() {
1116        return $this->author_data->get_name();
1117    }
1118
1119    /**
1120     * Get the author's first name of a feedback entry.
1121     *
1122     * @return string
1123     */
1124    public function get_author_first_name() {
1125        return $this->author_data->get_first_name();
1126    }
1127
1128    /**
1129     * Get the author's last name of a feedback entry.
1130     *
1131     * @return string
1132     */
1133    public function get_author_last_name() {
1134        return $this->author_data->get_last_name();
1135    }
1136
1137    /**
1138     * Get the author email of a feedback entry.
1139     *
1140     * @return string
1141     */
1142    public function get_author_email() {
1143        return $this->author_data->get_email();
1144    }
1145
1146    /**
1147     * Get the author's gravatar URL.
1148     *
1149     * This is a convenience method to get the author's gravatar URL.
1150     *
1151     * @return string
1152     */
1153    public function get_author_avatar() {
1154        return $this->author_data->get_avatar_url();
1155    }
1156
1157    /**
1158     * Get the author url of a feedback entry.
1159     *
1160     * @return string
1161     */
1162    public function get_author_url() {
1163        return $this->author_data->get_url();
1164    }
1165
1166    /**
1167     * Get the comment content of a feedback entry.
1168     *
1169     * @return string
1170     */
1171    public function get_comment_content() {
1172        return $this->comment_content;
1173    }
1174
1175    /**
1176     * Get the IP address of the submitted feedback request.
1177     *
1178     * @return string|null
1179     */
1180    public function get_ip_address() {
1181        return $this->ip_address;
1182    }
1183
1184    /**
1185     * Get the user agent of the submitted feedback request.
1186     *
1187     * @return string|null
1188     */
1189    public function get_user_agent() {
1190        return $this->user_agent;
1191    }
1192
1193    /**
1194     * Get the country code derived from the IP address.
1195     *
1196     * @return string|null
1197     */
1198    public function get_country_code() {
1199        return $this->country_code;
1200    }
1201
1202    /**
1203     * Get the form fill duration in seconds.
1204     *
1205     * Represents the time from first user interaction to form submission.
1206     *
1207     * @return int|null
1208     */
1209    public function get_form_fill_duration() {
1210        return $this->form_fill_duration;
1211    }
1212
1213    /**
1214     * Get the emoji flag for the country.
1215     *
1216     * @return string The emoji flag for the country code, or empty string if unavailable.
1217     */
1218    public function get_country_flag() {
1219        return self::country_code_to_emoji_flag( $this->country_code );
1220    }
1221
1222    /**
1223     * Get country code from IP address.
1224     *
1225     * This method uses a filter to allow custom implementations of GeoIP lookup.
1226     * The filter should return a country code (e.g., 'US', 'GB', 'DE') or null.
1227     *
1228     * @param string|null $ip_address The IP address.
1229     * @return string|null The country code or null if unavailable.
1230     */
1231    private function get_country_code_from_ip( $ip_address ) {
1232        if ( ! $ip_address ) {
1233            return null;
1234        }
1235        // This filter allows site owners to disable IP address storage entirely as well as GeoIP lookups.
1236        // This filter is documented in src/contact-form/class-contact-form-plugin.php
1237        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false ) ) {
1238            return null;
1239        }
1240
1241        /**
1242         * Filter to get country code from IP address.
1243         *
1244         * @since $$NEXT_VERSION$$
1245         *
1246         * @param string|null $country The country code (e.g., 'US', 'GB', 'DE') or null.
1247         * @param string      $ip_address The IP address to look up.
1248         * @param string      $context The context for the geolocation request.
1249         */
1250        $country = apply_filters( 'jetpack_get_country_from_ip', null, $ip_address, 'form-response' );
1251        if ( is_string( $country ) ) {
1252            return strtoupper( $country );
1253        }
1254
1255        $headers = array(
1256            'MM_COUNTRY_CODE',
1257            'GEOIP_COUNTRY_CODE',
1258            'HTTP_CF_IPCOUNTRY',
1259            'HTTP_X_COUNTRY_CODE',
1260            'HTTP_X_APPENGINE_COUNTRY',
1261            'HTTP_X_FORWARDED_FOR_COUNTRY',
1262            'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
1263        );
1264
1265        // Check for headers from the server.
1266        foreach ( $headers as $header ) {
1267            if ( isset( $_SERVER[ $header ] ) ) {
1268                $country = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
1269                if ( ! empty( $country ) ) {
1270                    return strtoupper( $country );
1271                }
1272            }
1273        }
1274
1275        if ( function_exists( 'geoip_country_code_by_name' ) ) {
1276            $country = geoip_country_code_by_name( $ip_address );
1277            if ( ! empty( $country ) ) {
1278                return strtoupper( $country );
1279            }
1280        }
1281
1282        $country = self::geolocate_via_api( $ip_address );
1283        if ( ! empty( $country ) ) {
1284            return strtoupper( $country );
1285        }
1286
1287        return null;
1288    }
1289
1290    /**
1291     * Use APIs to Geolocate the IP address.
1292     *
1293     * @param  string $ip_address IP address.
1294     * @return string
1295     */
1296    private static function geolocate_via_api( $ip_address ) {
1297        $country_code = \get_transient( 'geoip_' . $ip_address );
1298        if ( false === $country_code ) {
1299            $response = Client::wpcom_json_api_request_as_blog(
1300                '/ip-to-geo/' . $ip_address,
1301                '2',
1302                array( 'method' => 'GET' ),
1303                null,
1304                'wpcom'
1305            );
1306
1307            if ( ! is_wp_error( $response ) && ! empty( $response['body'] ) ) {
1308                $data         = json_decode( $response['body'] );
1309                $country_code = $data->country_short ?? '';
1310                $country_code = \sanitize_text_field( $country_code );
1311                // Share the transient with woocommerce to avoid multiple lookups.
1312                \set_transient( 'geoip_' . $ip_address, $country_code, DAY_IN_SECONDS );
1313            }
1314        }
1315        return $country_code;
1316    }
1317
1318    /**
1319     * Get the browser information from the user agent.
1320     *
1321     * Returns a formatted string like "Chrome (Desktop)" or "Safari (Mobile)".
1322     *
1323     * @return string|null Browser information or null if user agent is not available.
1324     */
1325    public function get_browser() {
1326        if ( empty( $this->user_agent ) ) {
1327            return null;
1328        }
1329
1330        // Use Jetpack Device Detection to parse the user agent.
1331        $ua_info = new User_Agent_Info( $this->user_agent );
1332
1333        // Get browser name.
1334        $browser_name = $ua_info->get_browser_display_name();
1335
1336        if ( $browser_name === User_Agent_Info::OTHER ) {
1337            return __( 'Unknown browser', 'jetpack-forms' );
1338        }
1339
1340        // Determine platform type (Mobile, Tablet, or Desktop).
1341        $platform_type = 'Desktop';
1342        if ( $ua_info->is_tablet() ) {
1343            $platform_type = 'Tablet';
1344        } elseif ( $ua_info->get_platform() ) {
1345            // If there's a mobile platform detected (not false), it's mobile.
1346            $platform_type = 'Mobile';
1347        }
1348
1349        return sprintf( '%s (%s)', $browser_name, $platform_type );
1350    }
1351
1352    /**
1353     * Get the logged-in user information who submitted the feedback.
1354     *
1355     * @return array|null Array with 'display_name' and 'id' keys, or null if not logged in.
1356     */
1357    public function get_logged_in_user() {
1358        return $this->logged_in_user;
1359    }
1360
1361    /**
1362     * Get the email subject.
1363     *
1364     * @return string
1365     */
1366    public function get_subject() {
1367        return $this->subject;
1368    }
1369
1370    /**
1371     * Gets the notification recipients of the feedback entry.
1372     *
1373     * @return array
1374     */
1375    public function get_notification_recipients() {
1376        return $this->notification_recipients;
1377    }
1378
1379    /**
1380     * Gets the value of the consent field.
1381     *
1382     * @return bool
1383     */
1384    public function has_consent() {
1385        return $this->has_consent;
1386    }
1387
1388    /**
1389     * Gets the value of the consent field.
1390     *
1391     * @return bool
1392     */
1393    public function has_file() {
1394        return $this->has_file;
1395    }
1396
1397    /**
1398     * Check if the feedback is unread.
1399     *
1400     * @return bool
1401     */
1402    public function is_unread() {
1403        return $this->is_unread;
1404    }
1405
1406    /**
1407     * Mark the feedback as read.
1408     *
1409     * @return bool True on success, false on failure.
1410     */
1411    public function mark_as_read() {
1412        if ( ! $this->post_id ) {
1413            return false;
1414        }
1415
1416        $updated = wp_update_post(
1417            array(
1418                'ID'             => $this->post_id,
1419                'comment_status' => self::STATUS_READ,
1420            )
1421        );
1422
1423        if ( ! is_wp_error( $updated ) && $updated ) {
1424            $this->is_unread = false;
1425            return true;
1426        }
1427
1428        return false;
1429    }
1430
1431    /**
1432     * Mark the feedback as unread.
1433     *
1434     * @return bool True on success, false on failure.
1435     */
1436    public function mark_as_unread() {
1437        if ( ! $this->post_id ) {
1438            return false;
1439        }
1440
1441        $updated = wp_update_post(
1442            array(
1443                'ID'             => $this->post_id,
1444                'comment_status' => self::STATUS_UNREAD,
1445            )
1446        );
1447
1448        if ( ! is_wp_error( $updated ) && $updated ) {
1449            $this->is_unread = true;
1450            return true;
1451        }
1452
1453        return false;
1454    }
1455
1456    /**
1457     * Get the count of unread feedback entries.
1458     *
1459     * @return int
1460     */
1461    public static function get_unread_count() {
1462        $query = new \WP_Query(
1463            array(
1464                'post_type'      => self::POST_TYPE,
1465                'post_status'    => 'publish',
1466                'comment_status' => self::STATUS_UNREAD,
1467                'posts_per_page' => -1,
1468                'fields'         => 'ids',
1469            )
1470        );
1471        return (int) $query->found_posts;
1472    }
1473
1474    /**
1475     * Get the uploaded files from the feedback entry.
1476     *
1477     * @return array
1478     */
1479    public function get_files() {
1480        $files = array();
1481        foreach ( $this->fields as $field ) {
1482            if ( $field->get_type() === 'file' ) {
1483                $field_value = $field->get_value();
1484                if ( ! empty( $field_value['files'] ) && is_array( $field_value['files'] ) ) {
1485                    $field_value['files'] = array_filter(
1486                        $field_value['files'],
1487                        function ( $file ) {
1488                            if ( empty( $file['file_id'] ) ) {
1489                                return false;
1490                            }
1491                            if ( empty( $file['name'] ) ) {
1492                                return false;
1493                            }
1494                            if ( empty( $file['size'] ) ) {
1495                                return false;
1496                            }
1497                            if ( empty( $file['type'] ) ) {
1498                                return false;
1499                            }
1500                            return true;
1501                        }
1502                    );
1503
1504                    $files = array_merge( $files, $field_value['files'] );
1505                }
1506            }
1507        }
1508        return $files;
1509    }
1510
1511    /**
1512     * Get the feedback status. For example 'publish', 'spam' or 'trash'.
1513     *
1514     * @return string
1515     */
1516    public function get_status() {
1517        return $this->status;
1518    }
1519
1520    /**
1521     * Sets the status of the feedback.
1522     *
1523     * @param string $status The status to set for the feedback entry.
1524     * @return void
1525     */
1526    public function set_status( $status ) {
1527        $this->status = $status;
1528    }
1529
1530    /**
1531     * Get the entry ID of the post that the feedback was submitted from.
1532     *
1533     * This is the post ID of the post or page that the feedback was submitted from.
1534     *
1535     * @return int|string
1536     */
1537    public function get_entry_id() {
1538        return $this->source->get_id();
1539    }
1540
1541    /**
1542     * Get the entry title of the post that the feedback was submitted from.
1543     *
1544     * This is the title of the post or page that the feedback was submitted from.
1545     *
1546     * @return string
1547     */
1548    public function get_entry_title() {
1549        return $this->source->get_title();
1550    }
1551
1552    /**
1553     * Get the permalink of the post or page that the feedback was submitted from.
1554     * This includes the page number if the feedback was submitted from a paginated form.
1555     *
1556     * @return string
1557     */
1558    public function get_entry_permalink() {
1559        return $this->source->get_permalink();
1560    }
1561
1562    /**
1563     * Get the editor URL where the user can edit the form.
1564     *
1565     * @return string
1566     */
1567    public function get_edit_form_url() {
1568        if ( ! empty( $this->form_id ) ) {
1569            return \get_edit_post_link( (int) $this->form_id, 'url' );
1570        }
1571        return $this->source->get_edit_form_url();
1572    }
1573    /**
1574     * Get the short permalink of a post.
1575     *
1576     * @return string
1577     */
1578    public function get_entry_short_permalink() {
1579        return $this->source->get_relative_permalink();
1580    }
1581
1582    /**
1583     * Whether this feedback was submitted from a form preview (test submission).
1584     *
1585     * @return bool
1586     */
1587    public function is_test() {
1588        return $this->source->is_test();
1589    }
1590
1591    /**
1592     * Flag this feedback as a test submission from form preview.
1593     *
1594     * @return void
1595     */
1596    public function mark_as_test() {
1597        $this->source->set_is_test( true );
1598    }
1599
1600    /**
1601     * Save the feedback entry to the database.
1602     *
1603     * @return int
1604     */
1605    public function save() {
1606        $post_id = wp_insert_post(
1607            array(
1608                'post_type'      => self::POST_TYPE,
1609                'post_status'    => $this->status,
1610                'post_title'     => $this->legacy_feedback_title,
1611                'post_date'      => $this->feedback_time,
1612                'post_name'      => $this->legacy_feedback_id,
1613                'post_content'   => $this->serialize(), // In V3 we started to addslashes.
1614                'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
1615                'post_parent'    => $this->form_id ?? $this->source->get_id(),
1616                'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
1617            )
1618        );
1619
1620        // Store source post ID as meta for queryable source filtering.
1621        $source_id = $this->source->get_id();
1622        if ( is_numeric( $post_id ) && (int) $post_id > 0 && is_numeric( $source_id ) && (int) $source_id > 0 ) {
1623            add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
1624            wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
1625        }
1626
1627        // Flag test submissions with a post meta so the REST collection can
1628        // filter them via meta_query without unpacking the serialized source.
1629        if ( is_numeric( $post_id ) && (int) $post_id > 0 && $this->source->is_test() ) {
1630            add_post_meta( $post_id, self::IS_TEST_META_KEY, 1, true );
1631        }
1632
1633        // If this feedback does not have a jetpack_form parent,
1634        // it's a classic form â€” mark the state accordingly.
1635        if ( empty( $this->form_id ) ) {
1636            Forms_Dashboard::mark_classic_form_detected();
1637        }
1638
1639        $feedback_post = get_post( $post_id );
1640        return $feedback_post ?? 0;
1641    }
1642
1643    /**
1644     * Serialize the fields to JSON format.
1645     *
1646     * @return string
1647     */
1648    public function serialize() {
1649
1650        $fields_to_serialize = array_merge(
1651            array(
1652                'subject'                 => $this->subject,
1653                'ip'                      => $this->ip_address,
1654                'country_code'            => $this->country_code,
1655                'user_agent'              => $this->user_agent,
1656                'form_fill_duration'      => $this->form_fill_duration,
1657                'notification_recipients' => $this->notification_recipients,
1658                'logged_in_user'          => $this->logged_in_user,
1659            ),
1660            $this->source->serialize()
1661        );
1662
1663        $fields_to_serialize['fields'] = array();
1664        foreach ( $this->fields as $field ) {
1665            $fields_to_serialize['fields'][] = $field->serialize();
1666        }
1667
1668        // Check if the IP and country_code should be included.
1669        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) {
1670            $fields_to_serialize['ip']           = null;
1671            $fields_to_serialize['country_code'] = null;
1672        }
1673
1674        /*
1675         * JSON_HEX_TAG escapes every `<` and `>` as a \u003C / \u003E sequence,
1676         * which is what keeps this payload intact on the way into the database.
1677         * It is load-bearing here, not cosmetic - do not drop it.
1678         *
1679         * This payload is written to `post_content`, and any submitter without
1680         * `unfiltered_html` - every logged-out visitor, and every non-super-admin
1681         * on a multisite - has `wp_filter_post_kses` attached to `content_save_pre`.
1682         * A bare `<` anywhere in the payload (a field label, a submitted value, or
1683         * the source page title) then reads as the start of a tag, and core's
1684         * `wp_pre_kses_less_than()` runs esc_html() over everything from that `<` to
1685         * the end of the string. The quotes inside it become `&quot;`, so
1686         * `json_decode()` can no longer read the payload and the entire response
1687         * comes back empty.
1688         *
1689         * `json_decode()` resolves the escaped sequences natively, so no decode step
1690         * is needed and payloads written before this flag was added still parse.
1691         *
1692         * This deliberately diverges from Jetpack.Functions.JsonEncodeFlags, which
1693         * recommends JSON_UNESCAPED_SLASHES alone for database-field writes. That
1694         * guidance assumes the write is not KSES-filtered; this one is.
1695         */
1696        return addslashes( wp_json_encode( $fields_to_serialize, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) );
1697    }
1698
1699    /**
1700     * Helper function to parse the post content.
1701     *
1702     * @param string      $post_content The post content to parse.
1703     * @param string|null $version The version of the content format.
1704     * @return array Parsed fields.
1705     */
1706    private function parse_content( $post_content = '', $version = null ) {
1707        if ( $version === 'v3' ) {
1708            $this->uses_structured_fields = true;
1709            return $this->parse_content_v3( $post_content );
1710        }
1711        if ( $version === 'v2' ) {
1712            $this->uses_structured_fields = true;
1713            return $this->parse_content_v2( $post_content );
1714        }
1715
1716        // Some feedback posts store JSON content without a version marker
1717        // (empty post_mime_type). Parse those as the current (v3) format instead
1718        // of falling through to the legacy plain-text parser.
1719        if ( self::is_json( $post_content ) ) {
1720            $decoded_content = json_decode( $post_content, true );
1721            if ( $decoded_content === null ) {
1722                // Content may be slash-escaped as stored by WordPress; retry,
1723                // mirroring the fallback used by the v2/v3 parsers.
1724                $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1725            }
1726            if ( isset( $decoded_content['fields'] ) && is_array( $decoded_content['fields'] ) ) {
1727                $this->uses_structured_fields = true;
1728                return $this->parse_content_v3( $post_content );
1729            }
1730        }
1731
1732        return $this->parse_legacy_content( $post_content );
1733    }
1734
1735    /**
1736     * Check whether a string looks like and decodes as valid JSON.
1737     *
1738     * Accepts slash-escaped JSON (as WordPress may store it), mirroring the
1739     * stripslashes fallback used by the v2/v3 parsers.
1740     *
1741     * @param string $string The string to test.
1742     * @return bool True if the string is a JSON object or array.
1743     */
1744    private static function is_json( $string ) {
1745        if ( ! is_string( $string ) || $string === '' ) {
1746            return false;
1747        }
1748        $string = trim( $string );
1749        if ( ! str_starts_with( $string, '{' ) && ! str_starts_with( $string, '[' ) ) {
1750            return false;
1751        }
1752        $decoded = json_decode( $string );
1753        if ( $decoded !== null && json_last_error() === JSON_ERROR_NONE ) {
1754            return true;
1755        }
1756        $decoded = json_decode( stripslashes( $string ) );
1757        return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
1758    }
1759
1760    /**
1761     * Parse the content in the v2 format.
1762     *
1763     * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1764     *
1765     * @param string $post_content The post content to parse.
1766     *
1767     * @return array Parsed fields.
1768     */
1769    private function parse_content_v2( $post_content = '' ) {
1770        $decoded_content = json_decode( $post_content, true );
1771        if ( $decoded_content === null ) {
1772            // If JSON decoding still fails, try with stripslashes and trim as a fallback
1773            // This is a workaround for some cases where the JSON data is not properly formatted
1774            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1775        }
1776
1777        if ( $decoded_content === null ) {
1778            // Final fallback: attempt to fix malformed JSON with unescaped quotes
1779            // Apply stripslashes first, then fix remaining issues
1780            $stripped_content = stripslashes( trim( $post_content ) );
1781            $fixed_content    = self::fix_malformed_json( $stripped_content );
1782            $decoded_content  = json_decode( $fixed_content, true );
1783        }
1784
1785        if ( $decoded_content === null ) {
1786            return array();
1787        }
1788        $fields = array();
1789        foreach ( $decoded_content['fields'] as $field ) {
1790            $feedback_field = Feedback_Field::from_serialized_v2( $field );
1791            if ( $feedback_field instanceof Feedback_Field ) {
1792                $fields[ $feedback_field->get_key() ] = $feedback_field;
1793                if ( ! $this->has_file && $feedback_field->has_file() ) {
1794                    $this->has_file = true;
1795                }
1796            }
1797        }
1798        $decoded_content['fields'] = $fields;
1799        return $decoded_content;
1800    }
1801
1802    /**
1803     * Parse the content in the v3 format.
1804     *
1805     * @param string $post_content The post content to parse.
1806     *
1807     * @return array Parsed fields.
1808     */
1809    private function parse_content_v3( $post_content = '' ) {
1810        $decoded_content = json_decode( $post_content, true );
1811        if ( $decoded_content === null ) {
1812            // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1813            // This is a workaround for some cases where the JSON data is not properly formatted.
1814            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1815        }
1816        if ( $decoded_content === null ) {
1817            return array();
1818        }
1819        $fields = array();
1820        foreach ( $decoded_content['fields'] as $field ) {
1821            $feedback_field = Feedback_Field::from_serialized( $field );
1822            if ( $feedback_field instanceof Feedback_Field ) {
1823                $fields[ $feedback_field->get_key() ] = $feedback_field;
1824                if ( ! $this->has_file && $feedback_field->has_file() ) {
1825                    $this->has_file = true;
1826                }
1827            }
1828        }
1829        $decoded_content['fields'] = $fields;
1830        return $decoded_content;
1831    }
1832
1833    /**
1834     * Parse the legacy content format.
1835     *
1836     * @param string $post_content The post content to parse.
1837     *
1838     * @return array Parsed fields.
1839     */
1840    private function parse_legacy_content( $post_content = '' ) {
1841        $content_parts   = $this->split_legacy_content( $post_content );
1842        $comment_content = $content_parts['comment_content'];
1843        $field_content   = $content_parts['field_content'];
1844
1845        $all_values = $this->extract_legacy_values( $field_content );
1846        $lines      = $this->extract_legacy_lines( $field_content );
1847
1848        $decoded_fields           = array();
1849        $decoded_fields['fields'] = array();
1850
1851        // Process lines for specific field types
1852        $this->process_legacy_lines( $lines, $decoded_fields );
1853
1854        // Process all other values
1855        $this->process_legacy_values( $all_values, $decoded_fields );
1856
1857        // Add comment content field
1858        $this->add_comment_content_field( $comment_content, $decoded_fields );
1859
1860        return $decoded_fields;
1861    }
1862
1863    /**
1864     * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1865     *
1866     * This method handles cases where JSON contains unescaped quotes within string values,
1867     * which causes json_decode to fail.
1868     *
1869     * @param string $json malformed JSON string.
1870     * @return string The JSON string with escaped quotes.
1871     */
1872    public static function fix_malformed_json( $json ) {
1873
1874        $find    = array();
1875        $replace = array();
1876
1877        // Start of JSON object
1878        $find[]    = '{\"';
1879        $replace[] = '{"';
1880
1881        // Key-value separator
1882        $find[]    = '\":\"';
1883        $replace[] = '":"';
1884
1885        $find[]    = '\\\"';
1886        $replace[] = '\"';
1887
1888        $find[]    = '\":[\"';
1889        $replace[] = '":["';
1890
1891        $find[]    = '\"],';
1892        $replace[] = '"],';
1893
1894        $find[]    = ',[\"';
1895        $replace[] = ',["';
1896
1897        $find[]    = '\",\"';
1898        $replace[] = '","';
1899
1900        $find[]    = ',\"';
1901        $replace[] = ',"';
1902
1903        $find[]    = '\", \"';
1904        $replace[] = '", "';
1905
1906        $find[]    = '\"],\"';
1907        $replace[] = '"],"';
1908
1909        $find[]    = '\"],"';
1910        $replace[] = '"],"';
1911
1912        $find[]    = '\":[]';
1913        $replace[] = '":[]';
1914
1915        $find[]    = '\"]}';
1916        $replace[] = '"]}';
1917
1918        $find[]    = '\":[';
1919        $replace[] = '":[';
1920
1921        $find[]    = '\":{';
1922        $replace[] = '":{';
1923
1924        $find[]    = '\":true';
1925        $replace[] = '":true';
1926
1927        $find[]    = '\":false';
1928        $replace[] = '":false';
1929
1930        $find[]    = '\":null';
1931        $replace[] = '":null';
1932
1933        for ( $i = 0; $i <= 9; $i++ ) {
1934            $find[]    = '\":' . $i;
1935            $replace[] = '":' . $i;
1936
1937            $find[]    = '\",' . $i;
1938            $replace[] = '",' . $i;
1939        }
1940
1941        $find[]    = '\",true';
1942        $replace[] = '",true';
1943
1944        $find[]    = '\",false';
1945        $replace[] = '",false';
1946
1947        $find[]    = '\",null';
1948        $replace[] = '",null';
1949
1950        $find[]    = "\'";
1951        $replace[] = "'";
1952
1953        // End of Json object
1954        $find[]    = '\"}';
1955        $replace[] = '"}';
1956
1957        // Remove any slashes that are there to start a new string.
1958        return str_replace( $find, $replace, addslashes( $json ) );
1959    }
1960
1961    /**
1962     * Split legacy content into comment and field sections.
1963     *
1964     * @param string $post_content The post content to parse.
1965     * @return array Array with 'comment_content' and 'field_content' keys.
1966     */
1967    private function split_legacy_content( $post_content ) {
1968        $content         = explode( '<!--more-->', $post_content );
1969        $comment_content = '';
1970        $field_content   = '';
1971
1972        if ( count( $content ) > 1 ) {
1973            $comment_content = $content[0];
1974            $field_content   = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1975        }
1976
1977        return array(
1978            'comment_content' => $comment_content,
1979            'field_content'   => $field_content,
1980        );
1981    }
1982
1983    /**
1984     * Extract values from legacy field content.
1985     *
1986     * @param string $field_content The field content to parse.
1987     * @return array Extracted values.
1988     */
1989    private function extract_legacy_values( $field_content ) {
1990        $all_values = array();
1991
1992        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1993            $all_values = $this->parse_json_data( $field_content );
1994        } else {
1995            $all_values = $this->parse_array_format( $field_content );
1996        }
1997
1998        // Ensure all_values is always an array
1999        if ( ! is_array( $all_values ) ) {
2000            $all_values = array();
2001        }
2002
2003        return $all_values;
2004    }
2005
2006    /**
2007     * Extract lines from legacy field content.
2008     *
2009     * @param string $field_content The field content to parse.
2010     * @return array Filtered lines.
2011     */
2012    private function extract_legacy_lines( $field_content ) {
2013        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
2014            $chunks = explode( "\nJSON_DATA", $field_content );
2015            return array_filter( explode( "\n", $chunks[0] ) );
2016        } else {
2017            return array_filter( explode( "\n", $field_content ) );
2018        }
2019    }
2020
2021    /**
2022     * Parse JSON data from field content.
2023     *
2024     * @param string $field_content The field content containing JSON data.
2025     * @return array Parsed JSON data.
2026     */
2027    private function parse_json_data( $field_content ) {
2028        $chunks = explode( "\nJSON_DATA", $field_content );
2029
2030        if ( ! isset( $chunks[1] ) ) {
2031            // Try with 'JSON_DATA' without the newline as a fallback.
2032            $chunks = explode( 'JSON_DATA', $field_content );
2033            if ( ! isset( $chunks[1] ) ) {
2034                // If JSON_DATA is still not found, return an empty array.
2035                return array();
2036            }
2037        }
2038
2039        $json_data = $chunks[1];
2040
2041        $all_values = json_decode( $json_data, true );
2042
2043        if ( $all_values === null ) {
2044            // Fallback for improperly formatted JSON
2045            $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
2046        }
2047
2048        return $all_values === null ? array() : $all_values;
2049    }
2050
2051    /**
2052     * Parse array format from field content.
2053     *
2054     * @param string $field_content The field content in array format.
2055     * @return array Parsed array data.
2056     */
2057    private function parse_array_format( $field_content ) {
2058        $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
2059
2060        // Parse key-value pairs formatted as [Key] => Value
2061        preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
2062
2063        if ( count( $matches ) > 1 ) {
2064            return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
2065        }
2066
2067        return array();
2068    }
2069
2070    /**
2071     * Process legacy lines into field objects.
2072     *
2073     * We do this so that we can extract specific fields but we don't display the values in the UI.
2074     *
2075     * @param array $lines The lines to process.
2076     * @param array &$decoded_fields Reference to the decoded fields array.
2077     */
2078    private function process_legacy_lines( $lines, &$decoded_fields ) {
2079        $var_map = array(
2080            'AUTHOR'       => array(
2081                'type'  => 'name',
2082                'label' => 'Author',
2083            ),
2084            'AUTHOR EMAIL' => array(
2085                'type'  => 'email',
2086                'label' => 'Email',
2087            ),
2088            'AUTHOR URL'   => array(
2089                'type'  => 'url',
2090                'label' => 'Url',
2091            ),
2092            'SUBJECT'      => array(
2093                'type'  => 'subject',
2094                'label' => 'Subject',
2095            ),
2096            'IP'           => array(
2097                'type'  => 'ip',
2098                'label' => 'IP',
2099            ),
2100        );
2101
2102        foreach ( $lines as $line ) {
2103            $line_parts = explode( ': ', $line, 2 );
2104
2105            if ( count( $line_parts ) !== 2 ) {
2106                continue;
2107            }
2108
2109            list( $key, $value ) = $line_parts;
2110
2111            if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
2112                $map_to_field = $var_map[ $key ];
2113                $value        = Contact_Form_Plugin::strip_tags( trim( $value ) );
2114
2115                $decoded_fields['fields'][ $key ] = new Feedback_Field(
2116                    $key,
2117                    $map_to_field['label'],
2118                    $value,
2119                    $map_to_field['type'],
2120                    array( 'render' => false )
2121                );
2122            }
2123        }
2124    }
2125
2126    /**
2127     * Check if the field is a legacy file upload.
2128     *
2129     * @param array $field The field to check.
2130     *
2131     * @return bool True if it's a legacy file upload, false otherwise.
2132     */
2133    private function is_legacy_file_upload( $field ) {
2134        return (
2135            is_array( $field ) &&
2136            ! empty( $field['field_id'] ) &&
2137            isset( $field['files'] ) &&
2138            is_array( $field['files'] )
2139        );
2140    }
2141
2142    /**
2143     * Process legacy values into field objects.
2144     *
2145     * @param array $all_values The values to process.
2146     * @param array &$decoded_fields Reference to the decoded fields array.
2147     */
2148    private function process_legacy_values( $all_values, &$decoded_fields ) {
2149        $non_user_fields = array(
2150            'email_marketing_consent',
2151            'entry_title',
2152            'entry_permalink',
2153            'entry_page',
2154            'feedback_id',
2155        );
2156
2157        foreach ( $all_values as $key => $value ) {
2158            $key   = wp_strip_all_tags( $key );
2159            $label = self::extract_label_from_key( $key );
2160
2161            if ( in_array( $key, $non_user_fields, true ) ) {
2162                if ( $key === 'email_marketing_consent' ) {
2163                    $decoded_fields['fields'][ $key ] = new Feedback_Field(
2164                        $key,
2165                        $label,
2166                        $value,
2167                        'consent',
2168                        array( 'render' => false )
2169                    );
2170                    continue;
2171                }
2172                $decoded_fields[ $key ] = $value;
2173                continue;
2174            }
2175
2176            // check for file upload data and then set it as a file type field.
2177            if ( $this->is_legacy_file_upload( $value ) ) {
2178                // If the value is a file upload, we need to handle it differently.
2179                $decoded_fields['fields'][ $key ] = new Feedback_Field(
2180                    $key,
2181                    $label,
2182                    $value,
2183                    'file'
2184                );
2185                $this->has_file                   = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
2186            } else {
2187                $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
2188            }
2189        }
2190    }
2191
2192    /**
2193     * Add comment content as a field.
2194     *
2195     * @param string $comment_content The comment content.
2196     * @param array  &$decoded_fields Reference to the decoded fields array.
2197     */
2198    private function add_comment_content_field( $comment_content, &$decoded_fields ) {
2199        $decoded_fields['fields']['comment_content'] = new Feedback_Field(
2200            'comment_content',
2201            'Comment Content',
2202            trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
2203            'textarea',
2204            array( 'render' => false )
2205        );
2206    }
2207
2208    /**
2209     * Extract the label from a key that might be in the format "1_label".
2210     *
2211     * @param string $key The key to extract the label from.
2212     * @return string The extracted label.
2213     */
2214    private static function extract_label_from_key( $key ) {
2215        // Check if the key starts with a number followed by underscore and has content after underscore
2216        if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
2217            return $matches[1];
2218        }
2219        // If the key is just a number followed by underscore (like "2_"), return empty string
2220        if ( preg_match( '/^\d+_$/', $key ) ) {
2221            return '';
2222        }
2223        // If the key doesn't start with a number followed by underscore, return the key as is
2224        return $key;
2225    }
2226
2227    /**
2228     * Get field-specific metadata based on the field type.
2229     *
2230     * @param Contact_Form_Field $field The field object.
2231     * @param string             $type  The field type.
2232     * @return array Metadata array for the field.
2233     */
2234    public static function get_field_meta( $field, $type ) {
2235        $meta = array();
2236
2237        if ( $type === 'rating' ) {
2238            $icon_style        = $field->get_attribute( 'iconstyle' );
2239            $max               = $field->get_attribute( 'max' );
2240            $meta['iconStyle'] = ! empty( $icon_style ) ? $icon_style : 'stars';
2241            $meta['maxRating'] = is_numeric( $max ) && (int) $max > 0 ? (int) $max : 5;
2242        }
2243
2244        return $meta;
2245    }
2246
2247    /**
2248     * Get all the fields of the response, computed from the post data.
2249     *
2250     * @param array        $post_data The post data from the form submission.
2251     * @param Contact_Form $form The form object.
2252     * @return array An array of Feedback_Field objects.
2253     */
2254    private function get_computed_fields( $post_data, $form ) {
2255
2256        $fields = array();
2257
2258        $field_ids = $form->get_field_ids();
2259
2260        // Collect renderable fields and their submitted values up front so conditional logic
2261        // rules (which may reference any sibling field) can be evaluated in the loop below.
2262        $renderable  = array();
2263        $form_values = array();
2264        foreach ( $field_ids['all'] as $field_id ) {
2265            $field = $form->fields[ $field_id ];
2266            $type  = $field->get_attribute( 'type' );
2267            if ( ! $field->is_field_renderable( $type ) ) {
2268                continue;
2269            }
2270            $value                    = $this->get_field_value( $field_id, $post_data, $type );
2271            $form_values[ $field_id ] = $value;
2272            $renderable[ $field_id ]  = array(
2273                'field' => $field,
2274                'type'  => $type,
2275                'value' => $value,
2276            );
2277        }
2278
2279        // Ask the form, rather than resolving a second time.
2280        //
2281        // Storage used to run its own resolve_visibility() over a different value source and a
2282        // different field set than validation did, and the two disagreed. Validation reads
2283        // get_computed_field_value() -- POST, then GET, then the field's default, then the
2284        // logged-in user -- while this loop reads POST only; and it skips anything
2285        // is_field_renderable() rejects, so a rule whose subject is an option-less select was
2286        // evaluated during validation and ignored here.
2287        //
2288        // Unchecking a consent field prefilled from a query argument hit both: the browser
2289        // posts nothing, validation fell back to the query argument and read it checked,
2290        // storage read '' and read it unchecked. The dependent field was required-validated
2291        // and then had its answer dropped -- the silently discarded answer this feature is
2292        // supposed to make impossible.
2293        //
2294        // Returns an empty array when the flag is off, so there is nothing extra to guard.
2295        $visibility = $form->get_resolved_field_visibility();
2296
2297        $i = 1;
2298        foreach ( $renderable as $field_id => $entry ) {
2299            $field = $entry['field'];
2300            $type  = $entry['type'];
2301            $value = $entry['value'];
2302
2303            if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) {
2304                continue;
2305            }
2306
2307            $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
2308            $key   = $i . '_' . $label;
2309
2310            $meta = self::get_field_meta( $field, $type );
2311
2312            // Process radio fields to detect and extract "Other" option metadata
2313            if ( $type === 'radio' ) {
2314                $processed = $this->process_radio_field_value( $value, $field, $field_id, $post_data );
2315                $value     = $processed['value'];
2316                $meta      = array_merge( $meta, $processed['meta'] );
2317            }
2318            $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
2319            if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
2320                $this->has_file = true;
2321            }
2322            ++$i;
2323        }
2324
2325        return $fields;
2326    }
2327
2328    /**
2329     * Gets the computed subject.
2330     *
2331     * @param array        $post_data The post data from the form submission.
2332     * @param Contact_Form $form The form object.
2333     * @return string
2334     */
2335    private function get_computed_subject( $post_data, $form ) {
2336
2337        $contact_form_subject = $form->get_attribute( 'subject' );
2338        $field_ids            = $form->get_field_ids();
2339
2340        if ( isset( $field_ids['subject'] ) ) {
2341            $value = $this->get_field_value( $field_ids['subject'], $post_data );
2342            if ( ! empty( $value ) ) {
2343                $contact_form_subject = $value;
2344            }
2345        }
2346
2347        return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
2348    }
2349
2350    /**
2351     * Gets the computed comment content.
2352     *
2353     * @param array        $post_data The post data from the form submission.
2354     * @param Contact_Form $form The form object.
2355     * @return string
2356     */
2357    private function get_computed_comment_content( $post_data, $form ) {
2358        $field_ids = $form->get_field_ids();
2359        if ( isset( $field_ids['textarea'] ) ) {
2360            $value = $this->get_field_value( $field_ids['textarea'], $post_data );
2361            if ( is_string( $value ) ) {
2362                return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
2363            }
2364        }
2365        return '';
2366    }
2367
2368    /**
2369     * Gets the computed consent.
2370     *
2371     * @param array        $post_data The post data from the form submission.
2372     * @param Contact_Form $form The form object.
2373     * @return bool
2374     */
2375    private function get_computed_consent( $post_data, $form ) {
2376        $field_ids = $form->get_field_ids();
2377
2378        if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
2379            return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
2380        }
2381
2382        return false;
2383    }
2384
2385    /**
2386     * Gets the computed form fill duration, in seconds.
2387     *
2388     * The value is supplied by the view script as a hidden field, so it is submitter-controlled
2389     * and cannot be trusted. Anything that is not a plain sequence of digits is treated as
2390     * unknown and stored as null, rather than being coerced into a number that would read as a
2391     * real measurement: `absint()` alone would turn "abc" into 0 (indistinguishable from a
2392     * genuine sub-second fill), "-1" into 1, and a value past PHP_INT_MAX into a float, which
2393     * would contradict the integer type the REST schema advertises.
2394     *
2395     * The value is left empty when the submitter never interacted with the form, or ran without
2396     * JavaScript, which is also an unknown duration.
2397     *
2398     * @since 7.24.0
2399     *
2400     * @param array $post_data The post data from the form submission.
2401     * @return int|null
2402     */
2403    private function get_computed_form_fill_duration( $post_data ) {
2404        if ( ! isset( $post_data[ self::FORM_FILL_DURATION_FIELD ] ) ) {
2405            return null;
2406        }
2407
2408        $raw = $post_data[ self::FORM_FILL_DURATION_FIELD ];
2409
2410        // is_scalar() has to come first so an array-shaped POST does not blow up on the cast.
2411        if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) {
2412            return null;
2413        }
2414
2415        // Clamp so an abandoned tab left open for days cannot skew aggregates.
2416        return min( (int) $raw, DAY_IN_SECONDS );
2417    }
2418
2419    /**
2420     * Gets the computed notification recipients.
2421     *
2422     * @since 6.10.0
2423     *
2424     * @param array        $post_data The post data from the form submission.
2425     * @param Contact_Form $form The form object.
2426     * @return array
2427     */
2428    private function get_computed_notification_recipients( $post_data, $form ) {
2429        $notification_recipients = $form->get_attribute( 'notificationRecipients' );
2430        return $this->validate_notification_recipients( $notification_recipients );
2431    }
2432
2433    /**
2434     * Validates notification recipients have proper capabilities.
2435     *
2436     * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
2437     * Filters out invalid or unauthorized user IDs.
2438     *
2439     * @since 6.10.0
2440     *
2441     * @param array $recipients Array of user IDs.
2442     * @return array Array of validated user IDs.
2443     */
2444    private function validate_notification_recipients( $recipients ) {
2445        if ( ! is_array( $recipients ) ) {
2446            return array();
2447        }
2448
2449        $valid_recipients = array();
2450        foreach ( $recipients as $user_id ) {
2451            $user = get_userdata( $user_id );
2452            // Only allow users with edit_posts or edit_pages capability
2453            if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
2454                $valid_recipients[] = $user_id;
2455            }
2456        }
2457
2458        return $valid_recipients;
2459    }
2460
2461    /**
2462     * Get a field by its original form ID.
2463     *
2464     * @since 5.5.0
2465     *
2466     * @param string $id Original form field ID.
2467     * @return Feedback_Field|null
2468     */
2469    public function get_field_by_form_field_id( $id ) {
2470        if ( ! is_string( $id ) || $id === '' ) {
2471            return null;
2472        }
2473        foreach ( $this->fields as $field ) {
2474            if ( $field->get_form_field_id() === $id ) {
2475                return $field;
2476            }
2477        }
2478        return null;
2479    }
2480
2481    /**
2482     * Get a field render value by its original form ID.
2483     *
2484     * @since 5.5.0
2485     *
2486     * @param string $id Original form field ID.
2487     * @param string $context Render context.
2488     * @return string
2489     */
2490    public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
2491        $field = $this->get_field_by_form_field_id( $id );
2492        if ( ! $field ) {
2493            return '';
2494        }
2495        return (string) $field->get_render_value( $context );
2496    }
2497}