PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.3
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.3
2.12.5 2.12.4 2.12.3 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 3 weeks ago admin 3 months ago ai-form-builder 1 month ago blocks 2 months ago compatibility 3 weeks ago database 3 weeks ago email 3 weeks ago fields 3 weeks ago global-settings 1 month ago lib 1 month ago migrator 2 months ago page-builders 3 weeks ago payments 3 weeks ago single-form-settings 2 months ago traits 2 months ago activator.php 1 year ago admin-ajax.php 2 months ago background-process.php 9 months ago create-new-form.php 3 months ago duplicate-form.php 3 months ago entries.php 3 weeks ago events-scheduler.php 2 years ago export.php 3 months ago field-validation.php 3 weeks ago form-restriction.php 2 months ago form-styling.php 1 month ago form-submit.php 3 weeks ago forms-data.php 5 months ago frontend-assets.php 1 month ago generate-form-markup.php 3 weeks ago gutenberg-hooks.php 3 weeks ago helper.php 3 weeks ago learn.php 4 months ago onboarding.php 2 months ago post-types.php 1 month ago rest-api.php 3 weeks ago smart-tags.php 4 months ago submit-token.php 4 months ago translatable.php 1 month ago updater-callbacks.php 3 weeks ago updater.php 3 weeks ago
entries.php
923 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 matching entry ID (numeric terms), form title, or submitted form data (3+ characters). 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. When a search term is active the WHERE includes
88 // an unindexed form_data LIKE, so the COUNT is a full scan of the candidate rows —
89 // and it re-runs on every pagination click for an answer that cannot change between
90 // clicks. Cache it briefly (30s) keyed on the exact conditions; ≤30s staleness in a
91 // pager total is harmless for an admin screen. Unsearched listings stay uncached so
92 // totals reflect trash/delete/read mutations immediately.
93 if ( ! empty( $args['search'] ) ) {
94 // wp_json_encode() returns false on failure, and (string) false is '' — which
95 // would make every search share md5('') and serve one search's total for all
96 // others. Skip the cache entirely rather than key it ambiguously.
97 $encoded_conditions = wp_json_encode( $where_conditions );
98 $count_cache_key = is_string( $encoded_conditions )
99 ? 'srfm_entries_search_count_' . md5( $encoded_conditions )
100 : '';
101 $cached_total = '' !== $count_cache_key ? get_transient( $count_cache_key ) : false;
102
103 if ( is_numeric( $cached_total ) ) {
104 $total = absint( $cached_total );
105 } else {
106 $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
107 // Honor the skip-on-encode-failure decision above: only cache when we have
108 // an unambiguous key. Otherwise set_transient( '', … ) would write a single
109 // global transient shared across all searches.
110 if ( '' !== $count_cache_key ) {
111 set_transient( $count_cache_key, $total, 30 );
112 }
113 }
114 } else {
115 $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
116 }
117
118 // Calculate offset.
119 $offset = ( absint( $args['page'] ) - 1 ) * absint( $args['per_page'] );
120
121 // Get entries.
122 $entries = EntriesTable::get_all(
123 [
124 'where' => $where_conditions,
125 'columns' => '*',
126 'orderby' => Helper::get_string_value( $args['orderby'] ),
127 'order' => Helper::get_string_value( $args['order'] ),
128 'limit' => absint( $args['per_page'] ),
129 'offset' => $offset,
130 ]
131 );
132
133 // Check if trash is empty.
134 $trash_count = EntriesTable::get_instance()->get_total_count(
135 [
136 [
137 [
138 'key' => 'status',
139 'compare' => '=',
140 'value' => 'trash',
141 ],
142 ],
143 ]
144 );
145
146 return [
147 'entries' => $entries,
148 'total' => $total,
149 'per_page' => absint( $args['per_page'] ),
150 'current_page' => absint( $args['page'] ),
151 'total_pages' => ceil( $total / max( 1, absint( $args['per_page'] ) ) ),
152 'emptyTrash' => 0 === $trash_count,
153 ];
154 }
155
156 /**
157 * Update entry status (trash/untrash/read/unread).
158 *
159 * @param int|array<int> $entry_ids Entry ID or array of entry IDs.
160 * @param string $status New status: 'trash', 'unread', 'read', or 'restore'.
161 *
162 * @since 2.0.0
163 * @return array<string,mixed> {
164 * @type bool $success Whether the operation was successful.
165 * @type int $updated Number of entries updated.
166 * @type array<string> $errors Array of error messages.
167 * }
168 */
169 public static function update_status( $entry_ids, $status ) {
170 $entry_ids = is_array( $entry_ids ) ? array_map( 'absint', $entry_ids ) : [ absint( $entry_ids ) ];
171 $entry_ids = array_filter( $entry_ids ); // Remove any zero values.
172
173 if ( empty( $entry_ids ) ) {
174 return [
175 'success' => false,
176 'updated' => 0,
177 'errors' => [ __( 'No valid entry IDs provided.', 'sureforms' ) ],
178 ];
179 }
180
181 // Validate status.
182 $valid_statuses = [ 'trash', 'unread', 'read', 'restore' ];
183 if ( ! in_array( $status, $valid_statuses, true ) ) {
184 return [
185 'success' => false,
186 'updated' => 0,
187 'errors' => [ __( 'Invalid status provided.', 'sureforms' ) ],
188 ];
189 }
190
191 // Map 'restore' to 'unread'.
192 $actual_status = 'restore' === $status ? 'unread' : $status;
193
194 $updated = 0;
195 $errors = [];
196
197 foreach ( $entry_ids as $entry_id ) {
198 $result = EntriesTable::update( $entry_id, [ 'status' => $actual_status ] );
199
200 if ( false === $result ) {
201 $errors[] = sprintf(
202 // translators: %d is the entry ID.
203 __( 'Failed to update entry #%d.', 'sureforms' ),
204 $entry_id
205 );
206 } else {
207 ++$updated;
208 }
209 }
210
211 return [
212 'success' => $updated > 0,
213 'updated' => $updated,
214 'errors' => $errors,
215 ];
216 }
217
218 /**
219 * Permanently delete entries.
220 *
221 * @param int|array<int> $entry_ids Entry ID or array of entry IDs.
222 *
223 * @since 2.0.0
224 * @return array<string,mixed> {
225 * @type bool $success Whether the operation was successful.
226 * @type int $deleted Number of entries deleted.
227 * @type array<string> $errors Array of error messages.
228 * }
229 */
230 public static function delete_entries( $entry_ids ) {
231 $entry_ids = is_array( $entry_ids ) ? array_map( 'absint', $entry_ids ) : [ absint( $entry_ids ) ];
232 $entry_ids = array_filter( $entry_ids );
233
234 if ( empty( $entry_ids ) ) {
235 return [
236 'success' => false,
237 'deleted' => 0,
238 'errors' => [ __( 'No valid entry IDs provided.', 'sureforms' ) ],
239 ];
240 }
241
242 $deleted = 0;
243 $errors = [];
244
245 foreach ( $entry_ids as $entry_id ) {
246 $result = EntriesTable::delete( $entry_id );
247
248 if ( false === $result ) {
249 $errors[] = sprintf(
250 // translators: %d is the entry ID.
251 __( 'Failed to delete entry #%d.', 'sureforms' ),
252 $entry_id
253 );
254 } else {
255 ++$deleted;
256 }
257 }
258
259 return [
260 'success' => $deleted > 0,
261 'deleted' => $deleted,
262 'errors' => $errors,
263 ];
264 }
265
266 /**
267 * Export entries to CSV format.
268 *
269 * @param array<string,mixed> $args {
270 * Optional. An array of arguments for export.
271 *
272 * @type int|array<int> $entry_ids Entry ID or array of entry IDs to export.
273 * @type int $form_id Form ID to export all entries from.
274 * @type string $status Entry status filter.
275 * @type string $search Search term filter.
276 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format).
277 * @type string $date_to End date for filtering entries (YYYY-MM-DD format).
278 * }
279 *
280 * @since 2.0.0
281 * @return array<string,mixed> {
282 * @type bool $success Whether the export was successful.
283 * @type string $filename Export filename (if single form).
284 * @type string $filepath Full path to the exported file.
285 * @type string $type Export type: 'csv' or 'zip'.
286 * @type string $error Error message if failed.
287 * }
288 */
289 public static function export_entries( $args = [] ) {
290 $defaults = [
291 'entry_ids' => [],
292 'form_id' => 0,
293 'status' => 'all',
294 'search' => '',
295 'date_from' => '',
296 'date_to' => '',
297 ];
298
299 /**
300 * Parsed and sanitized arguments for the export operation.
301 *
302 * @var array{entry_ids: int|array<int>, form_id: int, status: string, search: string, date_from: string, date_to: string} $args
303 */
304 $args = wp_parse_args( $args, $defaults );
305
306 // Get entry IDs to export.
307 if ( empty( $args['entry_ids'] ) ) {
308 // If no specific entry IDs provided, get all matching entries.
309 $where_conditions = self::build_where_conditions( $args );
310 $all_entries = EntriesTable::get_all(
311 [
312 'where' => $where_conditions,
313 'columns' => 'ID',
314 ],
315 false
316 );
317 $entry_ids = array_map( 'absint', array_column( $all_entries, 'ID' ) );
318 } else {
319 /**
320 * Entry IDs converted to array of integers.
321 *
322 * @var array<int> $entry_ids
323 */
324 $entry_ids = is_array( $args['entry_ids'] ) ? array_map( 'absint', $args['entry_ids'] ) : [ absint( (int) $args['entry_ids'] ) ];
325 }
326
327 if ( empty( $entry_ids ) ) {
328 return [
329 'success' => false,
330 'error' => __( 'No entries found to export.', 'sureforms' ),
331 ];
332 }
333
334 // Get form IDs from entry IDs.
335 $form_ids = EntriesTable::get_form_ids_by_entries( $entry_ids );
336 $is_single_form = count( $form_ids ) === 1;
337
338 $temp_dir = wp_normalize_path( trailingslashit( get_temp_dir() ) );
339
340 // Check if temp directory is writable.
341 if ( ! wp_is_writable( $temp_dir ) ) {
342 return [
343 'success' => false,
344 'error' => __( 'Temporary directory is not writable.', 'sureforms' ),
345 ];
346 }
347
348 $csv_files = [];
349 $zip = null;
350 $temp_zip = '';
351
352 // Create ZIP if multiple forms.
353 if ( ! $is_single_form ) {
354 if ( ! class_exists( 'ZipArchive' ) ) {
355 return [
356 'success' => false,
357 'error' => __( 'ZipArchive class is not available.', 'sureforms' ),
358 ];
359 }
360
361 $temp_zip = $temp_dir . 'srfm-entries-export-' . time() . '.zip';
362 $zip = new \ZipArchive();
363
364 if ( ! $zip->open( $temp_zip, \ZipArchive::CREATE ) ) {
365 return [
366 'success' => false,
367 'error' => __( 'Unable to create ZIP file.', 'sureforms' ),
368 ];
369 }
370 }
371
372 $csv_filepath = '';
373
374 // Process each form.
375 foreach ( $form_ids as $form_id ) {
376 $results = self::get_entries_data_for_export( $entry_ids, $form_id );
377
378 if ( empty( $results ) ) {
379 continue;
380 }
381
382 $sanitized_form_title = sanitize_title( get_the_title( $form_id ) );
383 $sanitized_form_title = ! empty( $sanitized_form_title ) ? $sanitized_form_title : "srfm-entries-{$form_id}";
384
385 $csv_filename = 'srfm-entries-' . $sanitized_form_title . '.csv';
386 $csv_filepath = $temp_dir . $csv_filename;
387
388 if ( file_exists( $csv_filepath ) ) {
389 wp_delete_file( $csv_filepath );
390 }
391
392 $stream = fopen( $csv_filepath, 'wb' ); // phpcs:ignore -- Using fopen to decrease the memory use.
393
394 if ( ! is_resource( $stream ) ) {
395 continue;
396 }
397
398 $csv_files[] = $csv_filepath;
399
400 // Build CSV content.
401 $block_data = self::build_block_key_map_and_labels( $results );
402 self::write_csv_header( $stream, $block_data['labels'] );
403 self::write_csv_rows( $stream, $results, $block_data['map'] );
404
405 fclose( $stream ); // phpcs:ignore -- Using fopen to decrease the memory use.
406
407 // Add to ZIP if multiple forms.
408 if ( ! $is_single_form && $zip && filesize( $csv_filepath ) > 0 ) {
409 $zip->addFile( $csv_filepath, $csv_filename );
410 }
411 }
412
413 // Single form - return CSV.
414 if ( $is_single_form && ! empty( $csv_filepath ) && file_exists( $csv_filepath ) ) {
415 return [
416 'success' => true,
417 'filename' => basename( $csv_filepath ),
418 'filepath' => $csv_filepath,
419 'type' => 'csv',
420 ];
421 }
422
423 // Multiple forms - return ZIP.
424 if ( ! $is_single_form && $zip ) {
425 $zip->close();
426
427 // Clean up CSV files.
428 foreach ( $csv_files as $csv_file ) {
429 if ( file_exists( $csv_file ) ) {
430 wp_delete_file( $csv_file );
431 }
432 }
433
434 return [
435 'success' => true,
436 'filename' => 'SureForms-Entries.zip',
437 'filepath' => $temp_zip,
438 'type' => 'zip',
439 ];
440 }
441
442 return [
443 'success' => false,
444 'error' => __( 'Unable to generate export file.', 'sureforms' ),
445 ];
446 }
447
448 /**
449 * Get adjacent entry IDs (previous and next) for navigation.
450 * Navigation follows chronological order: Previous = older, Next = newer.
451 *
452 * @param int $current_entry_id Current entry ID.
453 * @param array<string,mixed> $args {
454 * Optional. An array of arguments to filter the navigation context.
455 *
456 * @type int $form_id Form ID to filter entries. Default 0 (all forms).
457 * @type string $status Entry status: 'all', 'read', 'unread', 'trash'. Default 'all'.
458 * @type string $search Search term to filter entries by entry ID. Default empty.
459 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format). Default empty.
460 * @type string $date_to End date for filtering entries (YYYY-MM-DD format). Default empty.
461 * @type string $orderby Column to order by. Default 'created_at'.
462 * @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
463 * }
464 *
465 * @since 2.4.0
466 * @return array<string,int|null> {
467 * @type int|null $previous_id Previous entry ID (older entry) or null if at the oldest.
468 * @type int|null $next_id Next entry ID (newer entry) or null if at the newest.
469 * }
470 */
471 public static function get_adjacent_entry_ids( $current_entry_id, $args = [] ) {
472 $defaults = [
473 'form_id' => 0,
474 'status' => 'all',
475 'search' => '',
476 'date_from' => '',
477 'date_to' => '',
478 'orderby' => 'created_at',
479 'order' => 'DESC',
480 ];
481
482 $args = wp_parse_args( $args, $defaults );
483
484 // Build where conditions.
485 $where_conditions = self::build_where_conditions( $args );
486
487 // Get all entry IDs in chronological order (oldest to newest).
488 // This ensures Previous = older, Next = newer regardless of listing page sort.
489 $all_entries = EntriesTable::get_all(
490 [
491 'where' => $where_conditions,
492 'columns' => 'ID',
493 'orderby' => 'created_at',
494 'order' => 'ASC',
495 ],
496 false
497 );
498
499 // Extract entry IDs into a simple array.
500 $entry_ids = array_map(
501 static function ( $entry ) {
502 return is_array( $entry ) ? Helper::get_integer_value( $entry['ID'] ) : 0;
503 },
504 $all_entries
505 );
506
507 // Find the position of the current entry.
508 $current_position = array_search( absint( $current_entry_id ), $entry_ids, true );
509
510 if ( false === $current_position ) {
511 // Current entry not found in the filtered list.
512 return [
513 'previous_id' => null,
514 'next_id' => null,
515 ];
516 }
517
518 // Convert to integer after validation.
519 $current_position = Helper::get_integer_value( $current_position );
520
521 // Get previous and next entry IDs.
522 $previous_id = $current_position > 0 ? $entry_ids[ $current_position - 1 ] : null;
523 $next_id = $current_position < count( $entry_ids ) - 1 ? $entry_ids[ $current_position + 1 ] : null;
524
525 return [
526 'previous_id' => $previous_id,
527 'next_id' => $next_id,
528 ];
529 }
530
531 /**
532 * Neutralize CSV formula/macro injection in an exported cell.
533 *
534 * Spreadsheet applications (Excel, Google Sheets, LibreOffice) interpret a
535 * cell whose value begins with `=`, `+`, `-`, `@`, a tab, or a carriage
536 * return as a formula and may execute it when an admin opens the export.
537 * A submitter could store `=HYPERLINK(...)` or `=cmd|...` in a field and
538 * have it run on the admin's machine. Prefixing such values with a single
539 * quote forces the spreadsheet to treat them as literal text.
540 *
541 * Well-formed numbers (including negative and decimal values) are returned
542 * unchanged so numeric columns remain numeric in the spreadsheet.
543 *
544 * Public so every CSV writer in the product can share one implementation rather
545 * than carrying its own copy — SureForms Pro exports partial entries through a
546 * separate writer and needs the same guard.
547 *
548 * @param string $value Cell value (already normalized for CSV).
549 *
550 * @since 2.10.0
551 * @since 2.12.3 Promoted from private to public so other export writers can reuse it.
552 * @return string Safe cell value.
553 */
554 public static function escape_csv_formula( $value ) {
555 $value = Helper::get_string_value( $value );
556
557 if ( '' === $value || is_numeric( $value ) ) {
558 return $value;
559 }
560
561 if ( in_array( $value[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
562 return "'" . $value;
563 }
564
565 return $value;
566 }
567
568 /**
569 * Build where conditions for entry queries.
570 *
571 * @param array<string, int|string|array<int>> $args Query arguments.
572 *
573 * @since 2.0.0
574 * @return array<mixed> Where conditions array.
575 */
576 private static function build_where_conditions( $args ) {
577 $where_conditions = [];
578
579 // Filter by entry IDs.
580 if ( ! empty( $args['entry_ids'] ) && is_array( $args['entry_ids'] ) ) {
581 $where_conditions[] = [
582 [
583 'key' => 'ID',
584 'compare' => 'IN',
585 'value' => array_map( 'absint', $args['entry_ids'] ),
586 ],
587 ];
588 return $where_conditions;
589 }
590
591 // Filter by status.
592 if ( 'all' !== $args['status'] ) {
593 $where_conditions[] = [
594 [
595 'key' => 'status',
596 'compare' => '=',
597 'value' => Helper::get_string_value( $args['status'] ),
598 ],
599 ];
600 } else {
601 // Exclude trash when status is 'all'.
602 $where_conditions[] = [
603 [
604 'key' => 'status',
605 'compare' => '!=',
606 'value' => 'trash',
607 ],
608 ];
609 }
610
611 // Filter by form ID.
612 $form_id = Helper::get_integer_value( $args['form_id'] ?? 0 );
613 if ( ! empty( $args['form_id'] ) && $form_id > 0 ) {
614 $where_conditions[] = [
615 [
616 'key' => 'form_id',
617 'compare' => '=',
618 'value' => $form_id,
619 ],
620 ];
621 }
622
623 // Filter by date range.
624 if ( ! empty( $args['date_from'] ) || ! empty( $args['date_to'] ) ) {
625 $date_conditions = [];
626
627 if ( ! empty( $args['date_from'] ) ) {
628 $date_conditions[] = [
629 'key' => 'created_at',
630 'compare' => '>=',
631 'value' => Helper::get_string_value( $args['date_from'] ),
632 ];
633 }
634
635 if ( ! empty( $args['date_to'] ) ) {
636 $date_conditions[] = [
637 'key' => 'created_at',
638 'compare' => '<=',
639 'value' => Helper::get_string_value( $args['date_to'] ),
640 ];
641 }
642
643 if ( ! empty( $date_conditions ) ) {
644 $where_conditions[] = $date_conditions;
645 }
646 }
647
648 // Filter by search (entry ID + form title + submitted form data).
649 // Use an explicit empty-string test rather than ! empty(): empty( '0' ) is true in
650 // PHP, so searching "0" silently dropped the entire search group and returned every
651 // entry while the UI still showed the term.
652 if ( isset( $args['search'] ) && is_string( $args['search'] ) && '' !== $args['search'] ) {
653 global $wpdb;
654
655 $search_term = sanitize_text_field( $args['search'] );
656 $search_group = [ 'RELATION' => 'OR' ];
657
658 // If numeric, match entry ID.
659 if ( is_numeric( $search_term ) ) {
660 $search_group[] = [
661 'key' => 'ID',
662 'compare' => '=',
663 'value' => absint( $search_term ),
664 ];
665 }
666
667 // Match form titles.
668 $matching_form_ids = self::get_form_ids_by_title( $search_term );
669 if ( ! empty( $matching_form_ids ) ) {
670 $search_group[] = [
671 'key' => 'form_id',
672 'compare' => 'IN',
673 'value' => $matching_form_ids,
674 ];
675 }
676
677 // Match submitted form data. The form_data column stores plain JSON
678 // (Helper::encode_json() uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
679 // so a LIKE matches submitted values textually — including emails, URLs and
680 // non-ASCII input. The query compiler (Base::prepare_where_clauses()) wraps
681 // the value in "%...%" itself; esc_like() here neutralizes user-typed wildcard
682 // characters ("%", "_") so they match literally.
683 // Performance guard: the LIKE cannot use an index (full scan of the LONGTEXT
684 // column within the other filters), so require at least 3 characters before
685 // matching form data. Shorter terms would match almost every row anyway while
686 // costing the most. Numeric terms are exempt above (exact, indexed ID lookup),
687 // and form-title matching is a cheap separate posts query.
688 if ( mb_strlen( $search_term ) >= 3 ) {
689 $search_group[] = [
690 'key' => 'form_data',
691 'compare' => 'LIKE',
692 'value' => $wpdb->esc_like( $search_term ),
693 ];
694 }
695
696 // Guard: a short non-numeric term that matches no form title produces no
697 // usable condition — force an empty result instead of silently returning
698 // every entry (an OR-group with no conditions would be dropped by the
699 // query compiler).
700 if ( count( $search_group ) > 1 ) {
701 $where_conditions[] = $search_group;
702 } else {
703 $where_conditions[] = [
704 [
705 'key' => 'ID',
706 'compare' => '=',
707 'value' => 0,
708 ],
709 ];
710 }
711 }
712
713 return $where_conditions;
714 }
715
716 /**
717 * Get form IDs whose title matches the search term.
718 *
719 * @param string $search_term Search term to match against form titles.
720 *
721 * @since 2.6.0
722 * @return array<int> Array of matching form IDs.
723 */
724 private static function get_form_ids_by_title( $search_term ) {
725 $forms = get_posts(
726 [
727 'post_type' => SRFM_FORMS_POST_TYPE,
728 'post_status' => 'any',
729 's' => $search_term,
730 'search_columns' => [ 'post_title' ],
731 'posts_per_page' => 100,
732 'fields' => 'ids',
733 'no_found_rows' => true,
734 ]
735 );
736
737 return ! empty( $forms ) ? array_map( 'absint', $forms ) : [];
738 }
739
740 /**
741 * Get entries data for export based on entry IDs and form ID.
742 *
743 * @param array<int> $entry_ids Entry IDs.
744 * @param int $form_id Form ID.
745 *
746 * @since 2.0.0
747 * @return array<mixed> Entry data.
748 */
749 private static function get_entries_data_for_export( $entry_ids, $form_id ) {
750 return EntriesTable::get_all(
751 [
752 'where' => [
753 [
754 [
755 'key' => 'ID',
756 'compare' => 'IN',
757 'value' => $entry_ids,
758 ],
759 [
760 'key' => 'form_id',
761 'compare' => '=',
762 'value' => $form_id,
763 ],
764 ],
765 ],
766 'columns' => '*',
767 ],
768 false
769 );
770 }
771
772 /**
773 * Build block key map and labels for CSV export.
774 *
775 * @param array<mixed> $results Entry results.
776 *
777 * @since 2.0.0
778 * @return array{map: array<string,string>, labels: array<string,string>} Map and labels.
779 */
780 private static function build_block_key_map_and_labels( $results ) {
781 $block_key_map = [];
782 $block_labels = [];
783 $excluded = Helper::get_excluded_fields();
784
785 foreach ( $results as $entry ) {
786 $form_data = is_array( $entry ) && isset( $entry['form_data'] ) ? Helper::get_array_value( $entry['form_data'] ) : [];
787
788 foreach ( $form_data as $srfm_key => $value ) {
789 if ( in_array( $srfm_key, $excluded, true ) ) {
790 continue;
791 }
792
793 $block_id = Helper::get_block_id_from_key( $srfm_key );
794
795 if ( empty( $block_id ) ) {
796 continue;
797 }
798
799 $block_key_map[ $block_id ] = $srfm_key;
800 $block_labels[ $block_id ] = Helper::get_field_label_from_key( $srfm_key );
801 }
802 }
803
804 return [
805 'map' => $block_key_map,
806 'labels' => $block_labels,
807 ];
808 }
809
810 /**
811 * Write CSV header row.
812 *
813 * @param resource $stream File stream.
814 * @param array<string> $block_labels Block labels.
815 *
816 * @since 2.0.0
817 * @return void
818 */
819 private static function write_csv_header( $stream, $block_labels ) {
820 // Labels are decoded out of stored form_data keys, so they are submitter-influenced
821 // and need the same formula escaping as the data cells — see write_csv_rows().
822 $labels = array_map(
823 static function ( $label ) {
824 return self::escape_csv_formula( Helper::get_string_value( $label ) );
825 },
826 array_values( $block_labels )
827 );
828
829 $header = array_merge(
830 [ __( 'Entry ID', 'sureforms' ), __( 'Date', 'sureforms' ), __( 'Status', 'sureforms' ) ],
831 $labels
832 );
833 fputcsv( $stream, $header ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv
834 }
835
836 /**
837 * Write CSV data rows.
838 *
839 * @param resource $stream File stream.
840 * @param array<mixed> $results Entry results.
841 * @param array<string,string> $block_key_map Block key map.
842 *
843 * @since 2.0.0
844 * @return void
845 */
846 private static function write_csv_rows( $stream, $results, $block_key_map ) {
847 foreach ( $results as $entry ) {
848 if ( ! is_array( $entry ) ) {
849 continue;
850 }
851
852 $row = [];
853 $row[] = isset( $entry['ID'] ) ? Helper::get_integer_value( $entry['ID'] ) : '';
854 $row[] = isset( $entry['created_at'] ) ? Helper::get_string_value( $entry['created_at'] ) : '';
855 $row[] = isset( $entry['status'] ) ? Helper::get_string_value( $entry['status'] ) : '';
856 $form_data = isset( $entry['form_data'] ) ? Helper::get_array_value( $entry['form_data'] ) : [];
857
858 foreach ( $block_key_map as $srfm_key ) {
859 $field_value = $form_data[ $srfm_key ] ?? '';
860 $row[] = self::escape_csv_formula( self::normalize_field_values( $field_value, $srfm_key ) );
861 }
862
863 fputcsv( $stream, $row ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv
864 }
865 }
866
867 /**
868 * Normalize field values for CSV export.
869 *
870 * @param mixed $field_value Field value.
871 * @param string $field_key Field key for field type.
872 *
873 * @since 2.0.0
874 * @return string Normalized value.
875 */
876 private static function normalize_field_values( $field_value, $field_key = '' ) {
877 /**
878 * Filter field value for CSV normalization.
879 *
880 * Allows modification of field values during CSV export. This is particularly
881 * useful for custom field types (like repeater fields in Pro) that need
882 * special formatting for CSV export.
883 *
884 * @since 2.3.0
885 *
886 * @param mixed $field_value The field value to normalize.
887 * @param string $field_key The field key for identifying field type.
888 *
889 * @return mixed The filtered field value. Return a string to override default normalization.
890 */
891 $filtered_value = apply_filters( 'srfm_normalize_csv_field_value', $field_value, $field_key );
892
893 // If filter returned a string, use it directly (custom handling was applied).
894 if ( is_string( $filtered_value ) && $filtered_value !== $field_value ) {
895 return $filtered_value;
896 }
897
898 // Handle arrays (multi-select, checkboxes, upload fields, etc.).
899 if ( is_array( $field_value ) ) {
900 // Upload field URLs are stored rawurlencode'd — decode before export.
901 if ( str_contains( $field_key, 'srfm-upload' ) ) {
902 $decoded_values = array_map(
903 static function ( $val ) {
904 return sanitize_text_field( rawurldecode( Helper::get_string_value( $val ) ) );
905 },
906 $field_value
907 );
908 return implode( ', ', $decoded_values );
909 }
910 return implode( ', ', array_map( 'sanitize_text_field', $field_value ) );
911 }
912
913 // Textarea fields contain intentional line breaks — use sanitize_textarea_field()
914 // to preserve them. sanitize_text_field() strips newlines, flattening multi-line
915 // content in the CSV export.
916 if ( str_contains( $field_key, 'srfm-textarea' ) ) {
917 return sanitize_textarea_field( Helper::get_string_value( $field_value ) );
918 }
919
920 return sanitize_text_field( Helper::get_string_value( $field_value ) );
921 }
922 }
923