PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 1.7.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v1.7.1
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 0.0.3 All 96 releases
← All changes | inc/forms-data.php +1 -455 trunk1.7.1 View file →
@@ -7,9 +7,8 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 -use SRFM\Inc\Database\Tables\Entries;
12 11 use SRFM\Inc\Traits\Get_Instance;
13 12 use WP_Error;
14 13 use WP_REST_Response;
15 14
@@ -58,9 +57,9 @@
58 57 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
59 58 * @since 0.0.1
60 59 */
61 60 public function get_form_permissions_check() {
62 - if ( Helper::current_user_can( 'edit_posts' ) ) {
61 + if ( current_user_can( 'edit_posts' ) ) {
63 62 return true;
64 63 }
65 64
66 65 return new \WP_Error(
@@ -109,459 +108,6 @@
109 108 ];
110 109 }
111 110
112 111 return new WP_REST_Response( $data );
113 - }
114 -
115 - /**
116 - * Get forms list for the forms listing page.
117 - *
118 - * @param \WP_REST_Request $request Full details about the request.
119 - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
120 - * @since 2.0.0
121 - */
122 - public function get_forms_list( $request ) {
123 - $nonce = sanitize_text_field( Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) ) );
124 -
125 - Helper::verify_nonce_and_capabilities( 'rest', $nonce, 'wp_rest' );
126 -
127 - // Get and validate request parameters.
128 - $page = max( 1, Helper::get_integer_value( $request->get_param( 'page' ) ) );
129 - $status = sanitize_text_field( $request->get_param( 'status' ) );
130 -
131 - // Get per_page from option first, then request parameter, with fallback to 10.
132 - $saved_per_page = Helper::get_srfm_option( 'forms_per_page', 10 );
133 - $request_per_page = $request->get_param( 'per_page' );
134 - $per_page = $request_per_page ? min( 100, max( 1, Helper::get_integer_value( $request_per_page ) ) ) : $saved_per_page;
135 -
136 - // Save per_page to option if it came from request.
137 - if ( $request_per_page && 'trash' !== $status && 1 < $request_per_page ) {
138 - Helper::update_srfm_option( 'forms_per_page', $per_page );
139 - }
140 -
141 - $search = sanitize_text_field( $request->get_param( 'search' ) );
142 - $orderby = sanitize_text_field( $request->get_param( 'orderby' ) );
143 - $order = sanitize_text_field( $request->get_param( 'order' ) );
144 - $date_from = sanitize_text_field( $request->get_param( 'after' ) );
145 - $date_to = sanitize_text_field( $request->get_param( 'before' ) );
146 -
147 - // Build query arguments.
148 - $args = [
149 - 'post_type' => SRFM_FORMS_POST_TYPE,
150 - 'post_status' => 'any' === $status ? [ 'publish', 'draft' ] : $status,
151 - 'posts_per_page' => $per_page,
152 - 'paged' => $page,
153 - 'orderby' => $orderby,
154 - 'order' => $order,
155 - ];
156 -
157 - // Add date range filtering.
158 - if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
159 - $date_query = [];
160 - // Handle 'after' date.
161 - if ( ! empty( $date_from ) ) {
162 - $date_query['after'] = $date_from;
163 - }
164 -
165 - // Handle 'before' date - add 1 day to include the full end date.
166 - if ( ! empty( $date_to ) ) {
167 - $end_date = new \DateTime( $date_to );
168 - $end_date->add( new \DateInterval( 'P1D' ) ); // Add 1 day.
169 - $date_query['before'] = $end_date->format( 'Y-m-d' );
170 - }
171 -
172 - $date_query['inclusive'] = true;
173 - $args['date_query'] = [ $date_query ];
174 - }
175 -
176 - // Add search parameter.
177 - if ( ! empty( $search ) ) {
178 - if ( is_numeric( $search ) ) {
179 - // Numeric search: match by form ID and title (e.g., "2024 Survey").
180 - $numeric_search = $search;
181 - $where_filter = static function ( $where, $query ) use ( $numeric_search ) {
182 - if ( ! $query->get( 'srfm_numeric_search' ) ) {
183 - return $where;
184 - }
185 - global $wpdb;
186 - $where .= $wpdb->prepare(
187 - " AND ({$wpdb->posts}.ID = %d OR {$wpdb->posts}.post_title LIKE %s)",
188 - absint( $numeric_search ),
189 - '%' . $wpdb->esc_like( $numeric_search ) . '%'
190 - );
191 - return $where;
192 - };
193 -
194 - add_filter( 'posts_where', $where_filter, 10, 2 );
195 - $args['srfm_numeric_search'] = true;
196 - } else {
197 - // Text search: match by title only.
198 - $args['s'] = $search;
199 - $args['search_columns'] = [ 'post_title' ];
200 - }
201 - }
202 -
203 - // Execute query — use try/finally to guarantee filter cleanup.
204 - try {
205 - // Derived metrics can't be sorted by WP_Query, so they take a
206 - // compute-sort-paginate pass instead. Only while tracking is on: with the
207 - // feature off the columns are hidden, and a stored or hand-crafted request
208 - // would run that expensive pass to order rows nobody can see. It also
209 - // returns null when the site has more forms than the pass will scan.
210 - $response_data = null;
211 - $is_metric_sort = in_array( $orderby, [ 'views', 'conversion_rate' ], true );
212 -
213 - if ( $is_metric_sort && Form_Views::get_instance()->is_tracking_enabled() ) {
214 - $response_data = $this->get_forms_sorted_by_metric( $args, $orderby, $order, $page, Helper::get_integer_value( $per_page ) );
215 - }
216 -
217 - // True only when the metric sort actually ran. It returns null and falls back
218 - // to date order when the feature is off or the site is past the sort ceiling;
219 - // the table needs to know so it does not leave an active sort arrow on a
220 - // column whose order was silently ignored.
221 - $metric_sort_applied = $is_metric_sort && null !== $response_data;
222 -
223 - if ( null === $response_data ) {
224 - if ( $is_metric_sort ) {
225 - // WP_Query would silently discard 'views'/'conversion_rate' and fall
226 - // back to post_date anyway. Say so explicitly so the behaviour is in
227 - // the code rather than in core's tolerance for unknown keys.
228 - $args['orderby'] = 'date';
229 - }
230 -
231 - $query = new \WP_Query( $args );
232 -
233 - $forms = [];
234 - /**
235 - * Post object from the query.
236 - *
237 - * @var \WP_Post $post */
238 - foreach ( $query->posts as $post ) {
239 - $forms[] = $this->prepare_form_for_listing( $post );
240 - }
241 -
242 - $response_data = [
243 - 'forms' => $forms,
244 - 'total' => Helper::get_integer_value( $query->found_posts ),
245 - 'total_pages' => Helper::get_integer_value( $query->max_num_pages ),
246 - 'current_page' => $page,
247 - 'per_page' => $per_page,
248 - ];
249 - }
250 - } finally {
251 - if ( ! empty( $search ) && is_numeric( $search ) && isset( $where_filter ) ) {
252 - remove_filter( 'posts_where', $where_filter, 10 );
253 - }
254 - }
255 -
256 - // Travels with the rows so the table gates its columns on the same evaluation
257 - // that produced them. The localized `srfm_admin` flag is only a page-load
258 - // snapshot: toggle the setting in another tab and the open list would keep
259 - // rendering columns while every row came back empty, which reads as data loss.
260 - $response_data['views_enabled'] = Form_Views::get_instance()->is_tracking_enabled();
261 -
262 - // Lets the table reset a stale metric sort arrow when the order silently fell
263 - // back to date (feature off, or past the metric-sort ceiling).
264 - $response_data['metric_sort_applied'] = $metric_sort_applied;
265 -
266 - return new WP_REST_Response( $response_data, 200 );
267 - }
268 -
269 - /**
270 - * Sort the forms list by a derived metric (views or conversion rate) and paginate.
271 - *
272 - * WP_Query can't order by views (missing-meta forms would drop out) or by the
273 - * derived conversion rate at all, so we fetch every matching form id, compute the
274 - * metric, sort in PHP, then slice the requested page. Form counts are small in
275 - * practice; revisit with a grouped query if a site accumulates thousands of forms.
276 - *
277 - * @param array<string,mixed> $args Base WP_Query args (filters/search), pagination ignored.
278 - * @param string $orderby Either 'views' or 'conversion_rate'.
279 - * @param string $order 'asc' or 'desc'.
280 - * @param int $page Current page (1-based).
281 - * @param int $per_page Items per page.
282 - * @since 2.12.6
283 - * @return array<string,mixed>|null Response payload, or null when the site has more
284 - * forms than this pass will scan and the caller
285 - * should fall back to ordinary ordering.
286 - */
287 - private function get_forms_sorted_by_metric( $args, $orderby, $order, $page, $per_page ) {
288 - $id_args = $args;
289 - $id_args['posts_per_page'] = -1;
290 - $id_args['paged'] = 1;
291 - $id_args['fields'] = 'ids';
292 - $id_args['orderby'] = 'ID';
293 - $id_args['order'] = 'DESC';
294 -
295 - $id_query = new \WP_Query( $id_args );
296 -
297 - /**
298 - * Largest number of forms this path will sort before giving up.
299 - *
300 - * The pass is one entry COUNT per form, so cost grows linearly with the form
301 - * count while only one page is ever displayed. Past this ceiling the request
302 - * returns null so the caller falls back to ordinary date ordering rather than
303 - * firing thousands of queries to render ten rows.
304 - *
305 - * @param int $limit Maximum forms to sort in PHP. Default 500.
306 - * @since 2.12.6
307 - */
308 - $limit = Helper::get_integer_value( apply_filters( 'srfm_forms_metric_sort_limit', 500 ) );
309 -
310 - if ( $limit > 0 && count( $id_query->posts ) > $limit ) {
311 - /**
312 - * Fires when the metric sort is skipped because the site has too many forms.
313 - *
314 - * Announced rather than skipped silently: a list that quietly ignores the
315 - * column the user clicked reads as a broken sort, not as a deliberate
316 - * ceiling. Gives a site owner something to hook if they hit it.
317 - *
318 - * @param string $orderby Requested metric, 'views' or 'conversion_rate'.
319 - * @param int $count Number of forms that would have been sorted.
320 - * @param int $limit The ceiling in force.
321 - * @since 2.12.6
322 - */
323 - do_action( 'srfm_forms_metric_sort_skipped', $orderby, count( $id_query->posts ), $limit );
324 -
325 - return null;
326 - }
327 -
328 - // `fields => ids` skips the post-meta cache priming that a normal WP_Query does,
329 - // so prime it once for all matched forms — otherwise each get_views() below is a
330 - // separate get_post_meta() query (N+1). Entry counts are still one COUNT per form;
331 - // acceptable for typical form volumes, revisit with a grouped query if needed.
332 - if ( ! empty( $id_query->posts ) ) {
333 - $prime_ids = array_map(
334 - static function ( $post ) {
335 - return (int) ( $post instanceof \WP_Post ? $post->ID : $post );
336 - },
337 - $id_query->posts
338 - );
339 - update_meta_cache( 'post', $prime_ids );
340 -
341 - // `fields => ids` skips the post cache, so the get_post() in the pagination
342 - // loop below would be one query per displayed row. Prime it here instead.
343 - // Terms and meta are handled separately, hence both flags false.
344 - _prime_post_caches( $prime_ids, false, false );
345 - }
346 -
347 - $rows = [];
348 - foreach ( $id_query->posts as $post_id ) {
349 - $form_id = Helper::get_integer_value( $post_id );
350 -
351 - // Same three arguments the render path passes. Calling this with only the
352 - // form ID left $post_date_gmt empty, so strtotime() returned false, the
353 - // "form is younger than the window" shortcut could never be taken, and the
354 - // windowed COUNT ran for every row — both a second query per form and, for
355 - // any entry whose created_at predates the form's post_date (an import, a
356 - // migration, a restored backup), a different number than the column shows.
357 - // The comment below promises order and display can never disagree; passing
358 - // different arguments here is what made them disagree.
359 - $post = get_post( $form_id );
360 - $metrics = $this->calculate_form_metrics(
361 - $form_id,
362 - $post->post_date_gmt ?? '',
363 - Helper::get_integer_value( Entries::get_total_entries_by_status( 'all', $form_id ) )
364 - );
365 -
366 - // Same helper the column renders from, so the order always matches the
367 - // numbers on screen. An unmeasurable rate sorts as -1 rather than 0, so
368 - // the dash rows group below a genuine 0% instead of tying with it.
369 - $metric = 'views' === $orderby
370 - ? (float) $metrics['views']
371 - : ( null === $metrics['conversion_rate'] ? -1.0 : (float) $metrics['conversion_rate'] );
372 -
373 - $rows[] = [
374 - 'id' => $form_id,
375 - 'metric' => $metric,
376 - ];
377 - }
378 -
379 - // Sort by metric, tie-break on id (desc) for a stable order.
380 - $direction = 'asc' === strtolower( $order ) ? 1 : -1;
381 - usort(
382 - $rows,
383 - static function ( $a, $b ) use ( $direction ) {
384 - if ( $a['metric'] === $b['metric'] ) {
385 - return $b['id'] <=> $a['id'];
386 - }
387 - return ( $a['metric'] <=> $b['metric'] ) * $direction;
388 - }
389 - );
390 -
391 - $total = count( $rows );
392 - $total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
393 - $offset = ( $page - 1 ) * $per_page;
394 - $page_rows = array_slice( $rows, max( 0, $offset ), $per_page );
395 -
396 - $forms = [];
397 - foreach ( $page_rows as $row ) {
398 - $post = get_post( $row['id'] );
399 - if ( $post instanceof \WP_Post ) {
400 - $forms[] = $this->prepare_form_for_listing( $post );
401 - }
402 - }
403 -
404 - return [
405 - 'forms' => $forms,
406 - 'total' => $total,
407 - 'total_pages' => $total_pages,
408 - 'current_page' => $page,
409 - 'per_page' => $per_page,
410 - ];
411 - }
412 -
413 - /**
414 - * The tracking-window boundary as a datetime string comparable to `created_at`.
415 - *
416 - * `created_at` is written by MySQL (`DEFAULT CURRENT_TIMESTAMP`) and compared in
417 - * the session time zone, not UTC, so a bare `gmdate()` of a PHP timestamp is off
418 - * by the MySQL/PHP clock offset — permanently mis-counting entries near the
419 - * boundary. Entries::get_entries_count_after() solves this by reading
420 - * `SELECT NOW()`, but it does so on every call, which would be one extra query
421 - * per row on a listing page. The offset cannot change within a request, so it is
422 - * resolved once and reused.
423 - *
424 - * @param int $window_start Unix timestamp.
425 - * @return string Datetime string in MySQL's frame of reference.
426 - * @since 2.12.6
427 - */
428 - private static function window_boundary_sql( $window_start ) {
429 - static $offset_seconds = null;
430 -
431 - if ( null === $offset_seconds ) {
432 - global $wpdb;
433 -
434 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reading the server clock; a cached value would defeat the purpose.
435 - $mysql_now = $wpdb->get_var( 'SELECT NOW()' );
436 - $mysql_ts = $mysql_now ? strtotime( (string) $mysql_now ) : false;
437 -
438 - // Fall back to no adjustment rather than a wild offset if NOW() is
439 - // unreadable or unparseable — a UTC-configured server needs none anyway.
440 - $offset_seconds = false === $mysql_ts ? 0 : $mysql_ts - time();
441 - }
442 -
443 - return gmdate( 'Y-m-d H:i:s', $window_start + $offset_seconds );
444 - }
445 -
446 - /**
447 - * Views and conversion rate for one form.
448 - *
449 - * The single source of truth for both the rendered value and the sorted metric.
450 - * They were computed separately at first, and drifted: the sort used all-time
451 - * entries while the column used entries from the tracking window, so a form
452 - * rendering a dash sorted as though its rate were several hundred percent.
453 - * Anything needing these numbers must come through here.
454 - *
455 - * Returns `null` for the rate rather than a number whenever it cannot be
456 - * measured — tracking off, window never opened, no views yet, or more entries
457 - * than views. That last case is not possible in reality (every entry needs a
458 - * view first), so it means the view count is incomplete and any percentage
459 - * would be invented; the table renders the dash instead. `0.0` is reserved for
460 - * a real measurement of zero.
461 - *
462 - * @param int $form_id Form post ID.
463 - * @param string $post_date_gmt Form creation date, GMT. Used to skip a redundant count.
464 - * @param int $entries_all_time All-time entry count, when the caller already has it.
465 - * @return array{views:int,conversion_rate:float|null}
466 - * @since 2.12.6
467 - */
468 - private function calculate_form_metrics( $form_id, $post_date_gmt = '', $entries_all_time = null ) {
469 - $none = [
470 - 'views' => 0,
471 - 'conversion_rate' => null,
472 - ];
473 -
474 - $window_start = Form_Views::get_instance()->get_tracking_started_at();
475 -
476 - // A zero stamp means counting never started, so there is nothing to divide by
477 - // and no window to measure against. Guarded as well as the display toggle
478 - // because the two are written by different paths: the toggle could be forced
479 - // on by a direct option write that never ran maybe_start_tracking(), and
480 - // gmdate() on a zero timestamp would silently widen the window to 1970 and
481 - // count every entry the form has ever had.
482 - if ( ! Form_Views::get_instance()->is_tracking_enabled() || $window_start <= 0 ) {
483 - return $none;
484 - }
485 -
486 - $views = Form_Views::get_instance()->get_views( $form_id );
487 -
488 - if ( $views <= 0 ) {
489 - return $none;
490 - }
491 -
492 - // Compare like with like. The Entries column is all-time, but views only start
493 - // accruing when tracking opens, so the rate counts entries from that same
494 - // moment — otherwise a form that existed beforehand divides years of entries by
495 - // days of views and reports a rate that is pure noise.
496 - $form_created = strtotime( (string) $post_date_gmt );
497 -
498 - if ( null !== $entries_all_time && $form_created && $form_created >= $window_start ) {
499 - // The form is younger than the window, so every entry it has is already
500 - // inside the window and the caller's all-time count is the same number.
501 - // Skips a second COUNT per row on the listing.
502 - $entries_since = Helper::get_integer_value( $entries_all_time );
503 - } else {
504 - $entries_since = Helper::get_integer_value(
505 - Entries::get_total_entries_by_status(
506 - 'all',
507 - $form_id,
508 - [
509 - [
510 - [
511 - 'key' => 'created_at',
512 - 'compare' => '>=',
513 - 'value' => self::window_boundary_sql( $window_start ),
514 - ],
515 - ],
516 - ]
517 - )
518 - );
519 - }
520 -
521 - if ( $entries_since > $views ) {
522 - return [
523 - 'views' => $views,
524 - 'conversion_rate' => null,
525 - ];
526 - }
527 -
528 - return [
529 - 'views' => $views,
530 - 'conversion_rate' => round( $entries_since / $views * 100, 1 ),
531 - ];
532 - }
533 -
534 - /**
535 - * Prepare a single form for the listing response.
536 - *
537 - * @param \WP_Post $post Post object.
538 - * @return array<mixed> Prepared form data for listing.
539 - * @since 2.0.0
540 - */
541 - private function prepare_form_for_listing( $post ) {
542 - $form_id = $post->ID;
543 -
544 - // Get entries count.
545 - $entries_count = Helper::get_integer_value( Entries::get_total_entries_by_status( 'all', $form_id ) );
546 -
547 - // Views and conversion rate come from the same helper the sort path uses, so the
548 - // column can never order by a different number than it displays.
549 - $metrics = $this->calculate_form_metrics( $form_id, $post->post_date_gmt, $entries_count );
550 - $views = $metrics['views'];
551 - $conversion_rate = $metrics['conversion_rate'];
552 -
553 - return [
554 - 'id' => $form_id,
555 - 'title' => $post->post_title,
556 - 'status' => $post->post_status,
557 - 'date_created' => mysql_to_rfc3339( $post->post_date ),
558 - 'date_modified' => mysql_to_rfc3339( $post->post_modified ),
559 - 'entries_count' => $entries_count,
560 - 'views' => $views,
561 - 'conversion_rate' => $conversion_rate,
562 - 'shortcode' => "[sureforms id='{$form_id}']",
563 - 'edit_url' => admin_url( "post.php?post={$form_id}&action=edit" ),
564 - 'frontend_url' => get_permalink( $form_id ),
565 - ];
566 112 }
567 113 }