PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / classes / helpers / FrmFormEmbedsHelper.php

FrmFormEmbedsHelper.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More trunk, at classes/helpers/FrmFormEmbedsHelper.php

719 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 die( 'You are not allowed to call this page directly.' );
4 }
5
6 /**
7 * Finds and caches the posts that embed a form, for the Embeds column on the forms list.
8 *
9 * Locating an embed means searching post_content for a shortcode that can sit anywhere in the
10 * content, so the LIKE patterns are unanchored and no index applies. Two things keep that
11 * affordable: one query serves every form on a list page, and the cache is only invalidated
12 * when a save actually changes which forms a post embeds.
13 *
14 * @since 6.35
15 */
16 class FrmFormEmbedsHelper {
17
18 /**
19 * The transient name that stores data for which posts a form is embedded in.
20 *
21 * @since 6.35
22 *
23 * @var string
24 */
25 const TRANSIENT_NAME = 'frm_posts_contain_form';
26
27 /**
28 * Embed posts keyed by form ID, read from the transient once per request.
29 *
30 * @since 6.35
31 *
32 * @var array|null
33 */
34 private static $cached_posts;
35
36 /**
37 * Every post that embeds any Formidable form, queried once per request.
38 *
39 * @since 6.35
40 *
41 * @var array|null
42 */
43 private static $candidate_posts;
44
45 /**
46 * Every post ID that appears in the cache, flattened once per request.
47 *
48 * @since 6.35
49 *
50 * @var array|null
51 */
52 private static $cached_post_ids;
53
54 /**
55 * Expanded affected form IDs, keyed by the embedded form IDs they came from.
56 *
57 * @since 6.35
58 *
59 * @var array<string,array<int>>
60 */
61 private static $affected_form_ids = array();
62
63 /**
64 * Reads the embed posts cache, hitting the transient only once per request.
65 *
66 * @since 6.35
67 *
68 * @return array
69 */
70 public static function get_cached_posts() {
71 if ( null === self::$cached_posts ) {
72 $cached_posts = get_transient( self::TRANSIENT_NAME );
73 self::$cached_posts = is_array( $cached_posts ) ? $cached_posts : array();
74 }
75
76 return self::$cached_posts;
77 }
78
79 /**
80 * Saves the embed posts cache.
81 *
82 * @since 6.35
83 *
84 * @param array $cached_posts Embed posts keyed by form ID.
85 *
86 * @return void
87 */
88 public static function save_cached_posts( $cached_posts ) {
89 self::$cached_posts = $cached_posts;
90 self::$cached_post_ids = null;
91 set_transient( self::TRANSIENT_NAME, $cached_posts, DAY_IN_SECONDS );
92 }
93
94 /**
95 * Matches the candidate posts against the search strings of several forms at once.
96 *
97 * @since 6.35
98 *
99 * @param array $search_map Search strings keyed by form ID.
100 *
101 * @return array Posts keyed by form ID.
102 */
103 public static function match_candidate_posts( $search_map ) {
104 $matched = array_fill_keys( array_keys( $search_map ), array() );
105
106 foreach ( self::get_candidate_posts() as $candidate ) {
107 foreach ( $search_map as $form_id => $search_strings ) {
108 foreach ( $search_strings as $search_string ) {
109 if ( ! str_contains( $candidate->post_content, $search_string ) ) {
110 continue;
111 }
112
113 $matched[ $form_id ][] = (object) array(
114 'ID' => $candidate->ID,
115 'post_title' => $candidate->post_title,
116 'post_name' => $candidate->post_name,
117 );
118 break;
119 }
120 }
121 }
122
123 return $matched;
124 }
125
126 /**
127 * Reduces posts to the fields the Embeds column actually renders, before they are cached.
128 *
129 * The frm_get_posts_contain_form filter is free to return whole WP_Post objects, and the
130 * Landing Pages add-on does. Caching those stores post_content and every other column, and
131 * because the column JSON encodes this straight into a data-posts attribute it also puts
132 * full post content, drafts included, into the admin page markup.
133 *
134 * @since 6.35
135 *
136 * @param array $posts Posts that embed a form.
137 *
138 * @return \stdClass[]
139 */
140 public static function slim_posts( $posts ) {
141 /**
142 * Filters the post fields kept in the embeds cache.
143 *
144 * Anything rendered by the Embeds dropdown has to be listed here to survive caching.
145 *
146 * @since 6.35
147 *
148 * @param string[] $fields Property names to keep.
149 */
150 $fields = apply_filters(
151 'frm_embed_post_cached_fields',
152 array( 'ID', 'post_title', 'post_name', 'title_contains_html', 'permalink', 'edit_link' )
153 );
154
155 $slim = array();
156
157 foreach ( $posts as $post ) {
158 if ( ! is_object( $post ) || ! isset( $post->ID ) ) {
159 continue;
160 }
161
162 $kept = array();
163
164 foreach ( $fields as $field ) {
165 if ( property_exists( $post, $field ) ) {
166 $kept[ $field ] = $post->$field;
167 }
168 }
169
170 $slim[] = (object) $kept;
171 }
172
173 return $slim;
174 }
175
176 /**
177 * Adds the links and fallback titles the Embeds dropdown expects.
178 *
179 * @since 6.35
180 *
181 * @param array $posts Posts that embed a form.
182 *
183 * @return array
184 */
185 public static function prepare_posts( $posts ) {
186 $prepared = array();
187
188 foreach ( $posts as $post ) {
189 // Copied so the derived values never end up back in the cached original.
190 $display = clone $post;
191
192 if ( ! property_exists( $display, 'permalink' ) ) {
193 $display->permalink = get_permalink( $display->ID );
194 }
195
196 if ( ! property_exists( $display, 'edit_link' ) ) {
197 $display->edit_link = get_edit_post_link( $display->ID );
198 }
199
200 // Ensure post_name is not null or the string "null"
201 if ( ! isset( $display->post_name ) ) {
202 $display->post_name = '';
203 }
204
205 // Ensure post_title is not null or the string "null"
206 if ( ! isset( $display->post_title ) ) {
207 $display->post_title = '';
208 }
209
210 if ( '' === $display->post_title ) {
211 $display->post_title = __( '(no title)', 'formidable' );
212 }
213
214 $prepared[] = $display;
215 }//end foreach
216
217 return $prepared;
218 }
219
220 /**
221 * Gets the substrings that mark a post as embedding some Formidable form.
222 *
223 * Every string returned by FrmFormsListHelper::get_search_strings_for_form() has to contain
224 * at least one of these, because they are what narrows wp_posts to a candidate set in one
225 * query rather than one query per form.
226 *
227 * @since 6.35
228 *
229 * @return string[]
230 */
231 private static function get_needles() {
232 /**
233 * @since 6.35
234 *
235 * @param string[] $needles
236 */
237 $needles = apply_filters(
238 'frm_embed_post_needles',
239 array(
240 '[formidable ',
241 'wp:formidable/simple-form',
242 )
243 );
244
245 $strings = array();
246
247 foreach ( (array) $needles as $needle ) {
248 if ( is_string( $needle ) && '' !== $needle ) {
249 $strings[] = $needle;
250 }
251 }
252
253 return $strings;
254 }
255
256 /**
257 * Queries once for every post or page that embeds any Formidable form.
258 *
259 * @since 6.35
260 *
261 * @return array
262 */
263 private static function get_candidate_posts() {
264 if ( null !== self::$candidate_posts ) {
265 return self::$candidate_posts;
266 }
267
268 global $wpdb;
269
270 $needles = self::get_needles();
271
272 if ( ! $needles ) {
273 self::$candidate_posts = array();
274 return self::$candidate_posts;
275 }
276
277 $like_where = implode( ' OR ', array_fill( 0, count( $needles ), 'post_content LIKE %s' ) );
278 $args = array( $wpdb->posts, 'post', 'page', 'auto-draft', 'trash' );
279
280 foreach ( $needles as $needle ) {
281 $args[] = '%' . $wpdb->esc_like( $needle ) . '%';
282 }
283
284 $sql = 'SELECT ID, post_title, post_name, post_content FROM %i'
285 . ' WHERE post_type IN ( %s, %s )'
286 . ' AND post_status NOT IN ( %s, %s )'
287 . ' AND ( ' . $like_where . ' )';
288
289 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $like_where is built from a placeholder count, not from input.
290 $posts = $wpdb->get_results( $wpdb->prepare( $sql, $args ) );
291
292 self::$candidate_posts = is_array( $posts ) ? $posts : array();
293
294 return self::$candidate_posts;
295 }
296
297 /**
298 * Maybe clear the cache when a post is inserted.
299 *
300 * Updates go through maybe_clear_on_update(), which can compare the content before and
301 * after the change.
302 *
303 * @since 6.35
304 *
305 * @param int $post_id Post ID.
306 * @param WP_Post $post Post object.
307 * @param bool $update True when an existing post was updated rather than created.
308 *
309 * @return void
310 */
311 public static function maybe_clear_on_insert( $post_id, $post, $update = false ) {
312 if ( $update || ! self::post_can_embed_form( $post ) ) {
313 return;
314 }
315
316 // A post that has only just been created cannot be in the cache yet, so its own content
317 // is the only thing worth checking. Anything heavier here gets paid once per row by a
318 // bulk insert or an import.
319 if ( ! self::content_has_embed( $post->post_content ) ) {
320 return;
321 }
322
323 self::clear_for_forms( self::get_affected_form_ids( self::get_embedded_form_ids( $post->post_content ) ) );
324 }
325
326 /**
327 * Maybe clear the cache when a post is updated.
328 *
329 * @since 6.35
330 *
331 * @param int $post_id Post ID.
332 * @param WP_Post $post_after Post object after the update.
333 * @param WP_Post $post_before Post object before the update.
334 *
335 * @return void
336 */
337 public static function maybe_clear_on_update( $post_id, $post_after, $post_before ) {
338 if ( ! self::post_can_embed_form( $post_after ) && ! self::post_can_embed_form( $post_before ) ) {
339 return;
340 }
341
342 $before_ids = self::get_embedded_form_ids( $post_before->post_content );
343 $after_ids = self::get_embedded_form_ids( $post_after->post_content );
344 $status_changed = $post_before->post_status !== $post_after->post_status;
345 $embeds_changed = $before_ids !== $after_ids;
346
347 // The dropdown lists the title and slug, so renaming a post makes the cached copy wrong.
348 $label_changed = $post_before->post_title !== $post_after->post_title || $post_before->post_name !== $post_after->post_name;
349
350 $form_ids = array();
351
352 if ( $embeds_changed || $status_changed ) {
353 // The forms this post embeds changed, or a status change moved the post in or out
354 // of the embeds query.
355 $form_ids = self::get_affected_form_ids( array_merge( $before_ids, $after_ids ) );
356 }
357
358 $filter_added = ! $before_ids && ! $after_ids && $post_before->post_content !== $post_after->post_content;
359
360 if ( $label_changed || $status_changed || $filter_added ) {
361 // A post the frm_get_posts_contain_form filter added is listed even though its own
362 // content does not say so, so fall back to whichever rows actually list it.
363 $form_ids = array_merge( $form_ids, self::get_cached_form_ids_for_post( $post_id ) );
364 }
365
366 self::clear_for_forms( $form_ids );
367 }
368
369 /**
370 * Maybe clear the cache when a post is trashed, untrashed or deleted.
371 *
372 * @since 6.35
373 *
374 * @param int $post_id Post ID.
375 * @param WP_Post|null $post Post object, when the hook provides one.
376 *
377 * @return void
378 */
379 public static function maybe_clear_for_post( $post_id, $post = null ) {
380 if ( ! is_object( $post ) ) {
381 $post = get_post( $post_id );
382 }
383
384 if ( ! is_object( $post ) || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
385 return;
386 }
387
388 $form_ids = self::get_affected_form_ids( self::get_embedded_form_ids( $post->post_content ) );
389
390 self::clear_for_forms( array_merge( $form_ids, self::get_cached_form_ids_for_post( $post_id ) ) );
391 }
392
393 /**
394 * Checks if a post is one the embeds query would look at.
395 *
396 * Revisions, autosaves and auto-drafts all fire wp_insert_post, and an active site creates
397 * them constantly. Letting those clear the cache would keep it permanently cold.
398 *
399 * @since 6.35
400 *
401 * @param WP_Post $post Post object.
402 *
403 * @return bool
404 */
405 private static function post_can_embed_form( $post ) {
406 if ( ! $post instanceof WP_Post ) {
407 return false;
408 }
409
410 if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
411 return false;
412 }
413
414 return 'auto-draft' !== $post->post_status;
415 }
416
417 /**
418 * Checks whether post content embeds a Formidable form.
419 *
420 * Deliberately two string searches and nothing more. This runs on every post save on the
421 * site, so it is the gate that keeps the parsing off the hot path.
422 *
423 * @since 6.35
424 *
425 * @param string $content Post content.
426 *
427 * @return bool
428 */
429 private static function content_has_embed( $content ) {
430 if ( ! is_string( $content ) ) {
431 return false;
432 }
433
434 return str_contains( $content, '[formidable ' ) || str_contains( $content, '<!-- wp:formidable/simple-form ' );
435 }
436
437 /**
438 * Extracts the IDs of the forms embedded in post content.
439 *
440 * Only id= shortcodes and the simple-form block are recognised, matching what
441 * FrmFormsListHelper::get_base_search_strings_for_form() looks for. A key= shortcode is
442 * deliberately not matched, because the Embeds column cannot find it either.
443 *
444 * @since 6.35
445 *
446 * @param string $content Post content.
447 *
448 * @return array Sorted, unique form IDs.
449 */
450 private static function get_embedded_form_ids( $content ) {
451 if ( ! is_string( $content ) || '' === $content ) {
452 return array();
453 }
454
455 $ids = array();
456 $matches = array();
457
458 // [formidable id=5], [formidable key="contact-form"], and every quoting of both.
459 preg_match_all( '/\[formidable\b[^\]]*\b(?:id|key)=["\']?([A-Za-z0-9_\-]+)/', $content, $matches );
460
461 if ( $matches[1] ) {
462 $ids = $matches[1];
463 }
464
465 $matches = array();
466
467 // <!-- wp:formidable/simple-form {"formId":"5" ... -->.
468 preg_match_all( '/wp:formidable\/simple-form\s*\{[^}]*"formId":"?(\d+)/', $content, $matches );
469
470 if ( $matches[1] ) {
471 $ids = array_merge( $ids, $matches[1] );
472 }
473
474 $form_ids = array();
475
476 foreach ( $ids as $id ) {
477 // A key in either attribute resolves to the same form the shortcode would render.
478 $form_ids[] = is_numeric( $id ) ? intval( $id ) : FrmForm::get_id_by_key( $id );
479 }
480
481 $form_ids = array_filter( $form_ids );
482
483 $form_ids = array_unique( $form_ids );
484 sort( $form_ids );
485
486 return $form_ids;
487 }
488
489 /**
490 * Expands the forms embedded in a post to every form whose cached list they affect.
491 *
492 * A form's embeds list can be matched by a shortcode for a different form. Pro's nested
493 * forms are the case that matters: FrmProFormsListHelper::get_search_strings_for_form()
494 * makes form G's list match [formidable id=P] whenever form P embeds form G, so a change to
495 * a post embedding P has to invalidate G too.
496 *
497 * @since 6.35
498 *
499 * @param array $form_ids Form IDs embedded in the post content.
500 *
501 * @return array
502 */
503 private static function get_affected_form_ids( $form_ids ) {
504 if ( ! $form_ids ) {
505 return array();
506 }
507
508 $ids = array();
509
510 foreach ( $form_ids as $form_id ) {
511 $ids[] = intval( $form_id );
512 }
513
514 $form_ids = array_unique( $ids );
515 sort( $form_ids );
516
517 $key = implode( ',', $form_ids );
518
519 if ( isset( self::$affected_form_ids[ $key ] ) ) {
520 // Listeners hit the database to work this out, so a bulk import of pages embedding
521 // the same form must not pay for it once per page.
522 return self::$affected_form_ids[ $key ];
523 }
524
525 /**
526 * Filters the forms whose cached embeds list is affected by a post embedding $form_ids.
527 *
528 * Anything that widens get_search_strings_for_form() has to widen this to match, or the
529 * forms it added will keep a stale count.
530 *
531 * @since 6.35
532 *
533 * @param array $affected_form_ids Form IDs whose cached lists are affected.
534 * @param array $form_ids Form IDs embedded in the post content.
535 */
536 $affected = apply_filters( 'frm_form_ids_affected_by_embed', $form_ids, $form_ids );
537
538 if ( ! is_array( $affected ) ) {
539 $affected = $form_ids;
540 }
541
542 $expanded = array();
543
544 foreach ( $affected as $form_id ) {
545 $expanded[] = intval( $form_id );
546 }
547
548 $expanded = array_values( array_unique( $expanded ) );
549
550 self::$affected_form_ids[ $key ] = $expanded;
551
552 return $expanded;
553 }
554
555 /**
556 * Flattens the cache into a lookup of the post IDs it lists.
557 *
558 * Built once per request, so a bulk operation reads the cache once and then answers each
559 * post with an array lookup instead of walking every form's post list every time.
560 *
561 * @since 6.35
562 *
563 * @return array
564 */
565 private static function get_cached_post_ids() {
566 if ( null !== self::$cached_post_ids ) {
567 return self::$cached_post_ids;
568 }
569
570 self::$cached_post_ids = array();
571
572 foreach ( self::get_cached_posts() as $posts ) {
573 if ( ! is_array( $posts ) ) {
574 continue;
575 }
576
577 foreach ( $posts as $post_data ) {
578 if ( isset( $post_data->ID ) ) {
579 self::$cached_post_ids[ intval( $post_data->ID ) ] = true;
580 }
581 }
582 }
583
584 return self::$cached_post_ids;
585 }
586
587 /**
588 * Gets the cached forms that currently list a post.
589 *
590 * @since 6.35
591 *
592 * @param int $post_id Post ID.
593 *
594 * @return array
595 */
596 private static function get_cached_form_ids_for_post( $post_id ) {
597 $post_id = intval( $post_id );
598
599 if ( ! isset( self::get_cached_post_ids()[ $post_id ] ) ) {
600 // Answers the overwhelming majority of saves without walking the cache.
601 return array();
602 }
603
604 $form_ids = array();
605
606 foreach ( self::get_cached_posts() as $form_id => $posts ) {
607 if ( ! is_array( $posts ) ) {
608 continue;
609 }
610
611 foreach ( $posts as $post_data ) {
612 if ( isset( $post_data->ID ) && intval( $post_data->ID ) === $post_id ) {
613 $form_ids[] = intval( $form_id );
614 break;
615 }
616 }
617 }
618
619 return $form_ids;
620 }
621
622 /**
623 * Drops the given forms from the cache and keeps everything else.
624 *
625 * At 100k posts a rebuild costs seconds, so keeping the forms that did not change is worth
626 * far more than the cost of writing the map back.
627 *
628 * @since 6.35
629 *
630 * @param array $form_ids Form IDs to drop.
631 *
632 * @return void
633 */
634 private static function clear_for_forms( $form_ids ) {
635 if ( ! $form_ids ) {
636 return;
637 }
638
639 if ( ! self::can_target_forms() ) {
640 self::clear();
641 return;
642 }
643
644 $cached_posts = self::get_cached_posts();
645
646 if ( array() === $cached_posts ) {
647 return;
648 }
649
650 $dropped = false;
651
652 foreach ( $form_ids as $form_id ) {
653 if ( ! array_key_exists( $form_id, $cached_posts ) ) {
654 continue;
655 }
656
657 unset( $cached_posts[ $form_id ] );
658 $dropped = true;
659 }
660
661 if ( ! $dropped ) {
662 // None of the affected forms are cached, so every cached count is still accurate.
663 return;
664 }
665
666 if ( array() === $cached_posts ) {
667 self::clear();
668 return;
669 }
670
671 self::save_cached_posts( $cached_posts );
672 }
673
674 /**
675 * Checks whether the affected forms can be worked out precisely.
676 *
677 * Pro widens get_search_strings_for_form() for nested forms. A Pro old enough not to hook
678 * frm_form_ids_affected_by_embed cannot tell us which extra forms a post reaches, so on
679 * those installs the whole cache is cleared rather than risk leaving a stale count.
680 *
681 * @since 6.35
682 *
683 * @return bool
684 */
685 private static function can_target_forms() {
686 if ( has_filter( 'frm_form_ids_affected_by_embed' ) ) {
687 return true;
688 }
689
690 return ! FrmAppHelper::pro_is_installed();
691 }
692
693 /**
694 * Clears the embed posts cache.
695 *
696 * @since 6.35
697 *
698 * @return void
699 */
700 private static function clear() {
701 self::$cached_post_ids = array();
702
703 if ( array() === self::get_cached_posts() ) {
704 // Nothing left to delete. Without this, a bulk insert of pages that embed a form
705 // would run a delete query once per page.
706 //
707 // Reading the memoized copy rather than the transient is safe because
708 // save_cached_posts() is the only thing that writes this key, and it refreshes the
709 // memo. Pro and Landing delete the key on activation, which can only leave the memo
710 // stale in the harmless direction: one redundant delete.
711 return;
712 }
713
714 self::$cached_posts = array();
715
716 delete_transient( self::TRANSIENT_NAME );
717 }
718 }
719