PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 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 All 97 releases
← All changes | inc/forms-data.php +627 -6 0.0.132.12.7 View file →
@@ -7,12 +7,12 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 +use SRFM\Inc\Database\Tables\Entries;
12 +use SRFM\Inc\Traits\Get_Instance;
13 +use WP_Error;
11 14 use WP_REST_Response;
12 -use WP_Error;
13 -use SRFM\Inc\Traits\Get_Instance;
14 -use SRFM\Inc\Helper;
15 15
16 16 if ( ! defined( 'ABSPATH' ) ) {
17 17 exit; // Exit if accessed directly.
18 18 }
@@ -25,8 +25,28 @@
25 25 class Forms_Data {
26 26 use Get_Instance;
27 27
28 28 /**
29 + * Ids of the in-window submitters who can edit the site, keyed by blog id.
30 + *
31 + * Keyed on the blog rather than held as a single list because capabilities are
32 + * per-site: after a switch_to_blog() the previous site's answer is wrong, and a
33 + * flat cache would hand it back.
34 + *
35 + * A class property rather than a `static` inside the method so it can be reset,
36 + * which the tests need: they create a user and then ask for the metrics inside
37 + * one process.
38 + *
39 + * Deliberately per-request and never written to the object cache. A persistent
40 + * cache would keep counting a newly promoted editor as a visitor until
41 + * something invalidated it, and there is no natural invalidation point.
42 + *
43 + * @var array<int,array<int,int>>|null
44 + * @since 2.12.7
45 + */
46 + private static $editing_user_ids = null;
47 +
48 + /**
29 49 * Constructor
30 50 *
31 51 * @since 0.0.1
32 52 */
@@ -54,14 +74,13 @@
54 74
55 75 /**
56 76 * Checks whether a given request has permission to read the form.
57 77 *
58 - * @param \WP_REST_Request $request Full details about the request.
59 78 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
60 79 * @since 0.0.1
61 80 */
62 - public function get_form_permissions_check( $request ) {
63 - if ( current_user_can( 'edit_posts' ) ) {
81 + public function get_form_permissions_check() {
82 + if ( Helper::current_user_can( 'edit_posts' ) ) {
64 83 return true;
65 84 }
66 85
67 86 return new \WP_Error(
@@ -110,6 +129,608 @@
110 129 ];
111 130 }
112 131
113 132 return new WP_REST_Response( $data );
133 + }
134 +
135 + /**
136 + * Get forms list for the forms listing page.
137 + *
138 + * @param \WP_REST_Request $request Full details about the request.
139 + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
140 + * @since 2.0.0
141 + */
142 + public function get_forms_list( $request ) {
143 + $nonce = sanitize_text_field( Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) ) );
144 +
145 + Helper::verify_nonce_and_capabilities( 'rest', $nonce, 'wp_rest' );
146 +
147 + // Get and validate request parameters.
148 + $page = max( 1, Helper::get_integer_value( $request->get_param( 'page' ) ) );
149 + $status = sanitize_text_field( $request->get_param( 'status' ) );
150 +
151 + // Get per_page from option first, then request parameter, with fallback to 10.
152 + $saved_per_page = Helper::get_srfm_option( 'forms_per_page', 10 );
153 + $request_per_page = $request->get_param( 'per_page' );
154 + $per_page = $request_per_page ? min( 100, max( 1, Helper::get_integer_value( $request_per_page ) ) ) : $saved_per_page;
155 +
156 + // Save per_page to option if it came from request.
157 + if ( $request_per_page && 'trash' !== $status && 1 < $request_per_page ) {
158 + Helper::update_srfm_option( 'forms_per_page', $per_page );
159 + }
160 +
161 + $search = sanitize_text_field( $request->get_param( 'search' ) );
162 + $orderby = sanitize_text_field( $request->get_param( 'orderby' ) );
163 + $order = sanitize_text_field( $request->get_param( 'order' ) );
164 + $date_from = sanitize_text_field( $request->get_param( 'after' ) );
165 + $date_to = sanitize_text_field( $request->get_param( 'before' ) );
166 +
167 + // Build query arguments.
168 + $args = [
169 + 'post_type' => SRFM_FORMS_POST_TYPE,
170 + 'post_status' => 'any' === $status ? [ 'publish', 'draft' ] : $status,
171 + 'posts_per_page' => $per_page,
172 + 'paged' => $page,
173 + 'orderby' => $orderby,
174 + 'order' => $order,
175 + ];
176 +
177 + // Add date range filtering.
178 + if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
179 + $date_query = [];
180 + // Handle 'after' date.
181 + if ( ! empty( $date_from ) ) {
182 + $date_query['after'] = $date_from;
183 + }
184 +
185 + // Handle 'before' date - add 1 day to include the full end date.
186 + if ( ! empty( $date_to ) ) {
187 + $end_date = new \DateTime( $date_to );
188 + $end_date->add( new \DateInterval( 'P1D' ) ); // Add 1 day.
189 + $date_query['before'] = $end_date->format( 'Y-m-d' );
190 + }
191 +
192 + $date_query['inclusive'] = true;
193 + $args['date_query'] = [ $date_query ];
194 + }
195 +
196 + // Add search parameter.
197 + if ( ! empty( $search ) ) {
198 + if ( is_numeric( $search ) ) {
199 + // Numeric search: match by form ID and title (e.g., "2024 Survey").
200 + $numeric_search = $search;
201 + $where_filter = static function ( $where, $query ) use ( $numeric_search ) {
202 + if ( ! $query->get( 'srfm_numeric_search' ) ) {
203 + return $where;
204 + }
205 + global $wpdb;
206 + $where .= $wpdb->prepare(
207 + " AND ({$wpdb->posts}.ID = %d OR {$wpdb->posts}.post_title LIKE %s)",
208 + absint( $numeric_search ),
209 + '%' . $wpdb->esc_like( $numeric_search ) . '%'
210 + );
211 + return $where;
212 + };
213 +
214 + add_filter( 'posts_where', $where_filter, 10, 2 );
215 + $args['srfm_numeric_search'] = true;
216 + } else {
217 + // Text search: match by title only.
218 + $args['s'] = $search;
219 + $args['search_columns'] = [ 'post_title' ];
220 + }
221 + }
222 +
223 + // Execute query — use try/finally to guarantee filter cleanup.
224 + try {
225 + // Derived metrics can't be sorted by WP_Query, so they take a
226 + // compute-sort-paginate pass instead. Only while tracking is on: with the
227 + // feature off the columns are hidden, and a stored or hand-crafted request
228 + // would run that expensive pass to order rows nobody can see. It also
229 + // returns null when the site has more forms than the pass will scan.
230 + $response_data = null;
231 + $is_metric_sort = in_array( $orderby, [ 'views', 'conversion_rate' ], true );
232 +
233 + if ( $is_metric_sort && Form_Views::get_instance()->is_tracking_enabled() ) {
234 + $response_data = $this->get_forms_sorted_by_metric( $args, $orderby, $order, $page, Helper::get_integer_value( $per_page ) );
235 + }
236 +
237 + // True only when the metric sort actually ran. It returns null and falls back
238 + // to date order when the feature is off or the site is past the sort ceiling;
239 + // the table needs to know so it does not leave an active sort arrow on a
240 + // column whose order was silently ignored.
241 + $metric_sort_applied = $is_metric_sort && null !== $response_data;
242 +
243 + if ( null === $response_data ) {
244 + if ( $is_metric_sort ) {
245 + // WP_Query would silently discard 'views'/'conversion_rate' and fall
246 + // back to post_date anyway. Say so explicitly so the behaviour is in
247 + // the code rather than in core's tolerance for unknown keys.
248 + $args['orderby'] = 'date';
249 + }
250 +
251 + $query = new \WP_Query( $args );
252 +
253 + $forms = [];
254 + /**
255 + * Post object from the query.
256 + *
257 + * @var \WP_Post $post */
258 + foreach ( $query->posts as $post ) {
259 + $forms[] = $this->prepare_form_for_listing( $post );
260 + }
261 +
262 + $response_data = [
263 + 'forms' => $forms,
264 + 'total' => Helper::get_integer_value( $query->found_posts ),
265 + 'total_pages' => Helper::get_integer_value( $query->max_num_pages ),
266 + 'current_page' => $page,
267 + 'per_page' => $per_page,
268 + ];
269 + }
270 + } finally {
271 + if ( ! empty( $search ) && is_numeric( $search ) && isset( $where_filter ) ) {
272 + remove_filter( 'posts_where', $where_filter, 10 );
273 + }
274 + }
275 +
276 + // Travels with the rows so the table gates its columns on the same evaluation
277 + // that produced them. The localized `srfm_admin` flag is only a page-load
278 + // snapshot: toggle the setting in another tab and the open list would keep
279 + // rendering columns while every row came back empty, which reads as data loss.
280 + $response_data['views_enabled'] = Form_Views::get_instance()->is_tracking_enabled();
281 +
282 + // Lets the table reset a stale metric sort arrow when the order silently fell
283 + // back to date (feature off, or past the metric-sort ceiling).
284 + $response_data['metric_sort_applied'] = $metric_sort_applied;
285 +
286 + return new WP_REST_Response( $response_data, 200 );
287 + }
288 +
289 + /**
290 + * Sort the forms list by a derived metric (views or conversion rate) and paginate.
291 + *
292 + * WP_Query can't order by views (missing-meta forms would drop out) or by the
293 + * derived conversion rate at all, so we fetch every matching form id, compute the
294 + * metric, sort in PHP, then slice the requested page. Form counts are small in
295 + * practice; revisit with a grouped query if a site accumulates thousands of forms.
296 + *
297 + * @param array<string,mixed> $args Base WP_Query args (filters/search), pagination ignored.
298 + * @param string $orderby Either 'views' or 'conversion_rate'.
299 + * @param string $order 'asc' or 'desc'.
300 + * @param int $page Current page (1-based).
301 + * @param int $per_page Items per page.
302 + * @since 2.12.6
303 + * @return array<string,mixed>|null Response payload, or null when the site has more
304 + * forms than this pass will scan and the caller
305 + * should fall back to ordinary ordering.
306 + */
307 + private function get_forms_sorted_by_metric( $args, $orderby, $order, $page, $per_page ) {
308 + $id_args = $args;
309 + $id_args['posts_per_page'] = -1;
310 + $id_args['paged'] = 1;
311 + $id_args['fields'] = 'ids';
312 + $id_args['orderby'] = 'ID';
313 + $id_args['order'] = 'DESC';
314 +
315 + $id_query = new \WP_Query( $id_args );
316 +
317 + /**
318 + * Largest number of forms this path will sort before giving up.
319 + *
320 + * The pass is one entry COUNT per form, so cost grows linearly with the form
321 + * count while only one page is ever displayed. Past this ceiling the request
322 + * returns null so the caller falls back to ordinary date ordering rather than
323 + * firing thousands of queries to render ten rows.
324 + *
325 + * @param int $limit Maximum forms to sort in PHP. Default 500.
326 + * @since 2.12.6
327 + */
328 + $limit = Helper::get_integer_value( apply_filters( 'srfm_forms_metric_sort_limit', 500 ) );
329 +
330 + // A filter is allowed to tighten or loosen the ceiling, not to remove it. Zero
331 + // or negative read as "no ceiling" to a `> $limit` test, which is the one
332 + // outcome the ceiling exists to prevent, so fall back to the default.
333 + $limit = $limit > 0 ? $limit : 500;
334 +
335 + if ( count( $id_query->posts ) > $limit ) {
336 + /**
337 + * Fires when the metric sort is skipped because the site has too many forms.
338 + *
339 + * Announced rather than skipped silently: a list that quietly ignores the
340 + * column the user clicked reads as a broken sort, not as a deliberate
341 + * ceiling. Gives a site owner something to hook if they hit it.
342 + *
343 + * @param string $orderby Requested metric, 'views' or 'conversion_rate'.
344 + * @param int $count Number of forms that would have been sorted.
345 + * @param int $limit The ceiling in force.
346 + * @since 2.12.6
347 + */
348 + do_action( 'srfm_forms_metric_sort_skipped', $orderby, count( $id_query->posts ), $limit );
349 +
350 + return null;
351 + }
352 +
353 + // `fields => ids` skips the post-meta cache priming that a normal WP_Query does,
354 + // so prime it once for all matched forms — otherwise each get_views() below is a
355 + // separate get_post_meta() query (N+1). Entry counts are still one COUNT per form;
356 + // acceptable for typical form volumes, revisit with a grouped query if needed.
357 + if ( ! empty( $id_query->posts ) ) {
358 + $prime_ids = array_map(
359 + static function ( $post ) {
360 + return (int) ( $post instanceof \WP_Post ? $post->ID : $post );
361 + },
362 + $id_query->posts
363 + );
364 + update_meta_cache( 'post', $prime_ids );
365 +
366 + // `fields => ids` skips the post cache, so the get_post() in the pagination
367 + // loop below would be one query per displayed row. Prime it here instead.
368 + // Terms and meta are handled separately, hence both flags false.
369 + _prime_post_caches( $prime_ids, false, false );
370 + }
371 +
372 + $rows = [];
373 + foreach ( $id_query->posts as $post_id ) {
374 + $form_id = Helper::get_integer_value( $post_id );
375 +
376 + // The form id is the whole input, here and on the render path, so the two
377 + // cannot be handed different arguments and compute different numbers.
378 + // They once could: this took a creation date and an all-time count as
379 + // well, the two callers passed them differently, and the column and its
380 + // sort order disagreed.
381 + $metrics = $this->calculate_form_metrics( $form_id );
382 +
383 + // Same helper the column renders from, so the order always matches the
384 + // numbers on screen. An unmeasurable rate sorts as -1 rather than 0, so
385 + // the dash rows group below a genuine 0% instead of tying with it.
386 + $metric = 'views' === $orderby
387 + ? (float) $metrics['views']
388 + : ( null === $metrics['conversion_rate'] ? -1.0 : (float) $metrics['conversion_rate'] );
389 +
390 + $rows[] = [
391 + 'id' => $form_id,
392 + 'metric' => $metric,
393 + ];
394 + }
395 +
396 + // Sort by metric, tie-break on id (desc) for a stable order.
397 + $direction = 'asc' === strtolower( $order ) ? 1 : -1;
398 + usort(
399 + $rows,
400 + static function ( $a, $b ) use ( $direction ) {
401 + if ( $a['metric'] === $b['metric'] ) {
402 + return $b['id'] <=> $a['id'];
403 + }
404 + return ( $a['metric'] <=> $b['metric'] ) * $direction;
405 + }
406 + );
407 +
408 + $total = count( $rows );
409 + $total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
410 + $offset = ( $page - 1 ) * $per_page;
411 + $page_rows = array_slice( $rows, max( 0, $offset ), $per_page );
412 +
413 + $forms = [];
414 + foreach ( $page_rows as $row ) {
415 + $post = get_post( $row['id'] );
416 + if ( $post instanceof \WP_Post ) {
417 + $forms[] = $this->prepare_form_for_listing( $post );
418 + }
419 + }
420 +
421 + return [
422 + 'forms' => $forms,
423 + 'total' => $total,
424 + 'total_pages' => $total_pages,
425 + 'current_page' => $page,
426 + 'per_page' => $per_page,
427 + ];
428 + }
429 +
430 + /**
431 + * The tracking-window boundary as a datetime string comparable to `created_at`.
432 + *
433 + * `created_at` is written by MySQL (`DEFAULT CURRENT_TIMESTAMP`) and compared in
434 + * the session time zone, not UTC, so a bare `gmdate()` of a PHP timestamp is off
435 + * by the MySQL/PHP clock offset — permanently mis-counting entries near the
436 + * boundary. Entries::get_entries_count_after() solves this by reading
437 + * `SELECT NOW()`, but it does so on every call, which would be one extra query
438 + * per row on a listing page. The offset cannot change within a request, so it is
439 + * resolved once and reused.
440 + *
441 + * @param int $window_start Unix timestamp.
442 + * @return string Datetime string in MySQL's frame of reference.
443 + * @since 2.12.6
444 + */
445 + private static function window_boundary_sql( $window_start ) {
446 + static $offset_seconds = null;
447 +
448 + if ( null === $offset_seconds ) {
449 + global $wpdb;
450 +
451 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reading the server clock; a cached value would defeat the purpose.
452 + $mysql_now = $wpdb->get_var( 'SELECT NOW()' );
453 + $mysql_ts = $mysql_now ? strtotime( (string) $mysql_now ) : false;
454 +
455 + // Fall back to no adjustment rather than a wild offset if NOW() is
456 + // unreadable or unparseable — a UTC-configured server needs none anyway.
457 + $offset_seconds = false === $mysql_ts ? 0 : $mysql_ts - time();
458 + }
459 +
460 + return gmdate( 'Y-m-d H:i:s', $window_start + $offset_seconds );
461 + }
462 +
463 + /**
464 + * Ids of the in-window submitters who can edit the site, cached for the request.
465 + *
466 + * Bounded by submitters, not by users. Asking the user table for everyone with
467 + * `edit_posts` runs an unindexable leading-wildcard scan of capability meta with
468 + * no LIMIT, and the answer is then interpolated into one windowed COUNT per form,
469 + * up to the ceiling in get_forms_sorted_by_metric(). On a membership site that
470 + * hands `contributor` to every member that is a five-figure placeholder list
471 + * rebuilt for every row of a ten-row page. Only people who actually submitted
472 + * inside the window can affect the rate, and that set is small.
473 + *
474 + * Served by `idx_user_id_created_at (user_id, created_at)`, added for this
475 + * lookup. `idx_user_id` alone cannot: `created_at` is not in it and no other
476 + * index leads on `created_at`, so before that index this was a range scan with
477 + * a row read per row plus a temp table for the DISTINCT -- on a site with years
478 + * of logged-in submissions, every entry ever recorded, to return a short list.
479 + * The LIMIT bounds what comes back, not what is read; the index bounds the
480 + * read.
481 + *
482 + * Decided with `user_can()`, the same call Form_Views::should_track() makes, so
483 + * both halves of the rate answer one question rather than two similar ones. A
484 + * capability granted at runtime through `user_has_cap`, a multisite super admin
485 + * who is not a member of the subsite, and a role carrying `edit_posts` as an
486 + * explicit denial all resolve the same way on both sides.
487 + *
488 + * Reflects capability as it stands now, not as it stood at submission time. On a
489 + * site that promotes or demotes people the two halves still drift: a promoted
490 + * subscriber's earlier views stay in the denominator while their earlier entries
491 + * leave the numerator, and a demoted editor's earlier entries return without
492 + * their views. Closing that means stamping the decision on the entry at submit
493 + * time, which is a schema change this percentage does not justify.
494 + *
495 + * Cached because the forms listing asks once per row, and the answer cannot
496 + * change within a request.
497 + *
498 + * @param int $window_start Unix timestamp the view window opened at.
499 + * @since 2.12.7
500 + * @return array<int,int>
501 + */
502 + private static function get_editing_submitter_ids( $window_start ) {
503 + $blog_id = get_current_blog_id();
504 +
505 + if ( null === self::$editing_user_ids ) {
506 + self::$editing_user_ids = [];
507 + }
508 +
509 + if ( isset( self::$editing_user_ids[ $blog_id ] ) ) {
510 + return self::$editing_user_ids[ $blog_id ];
511 + }
512 +
513 + /**
514 + * Largest number of distinct in-window submitters to test for edit access.
515 + *
516 + * The test is bounded work per submitter, so this is a guard against a site
517 + * where a very large share of submissions are made while logged in. Past the
518 + * ceiling the exclusion is skipped rather than truncated: a partial exclusion
519 + * list reports a rate that is wrong in a way nobody can see, where no
520 + * exclusion at least reproduces the pre-existing behaviour.
521 + *
522 + * Two things to know before raising or lowering it. The count is of
523 + * distinct submitters across all forms since tracking was first enabled,
524 + * and that window never resets -- so a membership site, a store or an LMS
525 + * reaches 500 in ordinary operation, and once passed it stays passed, with
526 + * the rate quietly counting editor submissions again. That is why the
527 + * settings copy says those are "normally" left out rather than promising it
528 + * outright. And the query does not filter on status, so a user whose only
529 + * in-window entries were trashed still lands on the list and consumes
530 + * budget -- harmless for the count, but it brings the ceiling closer.
531 + *
532 + * @param int $limit Maximum submitters to test. Default 500.
533 + * @since 2.12.7
534 + */
535 + $limit = Helper::get_integer_value( apply_filters( 'srfm_forms_metric_submitter_limit', 500 ) );
536 +
537 + // Same reasoning as the sort ceiling: a filter may move it, not remove it.
538 + $limit = $limit > 0 ? $limit : 500;
539 +
540 + // One row past the ceiling, so a full page is proof the ceiling was passed
541 + // without counting the rest of the table to find out.
542 + $rows = Entries::get_instance()->get_results(
543 + [
544 + [
545 + [
546 + 'key' => 'created_at',
547 + 'compare' => '>=',
548 + 'value' => self::window_boundary_sql( $window_start ),
549 + ],
550 + [
551 + 'key' => 'user_id',
552 + 'compare' => '>',
553 + 'value' => 0,
554 + ],
555 + ],
556 + ],
557 + 'DISTINCT user_id',
558 + [ sprintf( 'LIMIT %d', $limit + 1 ) ]
559 + );
560 +
561 + $submitters = array_values( array_unique( array_map( 'absint', array_column( $rows, 'user_id' ) ) ) );
562 +
563 + if ( count( $submitters ) > $limit ) {
564 + /**
565 + * Fires when the editor exclusion is skipped because too many submitters
566 + * would have to be tested.
567 + *
568 + * Announced rather than skipped silently, for the same reason as
569 + * `srfm_forms_metric_sort_skipped`: a rate that quietly stops excluding
570 + * editors reads as a wrong number, not as a deliberate ceiling.
571 + *
572 + * @param int $count Number of distinct submitters found, capped at $limit + 1.
573 + * @param int $limit The ceiling in force.
574 + * @since 2.12.7
575 + */
576 + do_action( 'srfm_forms_metric_submitter_limit_exceeded', count( $submitters ), $limit );
577 +
578 + self::$editing_user_ids[ $blog_id ] = [];
579 +
580 + return self::$editing_user_ids[ $blog_id ];
581 + }
582 +
583 + if ( [] !== $submitters ) {
584 + // Two queries for the whole set. Without it user_can() resolves each user
585 + // on its own and the loop becomes one query per submitter.
586 + cache_users( $submitters );
587 + }
588 +
589 + self::$editing_user_ids[ $blog_id ] = array_values(
590 + array_filter(
591 + $submitters,
592 + static function ( $user_id ) {
593 + return user_can( $user_id, 'edit_posts' );
594 + }
595 + )
596 + );
597 +
598 + return self::$editing_user_ids[ $blog_id ];
599 + }
600 +
601 + /**
602 + * Views and conversion rate for one form.
603 + *
604 + * The single source of truth for both the rendered value and the sorted metric.
605 + * They were computed separately at first, and drifted: the sort used all-time
606 + * entries while the column used entries from the tracking window, so a form
607 + * rendering a dash sorted as though its rate were several hundred percent.
608 + * Anything needing these numbers must come through here.
609 + *
610 + * Both halves apply the same test. Views are not counted for anyone who can
611 + * edit the site (Form_Views::should_track()), so their submissions must not be
612 + * counted either: testing your own form five times would otherwise add five to
613 + * the numerator and nothing to the denominator, and report a rate several times
614 + * the real one. The test is applied to capability as it stands now on both
615 + * sides, so a site that promotes or demotes people still sees some drift --
616 + * get_editing_submitter_ids() has the detail. The Entries column is unaffected
617 + * and stays a true all-time count of every entry received.
618 + *
619 + * Returns `null` for the rate rather than a number whenever it cannot be
620 + * measured: tracking off, window never opened, no views yet, or more entries
621 + * than views. That last case means the view count is incomplete, or that a
622 + * submitter was promoted after submitting, and any percentage would be invented;
623 + * the table renders the dash instead. `0.0` is reserved for a real measurement
624 + * of zero.
625 + *
626 + * @param int $form_id Form post ID.
627 + * @return array{views:int,conversion_rate:float|null}
628 + * @since 2.12.7 -- Signature reduced to $form_id.
629 + * @since 2.12.6
630 + */
631 + private function calculate_form_metrics( $form_id ) {
632 + $none = [
633 + 'views' => 0,
634 + 'conversion_rate' => null,
635 + ];
636 +
637 + $window_start = Form_Views::get_instance()->get_tracking_started_at();
638 +
639 + // A zero stamp means counting never started, so there is nothing to divide by
640 + // and no window to measure against. Guarded as well as the display toggle
641 + // because the two are written by different paths: the toggle could be forced
642 + // on by a direct option write that never ran maybe_start_tracking(), and
643 + // gmdate() on a zero timestamp would silently widen the window to 1970 and
644 + // count every entry the form has ever had.
645 + if ( ! Form_Views::get_instance()->is_tracking_enabled() || $window_start <= 0 ) {
646 + return $none;
647 + }
648 +
649 + $views = Form_Views::get_instance()->get_views( $form_id );
650 +
651 + if ( $views <= 0 ) {
652 + return $none;
653 + }
654 +
655 + // Compare like with like. The Entries column is all-time, but views only start
656 + // accruing when tracking opens, so the rate counts entries from that same
657 + // moment. Otherwise a form that existed beforehand divides years of entries
658 + // by days of views and reports a rate that is pure noise.
659 + $where = [
660 + [
661 + [
662 + 'key' => 'created_at',
663 + 'compare' => '>=',
664 + 'value' => self::window_boundary_sql( $window_start ),
665 + ],
666 + ],
667 + ];
668 +
669 + $editing_users = self::get_editing_submitter_ids( $window_start );
670 +
671 + if ( [] !== $editing_users ) {
672 + $where[] = [
673 + [
674 + 'key' => 'user_id',
675 + 'compare' => 'NOT IN',
676 + 'value' => $editing_users,
677 + ],
678 + ];
679 + }
680 +
681 + // Always counted, never taken from the caller's all-time total. That total
682 + // includes the entries this exclusion exists to drop, so reusing it for a
683 + // form created inside the window -- the newly built form an admin has just
684 + // been testing, which is exactly the case that skews -- would hand back the
685 + // unfiltered number and quietly undo the exclusion.
686 + $entries_since = Helper::get_integer_value(
687 + Entries::get_total_entries_by_status( 'all', $form_id, $where )
688 + );
689 +
690 + if ( $entries_since > $views ) {
691 + return [
692 + 'views' => $views,
693 + 'conversion_rate' => null,
694 + ];
695 + }
696 +
697 + return [
698 + 'views' => $views,
699 + 'conversion_rate' => round( $entries_since / $views * 100, 1 ),
700 + ];
701 + }
702 +
703 + /**
704 + * Prepare a single form for the listing response.
705 + *
706 + * @param \WP_Post $post Post object.
707 + * @return array<mixed> Prepared form data for listing.
708 + * @since 2.0.0
709 + */
710 + private function prepare_form_for_listing( $post ) {
711 + $form_id = $post->ID;
712 +
713 + // Get entries count.
714 + $entries_count = Helper::get_integer_value( Entries::get_total_entries_by_status( 'all', $form_id ) );
715 +
716 + // Views and conversion rate come from the same helper the sort path uses, so the
717 + // column can never order by a different number than it displays.
718 + $metrics = $this->calculate_form_metrics( $form_id );
719 + $views = $metrics['views'];
720 + $conversion_rate = $metrics['conversion_rate'];
721 +
722 + return [
723 + 'id' => $form_id,
724 + 'title' => $post->post_title,
725 + 'status' => $post->post_status,
726 + 'date_created' => mysql_to_rfc3339( $post->post_date ),
727 + 'date_modified' => mysql_to_rfc3339( $post->post_modified ),
728 + 'entries_count' => $entries_count,
729 + 'views' => $views,
730 + 'conversion_rate' => $conversion_rate,
731 + 'shortcode' => "[sureforms id='{$form_id}']",
732 + 'edit_url' => admin_url( "post.php?post={$form_id}&action=edit" ),
733 + 'frontend_url' => get_permalink( $form_id ),
734 + ];
114 735 }
115 736 }