PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/Query.php +403 -107 4.4.14.9.2 View file →
@@ -1,8 +1,14 @@
1 1 <?php
2 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query,WordPress.DB.SlowDBQuery.slow_db_query_tax_query,WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- core docs query builder; meta/tax filtering required for docs/category/KB filtering.
3 +// phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- excludes are user-driven (settings UI) and part of the query builder's public contract.
4 +namespace WPDeveloper\BetterDocs\Core;
2 5
3 -namespace WPDeveloper\BetterDocs\Core;
6 +if ( ! defined( 'ABSPATH' ) ) {
7 + exit;
8 +}
4 9
10 +
5 11 use WP_Query;
6 12 use WPDeveloper\BetterDocs\Utils\Base;
7 13 use WPDeveloper\BetterDocs\Utils\Database;
8 14 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
@@ -20,9 +26,24 @@
20 26 add_action( 'parse_term_query', array( $this, 'parse_term_query' ) );
21 27 // add_action( 'parse_query', [$this, 'parse_query'], 1 );
22 28 add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1 );
23 29 add_filter( 'betterdocs_base_terms_args', array( $this, 'modify_terms_args_for_private_docs' ), 10, 1 );
30 + // Drops terms whose private-inclusive count is 0 after `hide_empty` was
31 + // turned off by modify_terms_args_for_private_docs().
32 + add_filter( 'get_terms', array( $this, 'filter_terms_for_private_docs' ), 10, 3 );
24 33
34 + // Invalidate cached doc-category counts on any write that could change them.
35 + add_action( 'save_post_docs', array( $this, 'flush_term_counts_cache' ) );
36 + add_action( 'deleted_post', array( $this, 'flush_term_counts_cache_on_post' ), 10, 2 );
37 + add_action( 'edited_doc_category', array( $this, 'flush_term_counts_cache' ) );
38 + add_action( 'created_doc_category', array( $this, 'flush_term_counts_cache' ) );
39 + add_action( 'delete_doc_category', array( $this, 'flush_term_counts_cache' ) );
40 + add_action( 'set_object_terms', array( $this, 'flush_term_counts_cache_on_set' ), 10, 4 );
41 + // wp_update_term_count_now() — the `wp term recount` repair path, and any core
42 + // count update — fires edited_term_taxonomy, not any of the above. Without this
43 + // the recount fixes the DB while we keep serving the cached counts (#166).
44 + add_action( 'edited_term_taxonomy', array( $this, 'flush_term_counts_cache_on_term_taxonomy' ), 10, 2 );
45 +
25 46 /**
26 47 * These below filters are hooked for navigation only.
27 48 *
28 49 * For old version of this portion.
@@ -71,26 +92,53 @@
71 92 public function init() {
72 93 }
73 94
74 95 /**
75 - * Modify terms args to include terms with only private docs for users with read_private_docs capability
96 + * Modify terms args to include terms whose only docs are private, for users allowed to read them.
76 97 *
98 + * Gated on being logged in rather than on `read_private_docs`, because a doc's own
99 + * author may read their private doc without holding that capability. Which docs
100 + * actually count is decided per post by can_read_doc().
101 + *
77 102 * @param array $args
78 103 * @return array
79 104 */
80 105 public function modify_terms_args_for_private_docs( $args ) {
81 - // Only modify for users with read_private_docs capability and supported taxonomies
82 - if ( ! current_user_can( 'read_private_docs' ) || ! isset( $args[ 'taxonomy' ] ) ) {
106 + // Logged-out visitors always get WordPress' stock `hide_empty` behaviour.
107 + if ( ! is_user_logged_in() || ! isset( $args[ 'taxonomy' ] ) ) {
83 108 return $args;
84 109 }
85 110
86 - // Only support doc_category taxonomy for private docs filtering
87 - // knowledge_base terms don't have docs directly assigned to them
88 - $supported_taxonomies = array( 'doc_category' );
89 - if ( ! in_array( $args[ 'taxonomy' ], $supported_taxonomies ) ) {
111 + /**
112 + * Let add-ons opt their own taxonomies into the private-docs rescue path.
113 + *
114 + * BetterDocs Pro hooks this for `knowledge_base` (Multiple KB), where docs
115 + * ARE assigned to the KB term directly.
116 + *
117 + * @param array $args Term query args.
118 + */
119 + $_filtered = apply_filters( 'betterdocs_modify_terms_args_for_private_docs', $args );
120 + if ( is_array( $_filtered ) ) {
121 + $args = $_filtered;
122 + }
123 +
124 + // Already handled by an add-on (e.g. Pro's Multiple KB).
125 + if ( ! empty( $args[ '_betterdocs_filter_private' ] ) ) {
90 126 return $args;
91 127 }
92 128
129 + /**
130 + * Taxonomies whose terms have docs assigned directly, so a private-inclusive
131 + * object count can rescue a term WordPress dropped for `hide_empty`.
132 + *
133 + * `knowledge_base` is included so a KB whose only docs are private still shows
134 + * up for privileged users even on older Pro builds that predate the filter above.
135 + */
136 + $supported_taxonomies = array( 'doc_category', 'knowledge_base' );
137 + if ( ! in_array( $args[ 'taxonomy' ], $supported_taxonomies, true ) ) {
138 + return $args;
139 + }
140 +
93 141 // If hide_empty is true, we need to modify the logic to include terms with private docs
94 142 if ( isset( $args[ 'hide_empty' ] ) && $args[ 'hide_empty' ] ) {
95 143 // Set hide_empty to false and we'll filter manually later
96 144 $args[ 'hide_empty' ] = false;
@@ -208,9 +256,10 @@
208 256 if ( ! empty( $_docs_order ) ) {
209 257 $_docs_order = explode( ',', $_docs_order );
210 258 $new_ids = array();
211 259
212 - $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
260 + // $query is prepared upstream; results are cached via $this->database->get_cache above (line 210).
261 + $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
213 262
214 263 if ( is_array( $results ) && ! empty( $results ) ) {
215 264 $object_ids = array_filter(
216 265 $results,
@@ -302,14 +351,18 @@
302 351
303 352 if ( empty( $_docs_order ) ) {
304 353 $statuses = array( 'publish' );
305 354
306 - if ( current_user_can( 'read_private_docs' ) ) {
355 + if ( is_user_logged_in() ) {
307 356 $statuses[ ] = 'private';
308 357 }
309 358
310 359 $_args = array(
311 360 'post_status' => $statuses,
361 + // Required: with an explicit `post_status` WordPress only narrows private
362 + // docs to the ones the user may read when `perm` is `readable`. Without it,
363 + // listing `private` would expose every private doc to any logged-in user.
364 + 'perm' => 'readable',
312 365 'term_id' => $_terms[ 0 ]->term_id
313 366 );
314 367 if ( isset( $wp_query->query_vars[ 'doc_category' ] ) ) {
315 368 $_args[ 'tax_query' ][ ] = array(
@@ -450,14 +503,13 @@
450 503 public function get_terms( $args ) {
451 504 $parsed_args = $this->parse_terms_args( $args );
452 505 $terms = get_terms( $parsed_args );
453 506
454 - // Filter terms manually if we need to consider private docs for users with read_private_docs capability
455 - if ( isset( $parsed_args[ '_betterdocs_filter_private' ] ) && $parsed_args[ '_betterdocs_filter_private' ] && current_user_can( 'read_private_docs' ) ) {
507 + // Filter terms manually if we need to consider private docs the current user may read
508 + if ( isset( $parsed_args[ '_betterdocs_filter_private' ] ) && $parsed_args[ '_betterdocs_filter_private' ] && is_user_logged_in() ) {
456 509 $terms = array_filter( $terms, function ( $term ) {
457 510 // Get the actual count including private docs for users with read_private_docs capability
458 - $actual_count = $this->get_docs_count( $term, false );
459 - return $actual_count > 0;
511 + return $this->get_private_inclusive_docs_count( $term ) > 0;
460 512 } );
461 513 }
462 514
463 515 return $terms;
@@ -462,8 +514,124 @@
462 514
463 515 return $terms;
464 516 }
465 517
518 + /**
519 + * Drop terms with no visible docs after `hide_empty` was disabled for private docs.
520 + *
521 + * modify_terms_args_for_private_docs() turns `hide_empty` off so WordPress stops
522 + * dropping terms whose only docs are private. Without this counterpart, every
523 + * genuinely empty term would leak into the listing for privileged users. Runs only
524 + * for queries carrying the `_betterdocs_filter_private` flag, so anonymous and
525 + * unflagged queries are untouched.
526 + *
527 + * @param array|WP_Error $terms
528 + * @param array|null $taxonomies
529 + * @param array $args
530 + * @return array|WP_Error
531 + */
532 + public function filter_terms_for_private_docs( $terms, $taxonomies, $args ) {
533 + if ( empty( $args[ '_betterdocs_filter_private' ] ) || empty( $terms ) || is_wp_error( $terms ) ) {
534 + return $terms;
535 + }
536 +
537 + if ( ! is_user_logged_in() ) {
538 + return $terms;
539 + }
540 +
541 + // Only term objects can be counted; `ids`, `names`, `count`, ... pass through.
542 + $_fields = isset( $args[ 'fields' ] ) ? $args[ 'fields' ] : 'all';
543 + if ( ! in_array( $_fields, array( 'all', 'all_with_object_id' ), true ) ) {
544 + return $terms;
545 + }
546 +
547 + $_filtered = array_filter( $terms, function ( $term ) {
548 + if ( ! is_object( $term ) ) {
549 + return true;
550 + }
551 +
552 + return $this->get_private_inclusive_docs_count( $term ) > 0;
553 + } );
554 +
555 + return array_values( $_filtered );
556 + }
557 +
558 + /**
559 + * Docs count for a term, including private docs when the user may read them.
560 + *
561 + * KB terms need object-in-term counting rather than the doc_category count path,
562 + * which BetterDocs Pro provides through the filter below; get_docs_count() is the
563 + * fallback and already counts via get_objects_in_term() for the term's own taxonomy.
564 + *
565 + * @param WP_Term $term
566 + * @return int
567 + */
568 + /**
569 + * Whether the current user may see a doc in a listing.
570 + *
571 + * Public docs are visible to everyone. A private doc is visible to whoever may read
572 + * it — `read_post` maps to the base `read` capability for the doc's own author and
573 + * to `read_private_docs` for everyone else, so owners see their own private docs
574 + * without needing the capability. Other non-public statuses (draft, pending) stay
575 + * out of listings for everyone, as before.
576 + *
577 + * @param int $post_id
578 + * @return bool
579 + */
580 + /**
581 + * Cache-key fragment identifying whose visibility a cached count reflects.
582 + *
583 + * Everyone who can read every private doc sees the same numbers, so they share one
584 + * `priv` bucket; authors differ from each other and get their own. Logged-out
585 + * visitors all share bucket `0`, keeping the anonymous cache as hot as before.
586 + *
587 + * @return string
588 + */
589 + protected function count_cache_viewer_key() {
590 + if ( ! is_user_logged_in() ) {
591 + return '0';
592 + }
593 +
594 + if ( current_user_can( 'read_private_docs' ) ) {
595 + return 'priv';
596 + }
597 +
598 + return 'u' . get_current_user_id();
599 + }
600 +
601 + public function can_read_doc( $post_id ) {
602 + if ( is_post_publicly_viewable( $post_id ) ) {
603 + return true;
604 + }
605 +
606 + return 'private' === get_post_status( $post_id ) && current_user_can( 'read_post', $post_id );
607 + }
608 +
609 + protected function get_private_inclusive_docs_count( $term ) {
610 + /**
611 + * Let add-ons supply their own private-inclusive count for a term.
612 + *
613 + * Passing `null` means "not handled" — implementations that don't recognise the
614 + * term's taxonomy return the value untouched, and we fall back to get_docs_count().
615 + *
616 + * @param int|null $count
617 + * @param WP_Term $term
618 + */
619 + $_count = apply_filters( 'betterdocs_get_term_docs_count_for_private_filter', null, $term );
620 +
621 + if ( null !== $_count ) {
622 + return (int) $_count;
623 + }
624 +
625 + // Count nested sub-category docs too, so a parent whose docs live only in its
626 + // children stays visible to logged-in users exactly as it does for anonymous
627 + // visitors (whose grid is descendant-aware). get_docs_count( …, true ) already
628 + // filters each descendant doc through can_read_doc() via get_doc_ids_by_term(),
629 + // so private/unreadable docs never resurrect a term. A non-nested count here
630 + // hid those parents from logged-in users only.
631 + return (int) $this->get_docs_count( $term, true );
632 + }
633 +
466 634 public function get_child_terms( $args ) {
467 635 if ( ! isset( $args[ 'number' ] ) ) {
468 636 global $wp_query;
469 637 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
@@ -661,57 +829,35 @@
661 829 *
662 830 * @return array An array of non-empty child term IDs.
663 831 */
664 832 public function get_all_child_term_ids( $taxonomy, $parent_id ) {
665 - // Set up the arguments for retrieving child terms
833 + $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
834 + $cache_key = "bd_term_counts_v{$version}_child_ids_{$taxonomy}_{$parent_id}";
835 +
836 + $cached = wp_cache_get( $cache_key, 'betterdocs' );
837 + if ( false !== $cached ) {
838 + return $cached;
839 + }
840 +
841 + // get_terms( child_of => X ) walks the full descendant tree using WP's
842 + // internally cached term hierarchy, replacing the previous recursive
843 + // get_term()-in-a-loop pattern with one call.
666 844 $args = apply_filters(
667 845 'betterdocs_get_child_term_ids_args',
668 846 array(
669 - 'taxonomy' => $taxonomy,
670 - 'parent' => $parent_id,
847 + 'taxonomy' => $taxonomy,
848 + 'child_of' => $parent_id,
671 849 'hide_empty' => true,
672 - 'fields' => 'ids'
850 + 'fields' => 'ids',
673 851 )
674 852 );
675 853
676 - // Get the terms based on the arguments
677 - $terms = get_terms( $args );
854 + $ids = get_terms( $args );
855 + $ids = ( is_wp_error( $ids ) || ! is_array( $ids ) ) ? array() : array_map( 'intval', $ids );
678 856
679 - // Initialize an empty array to hold non-empty child term IDs
680 - $non_empty_children = array();
857 + wp_cache_set( $cache_key, $ids, 'betterdocs', HOUR_IN_SECONDS * 6 );
681 858
682 - // Check if terms were retrieved without errors and that the result isn't empty
683 - if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
684 - $term_ids = array();
685 -
686 - // Loop through each term ID
687 - foreach ( $terms as $term_id ) {
688 - // Add the current term ID to the term_ids array
689 - $term_ids[ ] = $term_id;
690 -
691 - // Recursively get child terms for the current term
692 - $child_term_ids = $this->get_all_child_term_ids( $taxonomy, $term_id );
693 -
694 - // If there are child terms, merge them into the term_ids array
695 - if ( ! empty( $child_term_ids ) ) {
696 - $term_ids = array_merge( $term_ids, $child_term_ids );
697 - }
698 - }
699 -
700 - // Loop through all retrieved term IDs to filter out empty ones
701 - foreach ( $term_ids as $term_id ) {
702 - // Get the term object for the current term ID
703 - $child_term = get_term( $term_id, $taxonomy );
704 -
705 - // Only include terms that have a non-zero post count
706 - if ( $child_term && isset( $child_term->term_id ) && $child_term->count > 0 ) {
707 - $non_empty_children[ ] = $child_term->term_id;
708 - }
709 - }
710 - }
711 -
712 - // Return the final array of non-empty child term IDs
713 - return $non_empty_children;
859 + return $ids;
714 860 }
715 861
716 862 /**
717 863 * Get all nested child term IDs of a specific parent term in a taxonomy and return as a comma-separated string.
@@ -906,14 +1052,57 @@
906 1052
907 1053 return $final_args;
908 1054 }
909 1055
910 - public function faq_terms_query_args( $includes = '', $excludes = '', $args = array() ) {
1056 + /**
1057 + * The front-end FAQ ordering preference, set via the FAQ Builder header
1058 + * dropdown (the `.betterdocs-dropdown-select` control, saved to the
1059 + * `betterdocs_faq_order` option). The front end mirrors the builder so it
1060 + * shows FAQ groups (and the FAQs inside them) in the same order the admin
1061 + * sees while building.
1062 + *
1063 + * @return string One of: default, most_recent, least_recent, a_to_z, z_to_a, most_questions.
1064 + */
1065 + public function get_faq_order_key() {
1066 + $key = get_option( 'betterdocs_faq_order', 'default' );
1067 + $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
1068 +
1069 + if ( ! in_array( $key, $allowed, true ) ) {
1070 + $key = 'default';
1071 + }
1072 +
1073 + return apply_filters( 'betterdocs_faq_order_key', $key );
1074 + }
1075 +
1076 + /**
1077 + * Translate the FAQ order preference into `get_terms()` order clauses for
1078 + * the FAQ group list. Mirrors the admin builder's ORDER_MAP so the front
1079 + * end matches what's configured there.
1080 + *
1081 + * @return array orderby/order (+ meta_key for the manual `default` order).
1082 + */
1083 + public function faq_terms_order_clause() {
1084 + switch ( $this->get_faq_order_key() ) {
1085 + case 'most_recent':
1086 + return array( 'orderby' => 'term_id', 'order' => 'DESC' );
1087 + case 'least_recent':
1088 + return array( 'orderby' => 'term_id', 'order' => 'ASC' );
1089 + case 'a_to_z':
1090 + return array( 'orderby' => 'name', 'order' => 'ASC' );
1091 + case 'z_to_a':
1092 + return array( 'orderby' => 'name', 'order' => 'DESC' );
1093 + case 'most_questions':
1094 + return array( 'orderby' => 'count', 'order' => 'DESC' );
1095 + case 'default':
1096 + default:
1097 + // Manual drag-drop order stored in the `order` term meta.
1098 + return array( 'meta_key' => 'order', 'orderby' => 'meta_value_num', 'order' => 'ASC' );
1099 + }
1100 + }
1101 +
1102 + public function faq_terms_query_args( $includes = '', $excludes = '', $args = array(), $taxonomy = 'betterdocs_faq_category' ) {
911 1103 $_args = array(
912 - 'taxonomy' => 'betterdocs_faq_category',
913 - 'meta_key' => 'order',
914 - 'orderby' => 'meta_value_num',
915 - 'order' => 'ASC',
1104 + 'taxonomy' => $taxonomy,
916 1105 'include' => $includes,
917 1106 'exclude' => $excludes,
918 1107 'meta_query' => array(
919 1108 array(
@@ -923,8 +1112,10 @@
923 1112 )
924 1113 )
925 1114 );
926 1115
1116 + $_args = array_merge( $_args, $this->faq_terms_order_clause() );
1117 +
927 1118 if ( 'all' == $_args[ 'include' ] ) {
928 1119 unset( $_args[ 'include' ] );
929 1120 unset( $_args[ 'exclude' ] );
930 1121 }
@@ -939,9 +1130,9 @@
939 1130
940 1131 return wp_parse_args( $args, $_args );
941 1132 }
942 1133
943 - public function get_faq_by_term( $term_id ) {
1134 + public function get_faq_by_term( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
944 1135 global $wpdb;
945 1136
946 1137 $args = array(
947 1138 'post_type' => 'betterdocs_faq',
@@ -947,9 +1138,9 @@
947 1138 'post_type' => 'betterdocs_faq',
948 1139 'post_status' => 'publish',
949 1140 'tax_query' => array(
950 1141 array(
951 - 'taxonomy' => 'betterdocs_faq_category',
1142 + 'taxonomy' => $taxonomy,
952 1143 'field' => 'term_id',
953 1144 'terms' => $term_id,
954 1145 'operator' => 'AND'
955 1146 )
@@ -956,10 +1147,35 @@
956 1147 ),
957 1148 'posts_per_page' => -1
958 1149 );
959 1150
960 - $args[ 'orderby' ] = 'post__in';
961 - $args[ 'post__in' ] = $this->get_faq_orders( $term_id );
1151 + // Order the FAQs inside the group to mirror the FAQ Builder header
1152 + // dropdown. `default`/`most_questions` keep the manual drag-drop order
1153 + // (the others sort live, just like the admin builder does).
1154 + switch ( $this->get_faq_order_key() ) {
1155 + case 'most_recent':
1156 + $args[ 'orderby' ] = 'ID';
1157 + $args[ 'order' ] = 'DESC';
1158 + break;
1159 + case 'least_recent':
1160 + $args[ 'orderby' ] = 'ID';
1161 + $args[ 'order' ] = 'ASC';
1162 + break;
1163 + case 'a_to_z':
1164 + $args[ 'orderby' ] = 'title';
1165 + $args[ 'order' ] = 'ASC';
1166 + break;
1167 + case 'z_to_a':
1168 + $args[ 'orderby' ] = 'title';
1169 + $args[ 'order' ] = 'DESC';
1170 + break;
1171 + case 'default':
1172 + case 'most_questions':
1173 + default:
1174 + $args[ 'orderby' ] = 'post__in';
1175 + $args[ 'post__in' ] = $this->get_faq_orders( $term_id );
1176 + break;
1177 + }
962 1178
963 1179 return new WP_Query( $args );
964 1180 }
965 1181
@@ -976,9 +1192,10 @@
976 1192 }
977 1193
978 1194 if ( ! empty( $faq_order ) ) {
979 1195 $new_ids = array();
980 - $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1196 + // $query is prepared upstream; results are cached via $this->database->get_cache above (line 980).
1197 + $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
981 1198
982 1199 if ( ! is_null( $results ) && ! empty( $results ) && is_array( $results ) ) {
983 1200 $object_ids = array_filter(
984 1201 $results,
@@ -1004,12 +1221,12 @@
1004 1221
1005 1222 return $faq_order;
1006 1223 }
1007 1224
1008 - public function get_faq_terms( $terms = array() ) {
1225 + public function get_faq_terms( $terms = array(), $taxonomy = 'betterdocs_faq_category' ) {
1009 1226 $_terms = get_terms(
1010 1227 array(
1011 - 'taxonomy' => 'betterdocs_faq_category',
1228 + 'taxonomy' => $taxonomy,
1012 1229 'hide_empty' => true,
1013 1230 'orderby' => 'name',
1014 1231 'order' => 'ASC',
1015 1232 'meta_query' => array(
@@ -1049,8 +1266,47 @@
1049 1266
1050 1267 return $terms;
1051 1268 }
1052 1269
1270 + public function flush_term_counts_cache() {
1271 + $this->database->bump_cache_version( 'betterdocs_term_counts' );
1272 + }
1273 +
1274 + public function flush_term_counts_cache_on_post( $post_id, $post = null ) {
1275 + if ( $post && isset( $post->post_type ) && $post->post_type === 'docs' ) {
1276 + $this->flush_term_counts_cache();
1277 + }
1278 + }
1279 +
1280 + public function flush_term_counts_cache_on_set( $object_id, $terms, $tt_ids, $taxonomy ) {
1281 + if ( $taxonomy === 'doc_category' ) {
1282 + $this->flush_term_counts_cache();
1283 + }
1284 + }
1285 +
1286 + /**
1287 + * Invalidate the count cache when a term count is recalculated.
1288 + *
1289 + * wp_update_term_count_now() (a recount, `wp term recount`, or any core count
1290 + * update) fires edited_term_taxonomy for each affected term. The version bump is
1291 + * a single update_option, so it is debounced to once per request with a static
1292 + * flag: one bump already invalidates every cached count, and a bulk recount would
1293 + * otherwise write the option once per term. (#166)
1294 + *
1295 + * @param int $tt_id Term taxonomy id.
1296 + * @param string $taxonomy Taxonomy name.
1297 + */
1298 + public function flush_term_counts_cache_on_term_taxonomy( $tt_id, $taxonomy ) {
1299 + static $flushed = false;
1300 +
1301 + if ( $flushed || ! in_array( $taxonomy, array( 'doc_category', 'knowledge_base' ), true ) ) {
1302 + return;
1303 + }
1304 +
1305 + $this->flush_term_counts_cache();
1306 + $flushed = true;
1307 + }
1308 +
1053 1309 public function get_docs_count( $term, $nested_subcategory = false, $args = array() ) {
1054 1310 // Validate term object
1055 1311 if ( ! is_object( $term ) ) {
1056 1312 return 0;
@@ -1057,42 +1313,55 @@
1057 1313 }
1058 1314
1059 1315 $counts = isset( $term->count ) ? $term->count : 0;
1060 1316
1317 + if ( ! isset( $term->term_id ) || ! is_numeric( $term->term_id ) ) {
1318 + return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1319 + }
1320 +
1321 + $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1322 + // Counts depend on who is asking: an author sees their own private docs, so the
1323 + // key carries the user id. Logged-out visitors all share user 0.
1324 + $viewer = $this->count_cache_viewer_key();
1325 + $kb_slug = isset( $args['kb_slug'] ) ? $args['kb_slug'] : '';
1326 + $multi = ! empty( $args['multiple_knowledge_base'] ) ? 1 : 0;
1327 + $nested = $nested_subcategory ? 1 : 0;
1328 + $cache_key = "bd_term_counts_v{$version}_docs_count_{$term->term_id}_{$nested}_{$viewer}_{$kb_slug}_{$multi}";
1329 +
1330 + $cached = wp_cache_get( $cache_key, 'betterdocs' );
1331 + if ( false !== $cached ) {
1332 + return apply_filters( 'betterdocs_docs_count', $cached, $term, $nested_subcategory, $args );
1333 + }
1334 +
1061 1335 if ( false == $nested_subcategory ) {
1062 1336 // For non-nested categories, we need to recalculate counts based on user capabilities
1063 1337 // Only proceed if we have a valid term with required properties
1064 - if ( isset( $term->term_id ) && isset( $term->taxonomy ) && is_numeric( $term->term_id ) ) {
1338 + if ( isset( $term->taxonomy ) ) {
1065 1339 // Get all post IDs for this term
1066 1340 $post_ids = get_objects_in_term( $term->term_id, $term->taxonomy );
1067 1341
1068 1342 if ( ! empty( $post_ids ) ) {
1069 - if ( current_user_can( 'read_private_docs' ) ) {
1070 - // For users with read_private_docs capability, include both private and public posts
1071 - $filtered_post_ids = array_filter( $post_ids, function ( $post_id ) {
1072 - $post_status = get_post_status( $post_id );
1073 - return 'private' === $post_status || is_post_publicly_viewable( $post_id );
1074 - } );
1075 - } else {
1076 - // For users without read_private_docs capability, only include public posts
1077 - $filtered_post_ids = array_filter( $post_ids, function ( $post_id ) {
1078 - return is_post_publicly_viewable( $post_id );
1079 - } );
1080 - }
1343 + _prime_post_caches( $post_ids, false, false );
1081 1344
1345 + // Public docs for everyone, plus any private doc this user may read
1346 + // (its own author, or a `read_private_docs` holder).
1347 + $filtered_post_ids = array_filter( $post_ids, function ( $post_id ) {
1348 + return $this->can_read_doc( $post_id );
1349 + } );
1350 +
1082 1351 $counts = count( $filtered_post_ids );
1083 1352 } else {
1084 1353 $counts = 0;
1085 1354 }
1086 1355 }
1087 -
1088 - return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1356 + } else {
1357 + $_child_terms_docs_ids = $this->get_doc_ids_by_term( $term, null, $nested_subcategory );
1358 + if ( is_array( $_child_terms_docs_ids ) ) {
1359 + $counts = count( $_child_terms_docs_ids );
1360 + }
1089 1361 }
1090 1362
1091 - $_child_terms_docs_ids = $this->get_doc_ids_by_term( $term, null, $nested_subcategory );
1092 - if ( is_array( $_child_terms_docs_ids ) ) {
1093 - $counts = count( $_child_terms_docs_ids );
1094 - }
1363 + wp_cache_set( $cache_key, $counts, 'betterdocs', HOUR_IN_SECONDS * 6 );
1095 1364
1096 1365 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1097 1366 }
1098 1367
@@ -1101,14 +1370,28 @@
1101 1370 if ( ! is_object( $term ) || ! isset( $term->term_id ) || ! isset( $term->taxonomy ) || ! is_numeric( $term->term_id ) ) {
1102 1371 return false;
1103 1372 }
1104 1373
1105 - $args = array( 'include' => $term->term_id );
1374 + $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1375 + $viewer = $this->count_cache_viewer_key();
1376 + $nested = $nested_subcategory ? 1 : 0;
1377 + $optional_id = is_object( $optional ) && isset( $optional->term_id ) ? (int) $optional->term_id : 0;
1378 + $cache_key = "bd_term_counts_v{$version}_doc_ids_{$term->term_id}_{$nested}_{$optional_id}_{$viewer}";
1379 +
1380 + $cached = wp_cache_get( $cache_key, 'betterdocs' );
1381 + if ( false !== $cached ) {
1382 + return $cached;
1383 + }
1384 +
1385 + $args = array(
1386 + 'taxonomy' => $term->taxonomy,
1387 + 'include' => $term->term_id,
1388 + );
1106 1389 if ( $nested_subcategory ) {
1107 1390 $args[ 'child_of' ] = $term->term_id;
1108 1391 unset( $args[ 'include' ] );
1109 1392 }
1110 - $_child_terms = get_terms( $term->taxonomy, $args );
1393 + $_child_terms = get_terms( $args );
1111 1394
1112 1395 if ( ! is_array( $_child_terms ) ) {
1113 1396 return false;
1114 1397 }
@@ -1123,16 +1406,19 @@
1123 1406 $_optional_doc_ids = get_objects_in_term( $optional->term_id, $optional->taxonomy );
1124 1407 $_child_terms_docs_ids = array_intersect( $_child_terms_docs_ids, $_optional_doc_ids );
1125 1408 }
1126 1409
1127 - return array_filter( $_child_terms_docs_ids, function ( $doc_id ) {
1128 - if ( ! current_user_can( 'read_private_docs' ) ) {
1129 - return is_post_publicly_viewable( $doc_id );
1130 - }
1410 + if ( ! empty( $_child_terms_docs_ids ) ) {
1411 + _prime_post_caches( $_child_terms_docs_ids, false, false );
1412 + }
1131 1413
1132 - $_status = get_post_status( $doc_id );
1133 - return 'private' == $_status || is_post_publicly_viewable( $doc_id );
1414 + $filtered = array_filter( $_child_terms_docs_ids, function ( $doc_id ) {
1415 + return $this->can_read_doc( $doc_id );
1134 1416 } );
1417 +
1418 + wp_cache_set( $cache_key, $filtered, 'betterdocs', HOUR_IN_SECONDS * 6 );
1419 +
1420 + return $filtered;
1135 1421 }
1136 1422
1137 1423 /**
1138 1424 * Get the common query arguments for WP_Query.
@@ -1196,9 +1482,9 @@
1196 1482 * @param string $term_slug The taxonomy term slug.
1197 1483 * @return bool True if there are new posts, false otherwise.
1198 1484 */
1199 1485 public function check_new_posts( $terms, $term_slug ) {
1200 - $date_7_days_ago = date( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
1486 + $date_7_days_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
1201 1487
1202 1488 $args = $this->tax_query_args(
1203 1489 $terms,
1204 1490 $term_slug,
@@ -1230,15 +1516,22 @@
1230 1516 }
1231 1517
1232 1518 global $wpdb;
1233 1519
1234 - // Use BINARY comparison to avoid collation mismatch errors
1235 - // This works across all character sets (latin1, utf8, utf8mb4, etc.)
1520 + $keyword_hash = md5( $search_input );
1521 +
1522 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live search-keyword analytics; cache would defeat the purpose.
1523 + // Matched on keyword_hash first so the index can serve the lookup — the
1524 + // BINARY comparison that follows is what actually decides equality (it
1525 + // avoids collation mismatch errors across latin1/utf8/utf8mb4), but no
1526 + // index can serve it, so on its own it full-scanned the table on every
1527 + // single front-end search.
1236 1528 $search = $wpdb->get_results(
1237 1529 $wpdb->prepare(
1238 1530 "SELECT *
1239 1531 FROM {$wpdb->prefix}betterdocs_search_keyword
1240 - WHERE BINARY keyword = %s",
1532 + WHERE keyword_hash = %s AND BINARY keyword = %s",
1533 + $keyword_hash,
1241 1534 $search_input
1242 1535 )
1243 1536 );
1244 1537
@@ -1247,9 +1540,9 @@
1247 1540 $wpdb->prepare(
1248 1541 "SELECT *
1249 1542 FROM {$wpdb->prefix}betterdocs_search_log
1250 1543 WHERE created_at = %s AND keyword_id = %d",
1251 - date( 'Y-m-d' ),
1544 + gmdate( 'Y-m-d' ),
1252 1545 $search[ 0 ]->id
1253 1546 )
1254 1547 );
1255 1548
@@ -1260,14 +1553,15 @@
1260 1553 } else {
1261 1554 $tbl_field = 'count';
1262 1555 $count = $search_log[ 0 ]->count + 1;
1263 1556 }
1264 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1557 + // $tbl_field is validated immediately above to be either 'count' or
1558 + // 'not_found_count' — safe to interpolate as a column identifier.
1559 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $tbl_field is an allowlisted column name ('count'|'not_found_count'); safe to interpolate.
1265 1560 $insert = $wpdb->query(
1266 1561 $wpdb->prepare(
1267 - "UPDATE {$wpdb->prefix}betterdocs_search_log
1268 - SET " . $tbl_field . ' = ' . $count . '
1269 - WHERE created_at = %s AND keyword_id = %d',
1562 + "UPDATE {$wpdb->prefix}betterdocs_search_log SET {$tbl_field} = %d WHERE created_at = %s AND keyword_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1563 + $count,
1270 1564 $search_log[ 0 ]->created_at,
1271 1565 $search_log[ 0 ]->keyword_id
1272 1566 )
1273 1567 );
@@ -1287,9 +1581,9 @@
1287 1581 array(
1288 1582 $search[ 0 ]->id,
1289 1583 $count,
1290 1584 $not_found_count,
1291 - date( 'Y-m-d' )
1585 + gmdate( 'Y-m-d' )
1292 1586 )
1293 1587 )
1294 1588 );
1295 1589 }
@@ -1296,12 +1590,13 @@
1296 1590 } else {
1297 1591 $insert = $wpdb->query(
1298 1592 $wpdb->prepare(
1299 1593 "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
1300 - ( keyword )
1301 - VALUES ( %s )",
1594 + ( keyword, keyword_hash )
1595 + VALUES ( %s, %s )",
1302 1596 array(
1303 - $search_input
1597 + $search_input,
1598 + $keyword_hash
1304 1599 )
1305 1600 )
1306 1601 );
1307 1602
@@ -1321,14 +1616,15 @@
1321 1616 array(
1322 1617 $wpdb->insert_id,
1323 1618 $count,
1324 1619 $not_found_count,
1325 - date( 'Y-m-d' )
1620 + gmdate( 'Y-m-d' )
1326 1621 )
1327 1622 )
1328 1623 );
1329 1624 }
1330 1625 }
1626 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1331 1627 return $insert;
1332 1628 }
1333 1629
1334 1630 /**