PluginProbe ʕ •ᴥ•ʔ
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator / 2.12.1
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator v2.12.1
2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / entries.php
sureforms / inc Last commit date
abilities 2 weeks ago admin 1 month ago ai-form-builder 2 weeks ago blocks 1 month ago compatibility 3 weeks ago database 3 weeks ago email 3 months ago fields 3 weeks ago global-settings 2 weeks ago lib 2 weeks ago migrator 3 weeks ago page-builders 3 weeks ago payments 2 weeks ago single-form-settings 1 month ago traits 1 month ago activator.php 1 year ago admin-ajax.php 3 weeks ago background-process.php 8 months ago create-new-form.php 2 months ago duplicate-form.php 1 month ago entries.php 3 weeks ago events-scheduler.php 2 years ago export.php 1 month ago field-validation.php 2 weeks ago form-restriction.php 1 month ago form-styling.php 3 months ago form-submit.php 3 weeks ago forms-data.php 3 months ago frontend-assets.php 2 weeks ago generate-form-markup.php 3 weeks ago gutenberg-hooks.php 1 month ago helper.php 3 weeks ago learn.php 3 months ago onboarding.php 1 month ago post-types.php 1 month ago rest-api.php 2 weeks ago smart-tags.php 2 months ago submit-token.php 3 months ago translatable.php 2 weeks ago updater-callbacks.php 1 year ago updater.php 1 year ago
entries.php
854 lines
1 <?php
2 /**
3 * Sureforms entries.
4 *
5 * @package sureforms.
6 * @since 2.0.0
7 */
8
9 namespace SRFM\Inc;
10
11 use SRFM\Inc\Database\Tables\Entries as EntriesTable;
12 use SRFM\Inc\Traits\Get_Instance;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 /**
19 * Entries Class.
20 *
21 * @since 2.0.0
22 */
23 class Entries {
24 use Get_Instance;
25
26 /**
27 * Constructor
28 *
29 * @since 2.0.0
30 */
31 public function __construct() {
32 // Constructor code here.
33 }
34
35 /**
36 * Get entries with filters, sorting, and pagination.
37 *
38 * @param array<string,mixed> $args {
39 * Optional. An array of arguments to customize the query.
40 *
41 * @type int $form_id Form ID to filter entries. Default 0 (all forms).
42 * @type string $status Entry status: 'all', 'read', 'unread', 'trash'. Default 'all'.
43 * @type string $search Search term to filter entries by entry ID. Default empty.
44 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format). Default empty.
45 * @type string $date_to End date for filtering entries (YYYY-MM-DD format). Default empty.
46 * @type string $orderby Column to order by. Default 'created_at'.
47 * @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
48 * @type int $per_page Number of entries per page. Default 20.
49 * @type int $page Current page number. Default 1.
50 * @type array<mixed> $entry_ids Specific entry IDs to fetch. Default empty array.
51 * }
52 *
53 * @since 2.0.0
54 * @return array<string,mixed> {
55 * @type array<mixed> $entries Array of entry objects.
56 * @type int $total Total number of entries matching the query.
57 * @type int $per_page Number of entries per page.
58 * @type int $current_page Current page number.
59 * @type int $total_pages Total number of pages.
60 * @type bool $emptyTrash Whether the trash is empty (true) or contains items (false).
61 * }
62 */
63 public static function get_entries( $args = [] ) {
64 $defaults = [
65 'form_id' => 0,
66 'status' => 'all',
67 'search' => '',
68 'date_from' => '',
69 'date_to' => '',
70 'orderby' => 'created_at',
71 'order' => 'DESC',
72 'per_page' => 20,
73 'page' => 1,
74 'entry_ids' => [],
75 ];
76
77 /**
78 * Parsed and sanitized arguments for the entries query.
79 *
80 * @var array{form_id: int, status: string, search: string, date_from: string, date_to: string, orderby: string, order: string, per_page: int, page: int, entry_ids: array<int>} $args
81 */
82 $args = wp_parse_args( $args, $defaults );
83
84 // Build where conditions.
85 $where_conditions = self::build_where_conditions( $args );
86
87 // Get total count for pagination.
88 $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
89
90 // Calculate offset.
91 $offset = ( absint( $args['page'] ) - 1 ) * absint( $args['per_page'] );
92
93 // Get entries.
94 $entries = EntriesTable::get_all(
95 [
96 'where' => $where_conditions,
97 'columns' => '*',
98 'orderby' => Helper::get_string_value( $args['orderby'] ),
99 'order' => Helper::get_string_value( $args['order'] ),
100 'limit' => absint( $args['per_page'] ),
101 'offset' => $offset,
102 ]
103 );
104
105 // Check if trash is empty.
106 $trash_count = EntriesTable::get_instance()->get_total_count(
107 [
108 [
109 [
110 'key' => 'status',
111 'compare' => '=',
112 'value' => 'trash',
113 ],
114 ],
115 ]
116 );
117
118 return [
119 'entries' => $entries,
120 'total' => $total,
121 'per_page' => absint( $args['per_page'] ),
122 'current_page' => absint( $args['page'] ),
123 'total_pages' => ceil( $total / absint( $args['per_page'] ) ),
124 'emptyTrash' => 0 === $trash_count,
125 ];
126 }
127
128 /**
129 * Update entry status (trash/untrash/read/unread).
130 *
131 * @param int|array<int> $entry_ids Entry ID or array of entry IDs.
132 * @param string $status New status: 'trash', 'unread', 'read', or 'restore'.
133 *
134 * @since 2.0.0
135 * @return array<string,mixed> {
136 * @type bool $success Whether the operation was successful.
137 * @type int $updated Number of entries updated.
138 * @type array<string> $errors Array of error messages.
139 * }
140 */
141 public static function update_status( $entry_ids, $status ) {
142 $entry_ids = is_array( $entry_ids ) ? array_map( 'absint', $entry_ids ) : [ absint( $entry_ids ) ];
143 $entry_ids = array_filter( $entry_ids ); // Remove any zero values.
144
145 if ( empty( $entry_ids ) ) {
146 return [
147 'success' => false,
148 'updated' => 0,
149 'errors' => [ __( 'No valid entry IDs provided.', 'sureforms' ) ],
150 ];
151 }
152
153 // Validate status.
154 $valid_statuses = [ 'trash', 'unread', 'read', 'restore' ];
155 if ( ! in_array( $status, $valid_statuses, true ) ) {
156 return [
157 'success' => false,
158 'updated' => 0,
159 'errors' => [ __( 'Invalid status provided.', 'sureforms' ) ],
160 ];
161 }
162
163 // Map 'restore' to 'unread'.
164 $actual_status = 'restore' === $status ? 'unread' : $status;
165
166 $updated = 0;
167 $errors = [];
168
169 foreach ( $entry_ids as $entry_id ) {
170 $result = EntriesTable::update( $entry_id, [ 'status' => $actual_status ] );
171
172 if ( false === $result ) {
173 $errors[] = sprintf(
174 // translators: %d is the entry ID.
175 __( 'Failed to update entry #%d.', 'sureforms' ),
176 $entry_id
177 );
178 } else {
179 ++$updated;
180 }
181 }
182
183 return [
184 'success' => $updated > 0,
185 'updated' => $updated,
186 'errors' => $errors,
187 ];
188 }
189
190 /**
191 * Permanently delete entries.
192 *
193 * @param int|array<int> $entry_ids Entry ID or array of entry IDs.
194 *
195 * @since 2.0.0
196 * @return array<string,mixed> {
197 * @type bool $success Whether the operation was successful.
198 * @type int $deleted Number of entries deleted.
199 * @type array<string> $errors Array of error messages.
200 * }
201 */
202 public static function delete_entries( $entry_ids ) {
203 $entry_ids = is_array( $entry_ids ) ? array_map( 'absint', $entry_ids ) : [ absint( $entry_ids ) ];
204 $entry_ids = array_filter( $entry_ids );
205
206 if ( empty( $entry_ids ) ) {
207 return [
208 'success' => false,
209 'deleted' => 0,
210 'errors' => [ __( 'No valid entry IDs provided.', 'sureforms' ) ],
211 ];
212 }
213
214 $deleted = 0;
215 $errors = [];
216
217 foreach ( $entry_ids as $entry_id ) {
218 $result = EntriesTable::delete( $entry_id );
219
220 if ( false === $result ) {
221 $errors[] = sprintf(
222 // translators: %d is the entry ID.
223 __( 'Failed to delete entry #%d.', 'sureforms' ),
224 $entry_id
225 );
226 } else {
227 ++$deleted;
228 }
229 }
230
231 return [
232 'success' => $deleted > 0,
233 'deleted' => $deleted,
234 'errors' => $errors,
235 ];
236 }
237
238 /**
239 * Export entries to CSV format.
240 *
241 * @param array<string,mixed> $args {
242 * Optional. An array of arguments for export.
243 *
244 * @type int|array<int> $entry_ids Entry ID or array of entry IDs to export.
245 * @type int $form_id Form ID to export all entries from.
246 * @type string $status Entry status filter.
247 * @type string $search Search term filter.
248 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format).
249 * @type string $date_to End date for filtering entries (YYYY-MM-DD format).
250 * }
251 *
252 * @since 2.0.0
253 * @return array<string,mixed> {
254 * @type bool $success Whether the export was successful.
255 * @type string $filename Export filename (if single form).
256 * @type string $filepath Full path to the exported file.
257 * @type string $type Export type: 'csv' or 'zip'.
258 * @type string $error Error message if failed.
259 * }
260 */
261 public static function export_entries( $args = [] ) {
262 $defaults = [
263 'entry_ids' => [],
264 'form_id' => 0,
265 'status' => 'all',
266 'search' => '',
267 'date_from' => '',
268 'date_to' => '',
269 ];
270
271 /**
272 * Parsed and sanitized arguments for the export operation.
273 *
274 * @var array{entry_ids: int|array<int>, form_id: int, status: string, search: string, date_from: string, date_to: string} $args
275 */
276 $args = wp_parse_args( $args, $defaults );
277
278 // Get entry IDs to export.
279 if ( empty( $args['entry_ids'] ) ) {
280 // If no specific entry IDs provided, get all matching entries.
281 $where_conditions = self::build_where_conditions( $args );
282 $all_entries = EntriesTable::get_all(
283 [
284 'where' => $where_conditions,
285 'columns' => 'ID',
286 ],
287 false
288 );
289 $entry_ids = array_map( 'absint', array_column( $all_entries, 'ID' ) );
290 } else {
291 /**
292 * Entry IDs converted to array of integers.
293 *
294 * @var array<int> $entry_ids
295 */
296 $entry_ids = is_array( $args['entry_ids'] ) ? array_map( 'absint', $args['entry_ids'] ) : [ absint( (int) $args['entry_ids'] ) ];
297 }
298
299 if ( empty( $entry_ids ) ) {
300 return [
301 'success' => false,
302 'error' => __( 'No entries found to export.', 'sureforms' ),
303 ];
304 }
305
306 // Get form IDs from entry IDs.
307 $form_ids = EntriesTable::get_form_ids_by_entries( $entry_ids );
308 $is_single_form = count( $form_ids ) === 1;
309
310 $temp_dir = wp_normalize_path( trailingslashit( get_temp_dir() ) );
311
312 // Check if temp directory is writable.
313 if ( ! wp_is_writable( $temp_dir ) ) {
314 return [
315 'success' => false,
316 'error' => __( 'Temporary directory is not writable.', 'sureforms' ),
317 ];
318 }
319
320 $csv_files = [];
321 $zip = null;
322 $temp_zip = '';
323
324 // Create ZIP if multiple forms.
325 if ( ! $is_single_form ) {
326 if ( ! class_exists( 'ZipArchive' ) ) {
327 return [
328 'success' => false,
329 'error' => __( 'ZipArchive class is not available.', 'sureforms' ),
330 ];
331 }
332
333 $temp_zip = $temp_dir . 'srfm-entries-export-' . time() . '.zip';
334 $zip = new \ZipArchive();
335
336 if ( ! $zip->open( $temp_zip, \ZipArchive::CREATE ) ) {
337 return [
338 'success' => false,
339 'error' => __( 'Unable to create ZIP file.', 'sureforms' ),
340 ];
341 }
342 }
343
344 $csv_filepath = '';
345
346 // Process each form.
347 foreach ( $form_ids as $form_id ) {
348 $results = self::get_entries_data_for_export( $entry_ids, $form_id );
349
350 if ( empty( $results ) ) {
351 continue;
352 }
353
354 $sanitized_form_title = sanitize_title( get_the_title( $form_id ) );
355 $sanitized_form_title = ! empty( $sanitized_form_title ) ? $sanitized_form_title : "srfm-entries-{$form_id}";
356
357 $csv_filename = 'srfm-entries-' . $sanitized_form_title . '.csv';
358 $csv_filepath = $temp_dir . $csv_filename;
359
360 if ( file_exists( $csv_filepath ) ) {
361 wp_delete_file( $csv_filepath );
362 }
363
364 $stream = fopen( $csv_filepath, 'wb' ); // phpcs:ignore -- Using fopen to decrease the memory use.
365
366 if ( ! is_resource( $stream ) ) {
367 continue;
368 }
369
370 $csv_files[] = $csv_filepath;
371
372 // Build CSV content.
373 $block_data = self::build_block_key_map_and_labels( $results );
374 self::write_csv_header( $stream, $block_data['labels'] );
375 self::write_csv_rows( $stream, $results, $block_data['map'] );
376
377 fclose( $stream ); // phpcs:ignore -- Using fopen to decrease the memory use.
378
379 // Add to ZIP if multiple forms.
380 if ( ! $is_single_form && $zip && filesize( $csv_filepath ) > 0 ) {
381 $zip->addFile( $csv_filepath, $csv_filename );
382 }
383 }
384
385 // Single form - return CSV.
386 if ( $is_single_form && ! empty( $csv_filepath ) && file_exists( $csv_filepath ) ) {
387 return [
388 'success' => true,
389 'filename' => basename( $csv_filepath ),
390 'filepath' => $csv_filepath,
391 'type' => 'csv',
392 ];
393 }
394
395 // Multiple forms - return ZIP.
396 if ( ! $is_single_form && $zip ) {
397 $zip->close();
398
399 // Clean up CSV files.
400 foreach ( $csv_files as $csv_file ) {
401 if ( file_exists( $csv_file ) ) {
402 wp_delete_file( $csv_file );
403 }
404 }
405
406 return [
407 'success' => true,
408 'filename' => 'SureForms-Entries.zip',
409 'filepath' => $temp_zip,
410 'type' => 'zip',
411 ];
412 }
413
414 return [
415 'success' => false,
416 'error' => __( 'Unable to generate export file.', 'sureforms' ),
417 ];
418 }
419
420 /**
421 * Get adjacent entry IDs (previous and next) for navigation.
422 * Navigation follows chronological order: Previous = older, Next = newer.
423 *
424 * @param int $current_entry_id Current entry ID.
425 * @param array<string,mixed> $args {
426 * Optional. An array of arguments to filter the navigation context.
427 *
428 * @type int $form_id Form ID to filter entries. Default 0 (all forms).
429 * @type string $status Entry status: 'all', 'read', 'unread', 'trash'. Default 'all'.
430 * @type string $search Search term to filter entries by entry ID. Default empty.
431 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format). Default empty.
432 * @type string $date_to End date for filtering entries (YYYY-MM-DD format). Default empty.
433 * @type string $orderby Column to order by. Default 'created_at'.
434 * @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
435 * }
436 *
437 * @since 2.4.0
438 * @return array<string,int|null> {
439 * @type int|null $previous_id Previous entry ID (older entry) or null if at the oldest.
440 * @type int|null $next_id Next entry ID (newer entry) or null if at the newest.
441 * }
442 */
443 public static function get_adjacent_entry_ids( $current_entry_id, $args = [] ) {
444 $defaults = [
445 'form_id' => 0,
446 'status' => 'all',
447 'search' => '',
448 'date_from' => '',
449 'date_to' => '',
450 'orderby' => 'created_at',
451 'order' => 'DESC',
452 ];
453
454 $args = wp_parse_args( $args, $defaults );
455
456 // Build where conditions.
457 $where_conditions = self::build_where_conditions( $args );
458
459 // Get all entry IDs in chronological order (oldest to newest).
460 // This ensures Previous = older, Next = newer regardless of listing page sort.
461 $all_entries = EntriesTable::get_all(
462 [
463 'where' => $where_conditions,
464 'columns' => 'ID',
465 'orderby' => 'created_at',
466 'order' => 'ASC',
467 ],
468 false
469 );
470
471 // Extract entry IDs into a simple array.
472 $entry_ids = array_map(
473 static function ( $entry ) {
474 return is_array( $entry ) ? Helper::get_integer_value( $entry['ID'] ) : 0;
475 },
476 $all_entries
477 );
478
479 // Find the position of the current entry.
480 $current_position = array_search( absint( $current_entry_id ), $entry_ids, true );
481
482 if ( false === $current_position ) {
483 // Current entry not found in the filtered list.
484 return [
485 'previous_id' => null,
486 'next_id' => null,
487 ];
488 }
489
490 // Convert to integer after validation.
491 $current_position = Helper::get_integer_value( $current_position );
492
493 // Get previous and next entry IDs.
494 $previous_id = $current_position > 0 ? $entry_ids[ $current_position - 1 ] : null;
495 $next_id = $current_position < count( $entry_ids ) - 1 ? $entry_ids[ $current_position + 1 ] : null;
496
497 return [
498 'previous_id' => $previous_id,
499 'next_id' => $next_id,
500 ];
501 }
502
503 /**
504 * Build where conditions for entry queries.
505 *
506 * @param array<string, int|string|array<int>> $args Query arguments.
507 *
508 * @since 2.0.0
509 * @return array<mixed> Where conditions array.
510 */
511 private static function build_where_conditions( $args ) {
512 $where_conditions = [];
513
514 // Filter by entry IDs.
515 if ( ! empty( $args['entry_ids'] ) && is_array( $args['entry_ids'] ) ) {
516 $where_conditions[] = [
517 [
518 'key' => 'ID',
519 'compare' => 'IN',
520 'value' => array_map( 'absint', $args['entry_ids'] ),
521 ],
522 ];
523 return $where_conditions;
524 }
525
526 // Filter by status.
527 if ( 'all' !== $args['status'] ) {
528 $where_conditions[] = [
529 [
530 'key' => 'status',
531 'compare' => '=',
532 'value' => Helper::get_string_value( $args['status'] ),
533 ],
534 ];
535 } else {
536 // Exclude trash when status is 'all'.
537 $where_conditions[] = [
538 [
539 'key' => 'status',
540 'compare' => '!=',
541 'value' => 'trash',
542 ],
543 ];
544 }
545
546 // Filter by form ID.
547 $form_id = Helper::get_integer_value( $args['form_id'] ?? 0 );
548 if ( ! empty( $args['form_id'] ) && $form_id > 0 ) {
549 $where_conditions[] = [
550 [
551 'key' => 'form_id',
552 'compare' => '=',
553 'value' => $form_id,
554 ],
555 ];
556 }
557
558 // Filter by date range.
559 if ( ! empty( $args['date_from'] ) || ! empty( $args['date_to'] ) ) {
560 $date_conditions = [];
561
562 if ( ! empty( $args['date_from'] ) ) {
563 $date_conditions[] = [
564 'key' => 'created_at',
565 'compare' => '>=',
566 'value' => Helper::get_string_value( $args['date_from'] ),
567 ];
568 }
569
570 if ( ! empty( $args['date_to'] ) ) {
571 $date_conditions[] = [
572 'key' => 'created_at',
573 'compare' => '<=',
574 'value' => Helper::get_string_value( $args['date_to'] ),
575 ];
576 }
577
578 if ( ! empty( $date_conditions ) ) {
579 $where_conditions[] = $date_conditions;
580 }
581 }
582
583 // Filter by search (entry ID + form title).
584 if ( ! empty( $args['search'] ) && is_string( $args['search'] ) ) {
585 $search_term = sanitize_text_field( $args['search'] );
586 $search_group = [ 'RELATION' => 'OR' ];
587
588 // If numeric, match entry ID.
589 if ( is_numeric( $search_term ) ) {
590 $search_group[] = [
591 'key' => 'ID',
592 'compare' => '=',
593 'value' => absint( $search_term ),
594 ];
595 }
596
597 // Match form titles.
598 $matching_form_ids = self::get_form_ids_by_title( $search_term );
599 if ( ! empty( $matching_form_ids ) ) {
600 $search_group[] = [
601 'key' => 'form_id',
602 'compare' => 'IN',
603 'value' => $matching_form_ids,
604 ];
605 }
606
607 // Only add if we have search conditions, otherwise force empty result.
608 if ( count( $search_group ) > 1 ) {
609 $where_conditions[] = $search_group;
610 } else {
611 $where_conditions[] = [
612 [
613 'key' => 'ID',
614 'compare' => '=',
615 'value' => 0,
616 ],
617 ];
618 }
619 }
620
621 return $where_conditions;
622 }
623
624 /**
625 * Get form IDs whose title matches the search term.
626 *
627 * @param string $search_term Search term to match against form titles.
628 *
629 * @since 2.6.0
630 * @return array<int> Array of matching form IDs.
631 */
632 private static function get_form_ids_by_title( $search_term ) {
633 $forms = get_posts(
634 [
635 'post_type' => SRFM_FORMS_POST_TYPE,
636 'post_status' => 'any',
637 's' => $search_term,
638 'search_columns' => [ 'post_title' ],
639 'posts_per_page' => 100,
640 'fields' => 'ids',
641 'no_found_rows' => true,
642 ]
643 );
644
645 return ! empty( $forms ) ? array_map( 'absint', $forms ) : [];
646 }
647
648 /**
649 * Get entries data for export based on entry IDs and form ID.
650 *
651 * @param array<int> $entry_ids Entry IDs.
652 * @param int $form_id Form ID.
653 *
654 * @since 2.0.0
655 * @return array<mixed> Entry data.
656 */
657 private static function get_entries_data_for_export( $entry_ids, $form_id ) {
658 return EntriesTable::get_all(
659 [
660 'where' => [
661 [
662 [
663 'key' => 'ID',
664 'compare' => 'IN',
665 'value' => $entry_ids,
666 ],
667 [
668 'key' => 'form_id',
669 'compare' => '=',
670 'value' => $form_id,
671 ],
672 ],
673 ],
674 'columns' => '*',
675 ],
676 false
677 );
678 }
679
680 /**
681 * Build block key map and labels for CSV export.
682 *
683 * @param array<mixed> $results Entry results.
684 *
685 * @since 2.0.0
686 * @return array{map: array<string,string>, labels: array<string,string>} Map and labels.
687 */
688 private static function build_block_key_map_and_labels( $results ) {
689 $block_key_map = [];
690 $block_labels = [];
691 $excluded = Helper::get_excluded_fields();
692
693 foreach ( $results as $entry ) {
694 $form_data = is_array( $entry ) && isset( $entry['form_data'] ) ? Helper::get_array_value( $entry['form_data'] ) : [];
695
696 foreach ( $form_data as $srfm_key => $value ) {
697 if ( in_array( $srfm_key, $excluded, true ) ) {
698 continue;
699 }
700
701 $block_id = Helper::get_block_id_from_key( $srfm_key );
702
703 if ( empty( $block_id ) ) {
704 continue;
705 }
706
707 $block_key_map[ $block_id ] = $srfm_key;
708 $block_labels[ $block_id ] = Helper::get_field_label_from_key( $srfm_key );
709 }
710 }
711
712 return [
713 'map' => $block_key_map,
714 'labels' => $block_labels,
715 ];
716 }
717
718 /**
719 * Write CSV header row.
720 *
721 * @param resource $stream File stream.
722 * @param array<string> $block_labels Block labels.
723 *
724 * @since 2.0.0
725 * @return void
726 */
727 private static function write_csv_header( $stream, $block_labels ) {
728 $header = array_merge(
729 [ __( 'Entry ID', 'sureforms' ), __( 'Date', 'sureforms' ), __( 'Status', 'sureforms' ) ],
730 array_values( $block_labels )
731 );
732 fputcsv( $stream, $header ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv
733 }
734
735 /**
736 * Write CSV data rows.
737 *
738 * @param resource $stream File stream.
739 * @param array<mixed> $results Entry results.
740 * @param array<string,string> $block_key_map Block key map.
741 *
742 * @since 2.0.0
743 * @return void
744 */
745 private static function write_csv_rows( $stream, $results, $block_key_map ) {
746 foreach ( $results as $entry ) {
747 if ( ! is_array( $entry ) ) {
748 continue;
749 }
750
751 $row = [];
752 $row[] = isset( $entry['ID'] ) ? Helper::get_integer_value( $entry['ID'] ) : '';
753 $row[] = isset( $entry['created_at'] ) ? Helper::get_string_value( $entry['created_at'] ) : '';
754 $row[] = isset( $entry['status'] ) ? Helper::get_string_value( $entry['status'] ) : '';
755 $form_data = isset( $entry['form_data'] ) ? Helper::get_array_value( $entry['form_data'] ) : [];
756
757 foreach ( $block_key_map as $srfm_key ) {
758 $field_value = $form_data[ $srfm_key ] ?? '';
759 $row[] = self::escape_csv_formula( self::normalize_field_values( $field_value, $srfm_key ) );
760 }
761
762 fputcsv( $stream, $row ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv
763 }
764 }
765
766 /**
767 * Normalize field values for CSV export.
768 *
769 * @param mixed $field_value Field value.
770 * @param string $field_key Field key for field type.
771 *
772 * @since 2.0.0
773 * @return string Normalized value.
774 */
775 private static function normalize_field_values( $field_value, $field_key = '' ) {
776 /**
777 * Filter field value for CSV normalization.
778 *
779 * Allows modification of field values during CSV export. This is particularly
780 * useful for custom field types (like repeater fields in Pro) that need
781 * special formatting for CSV export.
782 *
783 * @since 2.3.0
784 *
785 * @param mixed $field_value The field value to normalize.
786 * @param string $field_key The field key for identifying field type.
787 *
788 * @return mixed The filtered field value. Return a string to override default normalization.
789 */
790 $filtered_value = apply_filters( 'srfm_normalize_csv_field_value', $field_value, $field_key );
791
792 // If filter returned a string, use it directly (custom handling was applied).
793 if ( is_string( $filtered_value ) && $filtered_value !== $field_value ) {
794 return $filtered_value;
795 }
796
797 // Handle arrays (multi-select, checkboxes, upload fields, etc.).
798 if ( is_array( $field_value ) ) {
799 // Upload field URLs are stored rawurlencode'd — decode before export.
800 if ( str_contains( $field_key, 'srfm-upload' ) ) {
801 $decoded_values = array_map(
802 static function ( $val ) {
803 return sanitize_text_field( rawurldecode( Helper::get_string_value( $val ) ) );
804 },
805 $field_value
806 );
807 return implode( ', ', $decoded_values );
808 }
809 return implode( ', ', array_map( 'sanitize_text_field', $field_value ) );
810 }
811
812 // Textarea fields contain intentional line breaks — use sanitize_textarea_field()
813 // to preserve them. sanitize_text_field() strips newlines, flattening multi-line
814 // content in the CSV export.
815 if ( str_contains( $field_key, 'srfm-textarea' ) ) {
816 return sanitize_textarea_field( Helper::get_string_value( $field_value ) );
817 }
818
819 return sanitize_text_field( Helper::get_string_value( $field_value ) );
820 }
821
822 /**
823 * Neutralize CSV formula/macro injection in an exported cell.
824 *
825 * Spreadsheet applications (Excel, Google Sheets, LibreOffice) interpret a
826 * cell whose value begins with `=`, `+`, `-`, `@`, a tab, or a carriage
827 * return as a formula and may execute it when an admin opens the export.
828 * A submitter could store `=HYPERLINK(...)` or `=cmd|...` in a field and
829 * have it run on the admin's machine. Prefixing such values with a single
830 * quote forces the spreadsheet to treat them as literal text.
831 *
832 * Well-formed numbers (including negative and decimal values) are returned
833 * unchanged so numeric columns remain numeric in the spreadsheet.
834 *
835 * @param string $value Cell value (already normalized for CSV).
836 *
837 * @since 2.10.0
838 * @return string Safe cell value.
839 */
840 private static function escape_csv_formula( $value ) {
841 $value = Helper::get_string_value( $value );
842
843 if ( '' === $value || is_numeric( $value ) ) {
844 return $value;
845 }
846
847 if ( in_array( $value[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
848 return "'" . $value;
849 }
850
851 return $value;
852 }
853 }
854