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 +1124 -266 3.5.04.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;
@@ -16,12 +22,28 @@
16 22 $this->container = $container;
17 23 $this->database = $database;
18 24 $this->settings = $settings;
19 25
20 - add_action( 'parse_term_query', [$this, 'parse_term_query'] );
26 + add_action( 'parse_term_query', array( $this, 'parse_term_query' ) );
21 27 // add_action( 'parse_query', [$this, 'parse_query'], 1 );
22 - add_action( 'pre_get_posts', [$this, 'pre_get_posts'], 1 );
28 + add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1 );
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 );
23 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 +
24 46 /**
25 47 * These below filters are hooked for navigation only.
26 48 *
27 49 * For old version of this portion.
@@ -32,10 +54,10 @@
32 54 * @see https://developer.wordpress.org/reference/functions/get_previous_post/
33 55 *
34 56 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
35 57 */
36 - add_filter( 'get_next_post_where', [$this, 'next_post_where'], 99, 5 );
37 - add_filter( 'get_previous_post_where', [$this, 'previous_post_where'], 99, 5 );
58 + add_filter( 'get_next_post_where', array( $this, 'next_post_where' ), 99, 5 );
59 + add_filter( 'get_previous_post_where', array( $this, 'previous_post_where' ), 99, 5 );
38 60
39 61 $this->init();
40 62
41 63 /**
@@ -40,21 +62,29 @@
40 62
41 63 /**
42 64 * Modify Popular Docs Query (For Shortcode & Widget)
43 65 */
44 - add_filter( 'posts_clauses', [$this, 'mod_query_popular_docs'], 10, 2 );
66 + add_filter( 'posts_clauses', array( $this, 'mod_query_popular_docs' ), 10, 2 );
45 67 }
46 68
47 69 public function mod_query_popular_docs( $clauses, $wp_query ) {
48 - if ( isset( $wp_query->query['meta_key'] ) ) {
49 - if ( $wp_query->query['meta_key'] == '_betterdocs_meta_views' ) {
70 + if ( isset( $wp_query->query[ 'meta_key' ] ) ) {
71 + if ( '_betterdocs_meta_views' == $wp_query->query[ 'meta_key' ] ) {
50 72 global $wpdb;
51 - $order = isset( $wp_query->query['order'] ) ? $wp_query->query['order'] : '';
52 - $order_by_query = ( $order == 'ASC' || $order == 'DESC' ) ? "SUM({$wpdb->prefix}betterdocs_analytics.impressions) {$order}" : ( $order == 'MODIFIED' ? "{$wpdb->prefix}posts.post_modified_gmt DESC" : "{$wpdb->prefix}posts.post_date_gmt DESC" );
53 - $clauses['join'] = "JOIN {$wpdb->prefix}betterdocs_analytics ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}betterdocs_analytics.post_id";
54 - $clauses['where'] = "AND ( ( {$wpdb->prefix}posts.post_type = 'docs' ) AND ( {$wpdb->prefix}posts.post_status = 'publish' OR {$wpdb->prefix}posts.post_status = 'future' OR {$wpdb->prefix}posts.post_status = 'draft' OR {$wpdb->prefix}posts.post_status = 'pending' OR {$wpdb->prefix}posts.post_status = 'private' ) )";
55 - $clauses['orderby'] = $order_by_query;
56 - $clauses['groupby'] = "{$wpdb->prefix}betterdocs_analytics.post_id";
73 + $order = isset( $wp_query->query[ 'order' ] ) ? $wp_query->query[ 'order' ] : '';
74 + $order_by_query = ( 'ASC' == $order || 'DESC' == $order ) ? "SUM({$wpdb->prefix}betterdocs_analytics.impressions) {$order}" : ( 'MODIFIED' == $order ? "{$wpdb->prefix}posts.post_modified_gmt DESC" : "{$wpdb->prefix}posts.post_date_gmt DESC" );
75 + $clauses[ 'join' ] = "JOIN {$wpdb->prefix}betterdocs_analytics ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}betterdocs_analytics.post_id";
76 + $clauses[ 'where' ] = ! current_user_can( 'read_private_docs' ) ? "AND ( ( {$wpdb->prefix}posts.post_type = 'docs' ) AND ( {$wpdb->prefix}posts.post_status = 'publish' OR {$wpdb->prefix}posts.post_status = 'future' ) )" : "AND ( ( {$wpdb->prefix}posts.post_type = 'docs' ) AND ( {$wpdb->prefix}posts.post_status = 'publish' OR {$wpdb->prefix}posts.post_status = 'future' OR {$wpdb->prefix}posts.post_status = 'draft' OR {$wpdb->prefix}posts.post_status = 'pending' OR {$wpdb->prefix}posts.post_status = 'private' ) )";
77 + $clauses[ 'orderby' ] = $order_by_query;
78 + $clauses[ 'groupby' ] = "{$wpdb->prefix}betterdocs_analytics.post_id";
79 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
80 + global $sitepress;
81 + if ( $sitepress->is_setup_complete() ) {
82 + $constant_language_code = ICL_LANGUAGE_CODE;
83 + $clauses[ 'join' ] .= " JOIN {$wpdb->prefix}icl_translations ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}icl_translations.element_id";
84 + $clauses[ 'where' ] .= " AND ( {$wpdb->prefix}icl_translations.language_code = '{$constant_language_code}' ) AND ( {$wpdb->prefix}icl_translations.element_type = CONCAT('post_', {$wpdb->prefix}posts.post_type ) )";
85 + }
86 + }
57 87 }
58 88 }
59 89 return $clauses;
60 90 }
@@ -61,33 +91,95 @@
61 91
62 92 public function init() {
63 93 }
64 94
95 + /**
96 + * Modify terms args to include terms whose only docs are private, for users allowed to read them.
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 + *
102 + * @param array $args
103 + * @return array
104 + */
105 + public function modify_terms_args_for_private_docs( $args ) {
106 + // Logged-out visitors always get WordPress' stock `hide_empty` behaviour.
107 + if ( ! is_user_logged_in() || ! isset( $args[ 'taxonomy' ] ) ) {
108 + return $args;
109 + }
110 +
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' ] ) ) {
126 + return $args;
127 + }
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 +
141 + // If hide_empty is true, we need to modify the logic to include terms with private docs
142 + if ( isset( $args[ 'hide_empty' ] ) && $args[ 'hide_empty' ] ) {
143 + // Set hide_empty to false and we'll filter manually later
144 + $args[ 'hide_empty' ] = false;
145 + // Add a flag to indicate we need to filter manually
146 + $args[ '_betterdocs_filter_private' ] = true;
147 + }
148 +
149 + return $args;
150 + }
151 +
65 152 public function parse_term_query( $term_query ) {
66 - if ( empty( $term_query->query_vars['taxonomy'] ) ) {
153 + if ( empty( $term_query->query_vars[ 'taxonomy' ] ) ) {
67 154 return;
68 155 }
69 156
70 - if ( ! in_array( 'doc_category', $term_query->query_vars['taxonomy'], true ) ) {
157 + if ( ! in_array( 'doc_category', $term_query->query_vars[ 'taxonomy' ], true ) ) {
71 158 return;
72 159 }
73 160
74 161 global $current_screen;
75 162
76 - if ( $current_screen == null ) {
163 + if ( null == $current_screen ) {
77 164 return;
78 165 }
79 166
80 - if ( $current_screen->taxonomy !== 'doc_category' || $current_screen->id != 'edit-doc_category' ) {
167 + if ( 'doc_category' !== $current_screen->taxonomy || 'edit-doc_category' != $current_screen->id ) {
81 168 return;
82 169 }
83 170
84 - $term_query->query_vars['meta_query'] = [[
85 - 'key' => 'doc_category_order',
86 - 'type' => 'NUMERIC'
87 - ]];
171 + // Use base meta key for admin listing - fallback logic is handled in set_tax_order
172 + $meta_key = 'doc_category_order';
88 173
89 - $term_query->query_vars['orderby'] = 'meta_value_num';
174 + $term_query->query_vars[ 'meta_query' ] = array(
175 + array(
176 + 'key' => $meta_key,
177 + 'type' => 'NUMERIC'
178 + )
179 + );
180 +
181 + $term_query->query_vars[ 'orderby' ] = 'meta_value_num';
90 182 }
91 183
92 184 public function parse_query( &$query ) {
93 185 // dump( is_single(), $query->query_vars );
@@ -108,9 +200,13 @@
108 200 $query->set( 'posts_per_archive_page', -1 );
109 201
110 202 $term = get_term_by( 'slug', $query->get( 'doc_category', '' ), 'doc_category' );
111 203
112 - $post__in = $this->get_docs_order_by_terms( $term->term_id );
204 + if ( $term && isset( $term->term_id ) ) {
205 + $post__in = $this->get_docs_order_by_terms( $term->term_id );
206 + } else {
207 + $post__in = array();
208 + }
113 209 if ( ! empty( $post__in ) ) {
114 210 $query->set( 'orderby', 'post__in' );
115 211 $query->set( 'post__in', $post__in );
116 212 }
@@ -144,31 +240,42 @@
144 240 * @return array
145 241 */
146 242 public function get_docs_order_by_terms( $term_id ) {
147 243 global $wpdb;
148 - $_docs_order = get_term_meta( $term_id, '_docs_order', true );
149 - $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
150 - $query_key = "docs_order_by_terms_{$term_id}_" . md5( $query );
151 244
152 - if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
245 + // Get language-specific meta key with fallback
246 + $meta_key = \WPDeveloper\BetterDocs\Utils\Helper::get_meta_key_with_fallback( '_docs_order', $term_id );
247 + $_docs_order = get_term_meta( $term_id, $meta_key, true );
248 +
249 + $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
250 + $query_key = "docs_order_by_terms_{$term_id}_{$meta_key}_" . md5( $query );
251 +
252 + if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
153 253 return $results;
154 254 }
155 255
156 256 if ( ! empty( $_docs_order ) ) {
157 257 $_docs_order = explode( ',', $_docs_order );
158 - $new_ids = [];
258 + $new_ids = array();
159 259
160 - $results = $wpdb->get_results( $query );
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
161 262
162 263 if ( is_array( $results ) && ! empty( $results ) ) {
163 - $object_ids = array_filter( $results, function ( $value ) use ( $_docs_order ) {
164 - return ! in_array( $value->object_id, $_docs_order );
165 - } );
264 + $object_ids = array_filter(
265 + $results,
266 + function ( $value ) use ( $_docs_order ) {
267 + return ! in_array( $value->object_id, $_docs_order );
268 + }
269 + );
166 270
167 271 if ( ! empty( $object_ids ) ) {
168 - array_walk( $object_ids, function ( $value ) use ( &$new_ids ) {
169 - $new_ids[] = $value->object_id;
170 - } );
272 + array_walk(
273 + $object_ids,
274 + function ( $value ) use ( &$new_ids ) {
275 + $new_ids[ ] = $value->object_id;
276 + }
277 + );
171 278 }
172 279 }
173 280
174 281 $_docs_order = array_merge( $new_ids, $_docs_order );
@@ -176,9 +283,9 @@
176 283
177 284 return $_docs_order;
178 285 }
179 286
180 - return [];
287 + return array();
181 288 }
182 289
183 290 /**
184 291 * For determine the next post ID
@@ -223,9 +330,9 @@
223 330 * @param mixed $taxonomy
224 331 * @return mixed
225 332 */
226 333 private function get_adjacent_post_id( $adjacent, $where, $post, $taxonomy ) {
227 - if ( $taxonomy !== 'doc_category' ) {
334 + if ( 'doc_category' !== $taxonomy ) {
228 335 return $where;
229 336 }
230 337
231 338 $_id = null;
@@ -235,57 +342,88 @@
235 342 }
236 343
237 344 global $wp_query, $wpdb;
238 345
239 - $_docs_order = $this->get_docs_order_by_terms( $_terms[0]->term_id );
346 + $_docs_order = $this->get_docs_order_by_terms( $_terms[ 0 ]->term_id );
240 347
241 - $_where = explode( 'AND', $where );
242 - if ( is_array( $_where ) ) {
243 - // $_where[0] = str_replace( 'WHERE ', '', $_where[0] );
244 - unset( $_where[0] );
245 - $_where = implode( 'AND', $_where );
246 - }
247 -
248 348 $_orderby = $this->settings->get( 'alphabetically_order_post', 'betterdocs_order' );
249 349 $_order = $this->settings->get( 'docs_order', 'ASC' );
250 - $_docs_order = $_orderby === 'betterdocs_order' ? $_docs_order : [];
350 + $_docs_order = 'betterdocs_order' === $_orderby ? $_docs_order : array();
251 351
252 352 if ( empty( $_docs_order ) ) {
253 - $statuses = [ 'publish' ];
353 + $statuses = array( 'publish' );
254 354
255 - if( is_user_logged_in() ) {
256 - $statuses[] = 'private';
355 + if ( is_user_logged_in() ) {
356 + $statuses[ ] = 'private';
257 357 }
258 358
259 - $_args = [
260 - 'post_status' => $statuses
261 - ];
262 - if ( isset( $wp_query->query_vars['doc_category'] ) ) {
263 - $_args['tax_query'][] = [
264 - 'taxonomy' => 'doc_category',
265 - 'field' => 'slug',
266 - 'terms' => $wp_query->query_vars['doc_category'],
267 - 'operator' => 'AND',
359 + $_args = array(
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',
365 + 'term_id' => $_terms[ 0 ]->term_id
366 + );
367 + if ( isset( $wp_query->query_vars[ 'doc_category' ] ) ) {
368 + $_args[ 'tax_query' ][ ] = array(
369 + 'taxonomy' => 'doc_category',
370 + 'field' => 'slug',
371 + 'terms' => $wp_query->query_vars[ 'doc_category' ],
372 + 'operator' => 'AND',
268 373 'include_children' => false
269 - ];
374 + );
375 + } elseif ( isset( $wp_query->query_vars[ 'name' ] ) && isset( $wp_query->query_vars[ 'post_type' ] ) && 'docs' == $wp_query->query_vars[ 'post_type' ] ) {
376 + $_post = get_page_by_path( $wp_query->query_vars[ 'name' ], OBJECT, 'docs' );
377 + $doc_terms = array();
378 +
379 + if ( isset( $_post->ID ) ) {
380 + $terms = get_the_terms( $_post->ID, 'doc_category' );
381 + if ( ! empty( $terms ) ) {
382 + $doc_terms = wp_list_pluck( $terms, 'term_id' );
383 + }
384 + }
385 +
386 + if ( ! empty( $doc_terms ) ) {
387 + $_args[ 'tax_query' ][ ] = array(
388 + 'taxonomy' => 'doc_category',
389 + 'field' => 'term_id',
390 + 'terms' => $doc_terms,
391 + 'operator' => 'AND',
392 + 'include_children' => false
393 + );
394 + $_args[ 'term_id' ] = $doc_terms[ 0 ];
395 + }
396 + } else {
397 + // Fallback: use the term from current post
398 + $_args[ 'tax_query' ][ ] = array(
399 + 'taxonomy' => 'doc_category',
400 + 'field' => 'term_id',
401 + 'terms' => $_terms[ 0 ]->term_id,
402 + 'operator' => 'AND',
403 + 'include_children' => false
404 + );
270 405 }
271 406
272 - $_args['orderby'] = $_orderby;
273 - if ( $_orderby != 'betterdocs_order' ) {
274 - $_args['order'] = $_order;
407 + $_args[ 'orderby' ] = $_orderby;
408 + if ( 'betterdocs_order' != $_orderby ) {
409 + $_args[ 'order' ] = $_order;
275 410 }
276 411
277 412 /**
278 413 * Before Query
279 414 */
280 - do_action_ref_array( 'betterdocs_navigation_docs_query', [ &$_args] );
415 + do_action_ref_array( 'betterdocs_navigation_docs_query', array( &$_args ) );
281 416 $docs = $this->get_posts( $this->docs_query_args( $_args ) );
282 417
283 - $_docs = [];
418 + $_docs = array();
284 419 if ( $docs->have_posts() ) {
285 - array_map( function ( $post ) use ( &$_docs ) {
286 - $_docs[] = $post->ID;
287 - }, $docs->posts );
420 + array_map(
421 + function ( $_post ) use ( &$_docs ) {
422 + $_docs[ ] = $_post->ID;
423 + },
424 + $docs->posts
425 + );
288 426 }
289 427
290 428 $_docs_order = $_docs;
291 429 }
@@ -291,32 +429,49 @@
291 429 }
292 430
293 431 $_docs_order = apply_filters( 'betterdocs_adjacent_docs_order', $_docs_order, $_terms );
294 432
295 - $_id_index = array_search( $post->ID, $_docs_order );
296 - $_id_index = $adjacent === 'next' ? $_id_index + 1 : $_id_index - 1;
297 - $_id = isset( $_docs_order[$_id_index] ) ? (int) $_docs_order[$_id_index] : null;
433 + // Ensure consistent type comparison by converting all IDs to integers
434 + $_docs_order = array_map( 'intval', $_docs_order );
435 + $_docs_order = array_values( $_docs_order ); // Re-index array after type conversion
298 436
299 - return $wpdb->prepare( "WHERE p.ID = %s AND $_where", (int) $_id );
437 + $_id_index = array_search( (int) $post->ID, $_docs_order, true );
438 +
439 + // If current post not found in order list, return original where clause
440 + if ( false === $_id_index ) {
441 + return $where;
442 + }
443 +
444 + $_id_index = 'next' === $adjacent ? $_id_index + 1 : $_id_index - 1;
445 + $_id = isset( $_docs_order[ $_id_index ] ) ? (int) $_docs_order[ $_id_index ] : null;
446 +
447 + // Fix: replace only ID comparison in where clause using regex
448 + if ( $_id ) {
449 + // This replaces any 'p.ID < N', 'p.ID > N', 'p.ID <= N', or 'p.ID >= N' etc, with 'p.ID = $_id'
450 + $where = preg_replace( '/p\.ID\s*[<>!=]+\s*\d+/', 'p.ID = ' . (int) $_id, $where );
451 + }
452 +
453 + return $where;
300 454 }
301 455
302 - public function parse_terms_args( $args = [] ) {
303 - $_default_args = [
456 + public function parse_terms_args( $args = array() ) {
457 + $_default_args = array(
304 458 'hide_empty' => true,
305 - 'taxonomy' => 'doc_category'
306 - ];
459 + 'taxonomy' => 'doc_category'
460 + );
307 461
308 462 // OrderBy & Order
309 - $_orderby = ! empty( $args['orderby'] ) && $args['orderby'] != 1 ? $args['orderby'] : 'name';
310 - $_order = ! empty( $args['order'] ) ? $args['order'] : '';
463 + $_orderby = ! empty( $args[ 'orderby' ] ) && 1 != $args[ 'orderby' ] ? $args[ 'orderby' ] : 'name';
464 + $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
311 465
312 - if ( $_orderby == 'betterdocs_order' ) {
313 - $args['meta_key'] = 'doc_category_order';
314 - $args['orderby'] = 'meta_value_num';
315 - $args['order'] = 'ASC';
466 + if ( 'betterdocs_order' == $_orderby ) {
467 + // Use base meta key - fallback logic will be handled in the terms_clauses filter
468 + $args[ 'meta_key' ] = 'doc_category_order';
469 + $args[ 'orderby' ] = 'meta_value_num';
470 + $args[ 'order' ] = 'ASC';
316 471 } else {
317 - $args['orderby'] = $_orderby;
318 - $args['order'] = $_order;
472 + $args[ 'orderby' ] = $_orderby;
473 + $args[ 'order' ] = $_order;
319 474 }
320 475
321 476 // Nested Sub Category
322 477 // $args['parent'] = 0;
@@ -322,39 +477,166 @@
322 477 // $args['parent'] = 0;
323 478 // if ( $nested_subcategory == true ) {
324 479 // }
325 480
326 - if ( ! isset( $args['number'] ) ) {
481 + if ( ! isset( $args[ 'number' ] ) ) {
327 482 global $wp_query;
328 - if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
329 - $args['number'] = 4;
483 + if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
484 + $args[ 'number' ] = 4;
330 485 }
331 486 }
332 487
333 488 // Includes
334 - if ( isset( $args['include'] ) ) {
335 - $_include = ! is_array( $args['include'] ) ? explode( ',', $args['include'] ) : $args['include'];
336 - $args['include'] = $_include;
337 - $args['orderby'] = 'include';
489 + if ( isset( $args[ 'include' ] ) ) {
490 + $_include = ! is_array( $args[ 'include' ] ) ? explode( ',', $args[ 'include' ] ) : $args[ 'include' ];
491 + $args[ 'include' ] = $_include;
492 + $args[ 'orderby' ] = 'include';
338 493
339 - unset( $args['parent'] );
494 + unset( $args[ 'parent' ] );
340 495 }
341 496
342 - $_meta_query = ! empty( $args['meta_query'] ) ? $args['meta_query'] : [];
343 - $args['meta_query'] = apply_filters( 'betterdocs_taxonomy_object_meta_query', $_meta_query, $args );
497 + $_meta_query = ! empty( $args[ 'meta_query' ] ) ? $args[ 'meta_query' ] : array();
498 + $args[ 'meta_query' ] = apply_filters( 'betterdocs_taxonomy_object_meta_query', $_meta_query, $args );
344 499
345 500 return apply_filters( 'betterdocs_category_terms_object', wp_parse_args( $args, $_default_args ), $args );
346 501 }
347 502
348 503 public function get_terms( $args ) {
349 - return get_terms( $this->parse_terms_args( $args ) );
504 + $parsed_args = $this->parse_terms_args( $args );
505 + $terms = get_terms( $parsed_args );
506 +
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() ) {
509 + $terms = array_filter( $terms, function ( $term ) {
510 + // Get the actual count including private docs for users with read_private_docs capability
511 + return $this->get_private_inclusive_docs_count( $term ) > 0;
512 + } );
513 + }
514 +
515 + return $terms;
350 516 }
351 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 +
352 634 public function get_child_terms( $args ) {
353 - if ( ! isset( $args['number'] ) ) {
635 + if ( ! isset( $args[ 'number' ] ) ) {
354 636 global $wp_query;
355 - if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
356 - $args['number'] = 1;
637 + if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
638 + $args[ 'number' ] = 1;
357 639 }
358 640 }
359 641
360 642 return $this->get_terms( $args );
@@ -377,9 +659,9 @@
377 659 public function get_taxonomy( $tax = '' ) {
378 660 global $wp_query;
379 661 if ( is_tax( 'knowledge_base' ) ) {
380 662 $_tax = $wp_query->tax_query->queried_terms;
381 - if ( array_key_exists( "doc_category", $_tax ) ) {
663 + if ( array_key_exists( 'doc_category', $_tax ) ) {
382 664 $tax = 'doc_category';
383 665 } else {
384 666 $tax = 'knowledge_base';
385 667 }
@@ -389,17 +671,17 @@
389 671
390 672 return $tax;
391 673 }
392 674
393 - public function terms_query( $args = [] ) {
675 + public function terms_query( $args = array() ) {
394 676 global $wp_query;
395 677 $_origin_args = $args;
396 678
397 - $default_args = [
679 + $default_args = array(
398 680 'hide_empty' => true,
399 - 'taxonomy' => 'doc_category',
400 - 'orderby' => 'name'
401 - ];
681 + 'taxonomy' => 'doc_category',
682 + 'orderby' => 'name'
683 + );
402 684
403 685 /**
404 686 * Number Set
405 687 *
@@ -411,11 +693,11 @@
411 693
412 694 /**
413 695 * Nested Sub Category
414 696 */
415 - if ( isset( $args['nested_subcategory'] ) && $args['nested_subcategory'] == true ) {
416 - $default_args['parent'] = 0;
417 - unset( $args['nested_subcategory'] );
697 + if ( isset( $args[ 'nested_subcategory' ] ) && true == $args[ 'nested_subcategory' ] ) {
698 + $default_args[ 'parent' ] = 0;
699 + unset( $args[ 'nested_subcategory' ] );
418 700 // if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
419 701 // $default_args['number'] = 4;
420 702 // }
421 703 }
@@ -422,27 +704,32 @@
422 704
423 705 /**
424 706 * OrderBy and Order
425 707 */
426 - if ( ! isset( $args['orderby'] ) ) {
708 + if ( ! isset( $args[ 'orderby' ] ) ) {
427 709 $_orderby = $this->settings->get( 'terms_orderby', 'name' );
428 710 $_order = $this->settings->get( 'terms_order', '' );
429 711 } else {
430 - $_orderby = $args['orderby'];
431 - $_order = ! empty( $args['order'] ) ? $args['order'] : '';
712 + $_orderby = $args[ 'orderby' ];
713 + $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
432 714 }
433 715
434 716 if ( 'betterdocs_order' === $_orderby ) {
435 - $default_args['meta_key'] = 'doc_category_order';
436 - $_orderby = 'meta_value_num';
437 - $_order = 'ASC';
438 - } elseif ( $_orderby === true ) {
717 + // Use different meta keys for different taxonomies
718 + if ( isset( $args[ 'taxonomy' ] ) && 'knowledge_base' === $args[ 'taxonomy' ] ) {
719 + $default_args[ 'meta_key' ] = 'kb_order';
720 + } else {
721 + $default_args[ 'meta_key' ] = 'doc_category_order';
722 + }
723 + $_orderby = 'meta_value_num';
724 + $_order = 'ASC';
725 + } elseif ( true === $_orderby ) {
439 726 $_orderby = 'name';
440 727 }
441 728
442 - $args['orderby'] = $_orderby;
443 - if( ! empty( $_order ) ) {
444 - $args['order'] = $_order;
729 + $args[ 'orderby' ] = $_orderby;
730 + if ( ! empty( $_order ) ) {
731 + $args[ 'order' ] = $_order;
445 732 }
446 733
447 734 /**
448 735 * @todo old hook
@@ -449,79 +736,84 @@
449 736 * hook: betterdocs_child_taxonomy_meta_query
450 737 */
451 738 $_multiple_kb = apply_filters(
452 739 'betterdocs_query_args_multiple_kb_enabled',
453 - isset( $args['multiple_kb'] ) ? (bool) $args['multiple_kb'] : false,
740 + isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
454 741 $_origin_args
455 742 );
456 743
457 - $_kb_slug = isset( $args['kb_slug'] ) ? trim( $args['kb_slug'] ) : '';
744 + $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
458 745
459 - unset( $args['multiple_kb'] );
460 - unset( $args['kb_slug'] );
746 + unset( $args[ 'multiple_kb' ] );
747 + unset( $args[ 'kb_slug' ] );
461 748
462 - $meta_query = ! empty( $args['meta_query'] ) ? $args['meta_query'] : [];
749 + $meta_query = ! empty( $args[ 'meta_query' ] ) ? $args[ 'meta_query' ] : array();
463 750 $meta_query = apply_filters( 'betterdocs_terms_meta_query_args', $meta_query, $_multiple_kb, $_kb_slug, $_origin_args );
464 751
465 752 if ( ! empty( $meta_query ) ) {
466 - $default_args['meta_query'] = $meta_query;
753 + $default_args[ 'meta_query' ] = $meta_query;
467 754 }
468 755
469 - if ( ! empty( $args['terms'] ) ) {
470 - $args['include'] = explode( ',', $args['terms'] );
471 - $args['orderby'] = 'include';
472 - $args['order'] = 'ASC';
756 + if ( ! empty( $args[ 'terms' ] ) ) {
757 + $args[ 'include' ] = explode( ',', $args[ 'terms' ] );
758 + $args[ 'orderby' ] = 'include';
759 + $args[ 'order' ] = 'ASC';
473 760
474 - unset( $default_args['parent'] );
475 - unset( $args['parent'] );
476 - unset( $args['terms'] );
761 + unset( $default_args[ 'parent' ] );
762 + unset( $args[ 'parent' ] );
763 + unset( $args[ 'terms' ] );
477 764 }
478 765
479 766 $_query_args = wp_parse_args( $args, $default_args );
480 - return apply_filters( 'betterdocs_terms_query_args', $_query_args, $_origin_args );
767 + $_query_args = apply_filters( 'betterdocs_terms_query_args', $_query_args, $_origin_args );
768 +
769 + // Apply private docs logic for logged-in users
770 + $_query_args = $this->modify_terms_args_for_private_docs( $_query_args );
771 +
772 + return $_query_args;
481 773 }
482 774
483 - public function get_term_parents( $term_id, $taxonomy = 'doc_category', $args = [] ) {
775 + public function get_term_parents( $term_id, $taxonomy = 'doc_category', $args = array() ) {
484 776 $term = get_term( $term_id, $taxonomy );
485 777 if ( is_wp_error( $term ) ) {
486 778 return $term;
487 779 }
488 780
489 - if ( ! $term ) {
490 - return [];
781 + if ( ! $term || ! isset( $term->term_id ) ) {
782 + return array();
491 783 }
492 784
493 - $_lists = [];
785 + $_lists = array();
494 786 $origin_term = $term_id;
495 787 $term_id = $term->term_id;
496 788
497 - $defaults = [
498 - 'format' => 'name',
789 + $defaults = array(
790 + 'format' => 'name',
499 791 'inclusive' => true
500 - ];
792 + );
501 793
502 794 $args = wp_parse_args( $args, $defaults );
503 795
504 - $args['inclusive'] = wp_validate_boolean( $args['inclusive'] );
796 + $args[ 'inclusive' ] = wp_validate_boolean( $args[ 'inclusive' ] );
505 797
506 798 $parents = get_ancestors( $term_id, $taxonomy );
507 799
508 - if ( $args['inclusive'] ) {
800 + if ( $args[ 'inclusive' ] ) {
509 801 array_unshift( $parents, $term_id );
510 802 }
511 803
512 804 foreach ( array_reverse( $parents ) as $term_id ) {
513 805 $parent = get_term( $term_id, $taxonomy );
514 - $name = ( 'slug' === $args['format'] ) ? $parent->slug : $parent->name;
806 + $name = ( 'slug' === $args[ 'format' ] ) ? $parent->slug : $parent->name;
515 807 $term_permalink = get_term_link( $parent->term_id, $taxonomy );
516 808 $term_permalink = apply_filters( 'betterdocs_breadcrumb_term_permalink', $term_permalink, $term_id );
517 809
518 - $_item = [
519 - 'url' => $term_permalink,
810 + $_item = array(
811 + 'url' => $term_permalink,
520 812 'text' => $name
521 - ];
813 + );
522 814
523 - $_lists[] = $_item;
815 + $_lists[ ] = $_item;
524 816 }
525 817
526 818 return apply_filters( 'betterdocs_breadcrumb_archive_lists', $_lists, $origin_term );
527 819 }
@@ -526,52 +818,140 @@
526 818 return apply_filters( 'betterdocs_breadcrumb_archive_lists', $_lists, $origin_term );
527 819 }
528 820
529 821 /**
822 + * Get all non-empty child term IDs recursively for a given taxonomy and parent term.
823 + *
824 + * This function retrieves all child terms for a specified taxonomy and parent term,
825 + * recursively fetching child terms of child terms, and returns only those with a non-zero post count.
826 + *
827 + * @param string $taxonomy The taxonomy name (e.g., 'doc_category').
828 + * @param int $parent_id The ID of the parent term to start retrieving children from.
829 + *
830 + * @return array An array of non-empty child term IDs.
831 + */
832 + public function get_all_child_term_ids( $taxonomy, $parent_id ) {
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.
844 + $args = apply_filters(
845 + 'betterdocs_get_child_term_ids_args',
846 + array(
847 + 'taxonomy' => $taxonomy,
848 + 'child_of' => $parent_id,
849 + 'hide_empty' => true,
850 + 'fields' => 'ids',
851 + )
852 + );
853 +
854 + $ids = get_terms( $args );
855 + $ids = ( is_wp_error( $ids ) || ! is_array( $ids ) ) ? array() : array_map( 'intval', $ids );
856 +
857 + wp_cache_set( $cache_key, $ids, 'betterdocs', HOUR_IN_SECONDS * 6 );
858 +
859 + return $ids;
860 + }
861 +
862 + /**
863 + * Get all nested child term IDs of a specific parent term in a taxonomy and return as a comma-separated string.
864 + *
865 + * @param string $taxonomy The taxonomy.
866 + * @param int $parent_id The parent term ID.
867 + * @return string The comma-separated term IDs.
868 + */
869 + public function get_child_term_ids_by_parent_id( $taxonomy, $parent_id ) {
870 + $term_ids = $this->get_all_child_term_ids( $taxonomy, $parent_id );
871 + return implode( ',', $term_ids );
872 + }
873 +
874 + public function get_terms_children( $taxonomy, $parent_id ) {
875 + $children = get_term_children( $parent_id, $taxonomy );
876 + $non_empty_children = array();
877 + if ( ! is_wp_error( $children ) && ! empty( $children ) ) {
878 + foreach ( $children as $child_id ) {
879 + $child_term = get_term( $child_id, $taxonomy );
880 +
881 + // Only include non-empty terms
882 + if ( $child_term && $child_term->count > 0 ) {
883 + $non_empty_children[ ] = $child_term;
884 + }
885 + }
886 + }
887 +
888 + return $non_empty_children;
889 + }
890 +
891 + public function get_terms_children_ids( $taxonomy, $parent_id ) {
892 + $children = $this->get_terms_children( $taxonomy, $parent_id );
893 + $term_ids = wp_list_pluck( $children, 'term_id' );
894 +
895 + return implode( ',', $term_ids );
896 + }
897 +
898 + public function count_terms_children( $taxonomy, $parent_id ) {
899 + return count( $this->get_terms_children( $taxonomy, $parent_id ) );
900 + }
901 +
902 + public function is_inner_templates() {
903 + if ( is_tax( 'knowledge_base' ) || is_tax( 'doc_category' ) || is_tax( 'doc_tag' ) || is_singular( 'docs' ) ) {
904 + return true;
905 + }
906 + return false;
907 + }
908 +
909 + /**
530 910 * Summary of docs_query_args
531 911 * @param mixed $args
532 912 * @throws \Exception
533 913 * @return mixed
534 914 */
535 - public function docs_query_args( $args, $filter = [] ) {
915 + public function docs_query_args( $args, $filter = array() ) {
536 916 $_origin_args = $args;
537 917
538 - $default_args = [
918 + $default_args = array(
539 919 'post_type' => 'docs'
540 - ];
920 + );
541 921
542 - if ( ! empty( $args['post_type'] ) && trim( $args['post_type'] ) === 'docs_any' ) {
543 - $default_args['post_type'] = 'docs';
544 - $default_args['post_status'] = 'any';
922 + if ( ! empty( $args[ 'post_type' ] ) && trim( $args[ 'post_type' ] ) === 'docs_any' ) {
923 + $default_args[ 'post_type' ] = 'docs';
924 + $default_args[ 'post_status' ] = 'any';
545 925
546 - unset( $args['post_type'] );
926 + unset( $args[ 'post_type' ] );
547 927 }
548 928
549 929 /**
550 930 * OrderBy and Order
551 931 */
552 - if ( ! isset( $args['orderby'] ) ) {
932 + if ( ! isset( $args[ 'orderby' ] ) ) {
553 933 $_orderby = $this->settings->get( 'alphabetically_order_post' );
554 934 $_order = $this->settings->get( 'docs_order', 'ASC' );
555 935 } else {
556 - $_orderby = $args['orderby'];
557 - $_order = ! empty( $args['order'] ) ? $args['order'] : 'ASC';
936 + $_orderby = $args[ 'orderby' ];
937 + $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : 'ASC';
558 938 }
559 939
560 940 if ( 'betterdocs_order' != $_orderby ) {
561 - if ( $_orderby === true ) {
562 - $args['orderby'] = 'title';
941 + if ( true === $_orderby ) {
942 + $args[ 'orderby' ] = 'title';
563 943 } else {
564 - $args['orderby'] = $_orderby;
944 + $args[ 'orderby' ] = $_orderby;
565 945 }
566 946
567 - $args['order'] = $_order;
947 + $args[ 'order' ] = $_order;
568 948 } elseif ( 'betterdocs_order' == $_orderby ) {
569 - unset( $args['orderby'] );
949 + unset( $args[ 'orderby' ] );
570 950 }
571 951
572 - if ( empty( $args['orderby'] ) ) {
573 - unset( $args['order'] );
952 + if ( empty( $args[ 'orderby' ] ) ) {
953 + unset( $args[ 'order' ] );
574 954 }
575 955
576 956 /**
577 957 * Term ID
@@ -576,11 +956,11 @@
576 956 /**
577 957 * Term ID
578 958 */
579 959 $_term_id = null;
580 - if ( ! empty( $args['term_id'] ) ) {
581 - $_term_id = intval( $args['term_id'] );
582 - unset( $args['term_id'] );
960 + if ( ! empty( $args[ 'term_id' ] ) ) {
961 + $_term_id = intval( $args[ 'term_id' ] );
962 + unset( $args[ 'term_id' ] );
583 963 }
584 964
585 965 // if ( $_term_id == null ) {
586 966 // throw new \Exception( __( '$args["term_id"] cannot be null.', 'betterdocs' ) );
@@ -589,11 +969,11 @@
589 969 /**
590 970 * Term Slug for tax_query
591 971 */
592 972 $_term_slug = '';
593 - if ( ! empty( $args['term_slug'] ) ) {
594 - $_term_slug = trim( $args['term_slug'] );
595 - unset( $args['term_slug'] );
973 + if ( ! empty( $args[ 'term_slug' ] ) ) {
974 + $_term_slug = trim( $args[ 'term_slug' ] );
975 + unset( $args[ 'term_slug' ] );
596 976 }
597 977
598 978 /**
599 979 * @todo old hook
@@ -601,34 +981,44 @@
601 981 */
602 982
603 983 $_multiple_kb = apply_filters(
604 984 'betterdocs_enable_multiple_knowledge_base',
605 - isset( $args['multiple_kb'] ) ? (bool) $args['multiple_kb'] : false,
985 + isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
606 986 $_origin_args
607 987 );
608 988
609 - $_kb_slug = isset( $args['kb_slug'] ) ? trim( $args['kb_slug'] ) : '';
989 + $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
610 990
611 - unset( $args['multiple_kb'] );
612 - unset( $args['kb_slug'] );
991 + unset( $args[ 'multiple_kb' ] );
992 + unset( $args[ 'kb_slug' ] );
613 993
614 - $tax_query = [
615 - [
616 - 'taxonomy' => 'doc_category',
617 - 'field' => 'slug',
618 - 'terms' => $_term_slug,
619 - 'operator' => 'AND',
994 + $tax_query = array(
995 + array(
996 + 'taxonomy' => 'doc_category',
997 + 'field' => 'slug',
998 + 'terms' => $_term_slug,
999 + 'operator' => 'AND',
620 1000 'include_children' => false
621 - ]
622 - ];
1001 + )
1002 + );
623 1003
624 - if ( isset( $args['tax_query'] ) ) {
625 - $tax_query = $args['tax_query'];
1004 + if ( ! isset( $args[ 'orderby' ] ) || 'betterdocs_order' == $args[ 'orderby' ] ) {
1005 + $args[ 'orderby' ] = 'post__in';
1006 + $args[ 'post__in' ] = $this->get_docs_order_by_terms( $_term_id );
626 1007 }
627 1008
628 - $args['tax_query'] = apply_filters(
1009 + if ( isset( $args[ 'tax_query' ] ) ) {
1010 + $tax_query = $args[ 'tax_query' ];
1011 + }
1012 +
1013 + $args[ 'tax_query' ] = apply_filters(
629 1014 'betterdocs_docs_tax_query_args',
630 - $tax_query, $_multiple_kb, $_term_slug, $_kb_slug, $_origin_args
1015 + $tax_query,
1016 + $_multiple_kb,
1017 + $_term_slug,
1018 + $_kb_slug,
1019 + $_origin_args,
1020 + $this->is_inner_templates()
631 1021 );
632 1022 /**
633 1023 * Final parse args
634 1024 */
@@ -635,68 +1025,157 @@
635 1025 $args = wp_parse_args( $args, $default_args );
636 1026
637 1027 if ( ! empty( $filter ) ) {
638 1028 $filter = array_flip( $filter );
639 - $args = array_filter( $args, function ( $item ) use ( $filter ) {
640 - return ! array_key_exists( $item, $filter );
641 - }, ARRAY_FILTER_USE_KEY );
1029 + $args = array_filter(
1030 + $args,
1031 + function ( $item ) use ( $filter ) {
1032 + return ! array_key_exists( $item, $filter );
1033 + },
1034 + ARRAY_FILTER_USE_KEY
1035 + );
642 1036 }
643 1037
644 - return apply_filters( 'betterdocs_articles_args', $args, $_term_id, $_origin_args );
1038 + $final_args = apply_filters( 'betterdocs_articles_args', $args, $_term_id, $_origin_args );
1039 +
1040 + // Process betterdocs_order AFTER all filters have run
1041 + if ( isset( $final_args[ 'orderby' ] ) && 'betterdocs_order' == $final_args[ 'orderby' ] ) {
1042 + $docs_order = $this->get_docs_order_by_terms( $_term_id );
1043 +
1044 + if ( ! empty( $docs_order ) ) {
1045 + $final_args[ 'orderby' ] = 'post__in';
1046 + $final_args[ 'post__in' ] = $docs_order;
1047 + } else {
1048 + $final_args[ 'orderby' ] = 'menu_order';
1049 + $final_args[ 'order' ] = 'ASC';
1050 + }
1051 + }
1052 +
1053 + return $final_args;
645 1054 }
646 1055
647 - public function faq_terms_query_args( $includes = '', $excludes = '', $args = [] ) {
648 - $_args = [
649 - 'taxonomy' => 'betterdocs_faq_category',
650 - 'meta_key' => 'order',
651 - 'orderby' => 'meta_value_num',
652 - 'order' => 'ASC',
653 - 'include' => $includes,
654 - 'exclude' => $excludes,
655 - 'meta_query' => [
656 - [
657 - 'key' => 'status',
658 - 'value' => 1,
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' ) {
1103 + $_args = array(
1104 + 'taxonomy' => $taxonomy,
1105 + 'include' => $includes,
1106 + 'exclude' => $excludes,
1107 + 'meta_query' => array(
1108 + array(
1109 + 'key' => 'status',
1110 + 'value' => 1,
659 1111 'compare' => '=='
660 - ]
661 - ]
662 - ];
1112 + )
1113 + )
1114 + );
663 1115
664 - if ( $_args['include'] == 'all' ) {
665 - unset( $_args['include'] );
666 - unset( $_args['exclude'] );
1116 + $_args = array_merge( $_args, $this->faq_terms_order_clause() );
1117 +
1118 + if ( 'all' == $_args[ 'include' ] ) {
1119 + unset( $_args[ 'include' ] );
1120 + unset( $_args[ 'exclude' ] );
667 1121 }
668 1122
669 - if ( empty( $_args['exclude'] ) ) {
670 - unset( $_args['exclude'] );
1123 + if ( empty( $_args[ 'exclude' ] ) ) {
1124 + unset( $_args[ 'exclude' ] );
671 1125 }
672 1126
673 - if ( empty( $_args['include'] ) ) {
674 - unset( $_args['include'] );
1127 + if ( empty( $_args[ 'include' ] ) ) {
1128 + unset( $_args[ 'include' ] );
675 1129 }
676 1130
677 1131 return wp_parse_args( $args, $_args );
678 1132 }
679 1133
680 - public function get_faq_by_term( $term_id ) {
1134 + public function get_faq_by_term( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
681 1135 global $wpdb;
682 1136
683 - $args = [
684 - 'post_type' => 'betterdocs_faq',
685 - 'post_status' => 'publish',
686 - 'tax_query' => [
687 - [
688 - 'taxonomy' => 'betterdocs_faq_category',
689 - 'field' => 'term_id',
690 - 'terms' => $term_id,
1137 + $args = array(
1138 + 'post_type' => 'betterdocs_faq',
1139 + 'post_status' => 'publish',
1140 + 'tax_query' => array(
1141 + array(
1142 + 'taxonomy' => $taxonomy,
1143 + 'field' => 'term_id',
1144 + 'terms' => $term_id,
691 1145 'operator' => 'AND'
692 - ]
693 - ],
1146 + )
1147 + ),
694 1148 'posts_per_page' => -1
695 - ];
1149 + );
696 1150
697 - $args['orderby'] = 'post__in';
698 - $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 + }
699 1178
700 1179 return new WP_Query( $args );
701 1180 }
702 1181
@@ -705,27 +1184,34 @@
705 1184 $faq_order = get_term_meta( $term_id, '_betterdocs_faq_order', true );
706 1185 $faq_order = explode( ',', $faq_order );
707 1186
708 1187 $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
709 - $query_key = "betterdocs_faq_order_" . md5( $query );
1188 + $query_key = 'betterdocs_faq_order_' . md5( $query );
710 1189
711 - if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
1190 + if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
712 1191 return $results;
713 1192 }
714 1193
715 1194 if ( ! empty( $faq_order ) ) {
716 - $new_ids = [];
717 - $results = $wpdb->get_results( $query );
1195 + $new_ids = array();
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
718 1198
719 1199 if ( ! is_null( $results ) && ! empty( $results ) && is_array( $results ) ) {
720 - $object_ids = array_filter( $results, function ( $value ) use ( $faq_order ) {
721 - return ! in_array( $value->object_id, $faq_order );
722 - } );
1200 + $object_ids = array_filter(
1201 + $results,
1202 + function ( $value ) use ( $faq_order ) {
1203 + return ! in_array( $value->object_id, $faq_order );
1204 + }
1205 + );
723 1206
724 1207 if ( ! empty( $object_ids ) ) {
725 - array_walk( $object_ids, function ( $value ) use ( &$new_ids ) {
726 - $new_ids[] = $value->object_id;
727 - } );
1208 + array_walk(
1209 + $object_ids,
1210 + function ( $value ) use ( &$new_ids ) {
1211 + $new_ids[ ] = $value->object_id;
1212 + }
1213 + );
728 1214 }
729 1215 }
730 1216
731 1217 $faq_order = array_merge( $new_ids, $faq_order );
@@ -735,26 +1221,28 @@
735 1221
736 1222 return $faq_order;
737 1223 }
738 1224
739 - public function get_faq_terms( $terms = [] ) {
740 - $_terms = get_terms( [
741 - 'taxonomy' => 'betterdocs_faq_category',
742 - 'hide_empty' => true,
743 - 'orderby' => 'name',
744 - 'order' => 'ASC',
745 - 'meta_query' => [
746 - [
747 - 'key' => 'status',
748 - 'value' => 1,
749 - 'compare' => '=='
750 - ]
751 - ]
752 - ] );
1225 + public function get_faq_terms( $terms = array(), $taxonomy = 'betterdocs_faq_category' ) {
1226 + $_terms = get_terms(
1227 + array(
1228 + 'taxonomy' => $taxonomy,
1229 + 'hide_empty' => true,
1230 + 'orderby' => 'name',
1231 + 'order' => 'ASC',
1232 + 'meta_query' => array(
1233 + array(
1234 + 'key' => 'status',
1235 + 'value' => 1,
1236 + 'compare' => '=='
1237 + )
1238 + )
1239 + )
1240 + );
753 1241
754 1242 if ( ! is_wp_error( $_terms ) ) {
755 1243 foreach ( $_terms as $term ) {
756 - $terms[$term->term_id] = $term->name;
1244 + $terms[ $term->term_id ] = $term->name;
757 1245 }
758 1246 }
759 1247
760 1248 return $terms;
@@ -759,31 +1247,152 @@
759 1247
760 1248 return $terms;
761 1249 }
762 1250
763 - public function get_docs_count( $term, $nested_subcategory = false, $args = [] ) {
1251 + public function get_doc_terms( $terms = array() ) {
1252 + $_terms = get_terms(
1253 + array(
1254 + 'taxonomy' => 'doc_category',
1255 + 'hide_empty' => true,
1256 + 'orderby' => 'name',
1257 + 'order' => 'ASC'
1258 + )
1259 + );
1260 +
1261 + if ( ! is_wp_error( $_terms ) ) {
1262 + foreach ( $_terms as $term ) {
1263 + $terms[ $term->term_id ] = $term->name;
1264 + }
1265 + }
1266 +
1267 + return $terms;
1268 + }
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 +
1309 + public function get_docs_count( $term, $nested_subcategory = false, $args = array() ) {
1310 + // Validate term object
1311 + if ( ! is_object( $term ) ) {
1312 + return 0;
1313 + }
1314 +
764 1315 $counts = isset( $term->count ) ? $term->count : 0;
765 1316
766 - if ( $nested_subcategory == false ) {
1317 + if ( ! isset( $term->term_id ) || ! is_numeric( $term->term_id ) ) {
767 1318 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
768 1319 }
769 1320
770 - $_child_terms_docs_ids = $this->get_doc_ids_by_term( $term, null, $nested_subcategory );
771 - if ( is_array( $_child_terms_docs_ids ) ) {
772 - $counts = count( $_child_terms_docs_ids );
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 );
773 1333 }
774 1334
1335 + if ( false == $nested_subcategory ) {
1336 + // For non-nested categories, we need to recalculate counts based on user capabilities
1337 + // Only proceed if we have a valid term with required properties
1338 + if ( isset( $term->taxonomy ) ) {
1339 + // Get all post IDs for this term
1340 + $post_ids = get_objects_in_term( $term->term_id, $term->taxonomy );
1341 +
1342 + if ( ! empty( $post_ids ) ) {
1343 + _prime_post_caches( $post_ids, false, false );
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 +
1351 + $counts = count( $filtered_post_ids );
1352 + } else {
1353 + $counts = 0;
1354 + }
1355 + }
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 + }
1361 + }
1362 +
1363 + wp_cache_set( $cache_key, $counts, 'betterdocs', HOUR_IN_SECONDS * 6 );
1364 +
775 1365 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
776 1366 }
777 1367
778 1368 public function get_doc_ids_by_term( $term, $optional = null, $nested_subcategory = false ) {
779 - $args = [ 'include' => $term->term_id ];
780 - if( $nested_subcategory ) {
781 - $args['child_of'] = $term->term_id;
782 - unset( $args['include'] );
1369 + // Check if term has required properties and is a valid object
1370 + if ( ! is_object( $term ) || ! isset( $term->term_id ) || ! isset( $term->taxonomy ) || ! is_numeric( $term->term_id ) ) {
1371 + return false;
783 1372 }
784 - $_child_terms = get_terms( $term->taxonomy, $args );
785 1373
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 + );
1389 + if ( $nested_subcategory ) {
1390 + $args[ 'child_of' ] = $term->term_id;
1391 + unset( $args[ 'include' ] );
1392 + }
1393 + $_child_terms = get_terms( $args );
1394 +
786 1395 if ( ! is_array( $_child_terms ) ) {
787 1396 return false;
788 1397 }
789 1398
@@ -792,19 +1401,268 @@
792 1401 $_child_terms_ids = array_column( $_child_terms, 'term_id' );
793 1402 $_child_terms_taxs = array_column( $_child_terms, 'taxonomy' );
794 1403 $_child_terms_docs_ids = get_objects_in_term( $_child_terms_ids, $_child_terms_taxs );
795 1404
796 - if ( $optional !== null ) {
1405 + if ( null !== $optional ) {
797 1406 $_optional_doc_ids = get_objects_in_term( $optional->term_id, $optional->taxonomy );
798 1407 $_child_terms_docs_ids = array_intersect( $_child_terms_docs_ids, $_optional_doc_ids );
799 1408 }
800 1409
801 - return array_filter( $_child_terms_docs_ids, function ( $doc_id ) {
802 - if ( ! is_user_logged_in() ) {
803 - return is_post_publicly_viewable( $doc_id );
1410 + if ( ! empty( $_child_terms_docs_ids ) ) {
1411 + _prime_post_caches( $_child_terms_docs_ids, false, false );
1412 + }
1413 +
1414 + $filtered = array_filter( $_child_terms_docs_ids, function ( $doc_id ) {
1415 + return $this->can_read_doc( $doc_id );
1416 + } );
1417 +
1418 + wp_cache_set( $cache_key, $filtered, 'betterdocs', HOUR_IN_SECONDS * 6 );
1419 +
1420 + return $filtered;
1421 + }
1422 +
1423 + /**
1424 + * Get the common query arguments for WP_Query.
1425 + *
1426 + * @param string $terms The taxonomy.
1427 + * @param string $term_slug The taxonomy term slug.
1428 + * @param array $additional_args Additional arguments to merge with the common arguments.
1429 + * @return array The query arguments.
1430 + */
1431 + private function tax_query_args( $terms, $term_slug, $additional_args = array() ) {
1432 + $common_args = array(
1433 + 'post_type' => 'docs',
1434 + 'post_status' => 'publish',
1435 + 'tax_query' => array(
1436 + array(
1437 + 'taxonomy' => $terms,
1438 + 'field' => 'slug',
1439 + 'terms' => $term_slug
1440 + )
1441 + )
1442 + );
1443 + return array_merge( $common_args, $additional_args );
1444 + }
1445 +
1446 + /**
1447 + * Get the latest updated date for a specific taxonomy term.
1448 + *
1449 + * @param string $terms The taxonomy.
1450 + * @param string $term_slug The taxonomy term slug.
1451 + * @return string|null The latest modified date or null if no posts found.
1452 + */
1453 + public function latest_updated_date( $terms, $term_slug ) {
1454 + $args = $this->tax_query_args(
1455 + $terms,
1456 + $term_slug,
1457 + array(
1458 + 'posts_per_page' => 1,
1459 + 'orderby' => 'modified',
1460 + 'order' => 'DESC'
1461 + )
1462 + );
1463 +
1464 + $query = new WP_Query( $args );
1465 +
1466 + if ( $query->have_posts() ) {
1467 + while ( $query->have_posts() ) {
1468 + $query->the_post();
1469 + $latest_post = get_the_modified_date();
1470 + wp_reset_postdata();
1471 + return $latest_post;
804 1472 }
1473 + } else {
1474 + return null;
1475 + }
1476 + }
805 1477
806 - $_status = get_post_status( $doc_id );
807 - return $_status == 'private' || is_post_publicly_viewable( $doc_id );
808 - } );
1478 + /**
1479 + * Check if there are any new posts within the last 7 days for a specific taxonomy term.
1480 + *
1481 + * @param string $terms The taxonomy.
1482 + * @param string $term_slug The taxonomy term slug.
1483 + * @return bool True if there are new posts, false otherwise.
1484 + */
1485 + public function check_new_posts( $terms, $term_slug ) {
1486 + $date_7_days_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
1487 +
1488 + $args = $this->tax_query_args(
1489 + $terms,
1490 + $term_slug,
1491 + array(
1492 + 'posts_per_page' => 1,
1493 + 'orderby' => 'modified',
1494 + 'order' => 'DESC',
1495 + 'date_query' => array(
1496 + array(
1497 + 'after' => $date_7_days_ago,
1498 + 'inclusive' => true
1499 + )
1500 + )
1501 + )
1502 + );
1503 +
1504 + $query = new WP_Query( $args );
1505 +
1506 + $has_new_posts = $query->have_posts();
1507 +
1508 + wp_reset_postdata();
1509 +
1510 + return $has_new_posts;
1511 + }
1512 +
1513 + public function insert_search_keyword( $search_input, $input_not_found ) {
1514 + if ( empty( $search_input ) ) {
1515 + return false;
1516 + }
1517 +
1518 + global $wpdb;
1519 +
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.
1528 + $search = $wpdb->get_results(
1529 + $wpdb->prepare(
1530 + "SELECT *
1531 + FROM {$wpdb->prefix}betterdocs_search_keyword
1532 + WHERE keyword_hash = %s AND BINARY keyword = %s",
1533 + $keyword_hash,
1534 + $search_input
1535 + )
1536 + );
1537 +
1538 + if ( ! empty( $search ) ) {
1539 + $search_log = $wpdb->get_results(
1540 + $wpdb->prepare(
1541 + "SELECT *
1542 + FROM {$wpdb->prefix}betterdocs_search_log
1543 + WHERE created_at = %s AND keyword_id = %d",
1544 + gmdate( 'Y-m-d' ),
1545 + $search[ 0 ]->id
1546 + )
1547 + );
1548 +
1549 + if ( ! empty( $search_log ) ) {
1550 + if ( ! empty( $input_not_found ) ) {
1551 + $tbl_field = 'not_found_count';
1552 + $count = $search_log[ 0 ]->not_found_count + 1;
1553 + } else {
1554 + $tbl_field = 'count';
1555 + $count = $search_log[ 0 ]->count + 1;
1556 + }
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.
1560 + $insert = $wpdb->query(
1561 + $wpdb->prepare(
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,
1564 + $search_log[ 0 ]->created_at,
1565 + $search_log[ 0 ]->keyword_id
1566 + )
1567 + );
1568 + } else {
1569 + if ( ! empty( $input_not_found ) ) {
1570 + $count = 0;
1571 + $not_found_count = 1;
1572 + } else {
1573 + $count = 1;
1574 + $not_found_count = 0;
1575 + }
1576 + $insert = $wpdb->query(
1577 + $wpdb->prepare(
1578 + "INSERT INTO {$wpdb->prefix}betterdocs_search_log
1579 + ( keyword_id, count, not_found_count, created_at )
1580 + VALUES ( %d, %d, %d, %s )",
1581 + array(
1582 + $search[ 0 ]->id,
1583 + $count,
1584 + $not_found_count,
1585 + gmdate( 'Y-m-d' )
1586 + )
1587 + )
1588 + );
1589 + }
1590 + } else {
1591 + $insert = $wpdb->query(
1592 + $wpdb->prepare(
1593 + "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
1594 + ( keyword, keyword_hash )
1595 + VALUES ( %s, %s )",
1596 + array(
1597 + $search_input,
1598 + $keyword_hash
1599 + )
1600 + )
1601 + );
1602 +
1603 + if ( $insert ) {
1604 + if ( ! empty( $input_not_found ) ) {
1605 + $count = 0;
1606 + $not_found_count = 1;
1607 + } else {
1608 + $count = 1;
1609 + $not_found_count = 0;
1610 + }
1611 + $insert = $wpdb->query(
1612 + $wpdb->prepare(
1613 + "INSERT INTO {$wpdb->prefix}betterdocs_search_log
1614 + ( keyword_id, count, not_found_count, created_at )
1615 + VALUES ( %d, %d, %d, %s )",
1616 + array(
1617 + $wpdb->insert_id,
1618 + $count,
1619 + $not_found_count,
1620 + gmdate( 'Y-m-d' )
1621 + )
1622 + )
1623 + );
1624 + }
1625 + }
1626 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1627 + return $insert;
1628 + }
1629 +
1630 + /**
1631 + * Counts the number of 'doc_category' terms assigned to a specific 'knowledge_base' by slug.
1632 + *
1633 + * This method retrieves all terms in the 'doc_category' taxonomy and checks the term meta
1634 + * 'doc_category_knowledge_base' to determine how many 'doc_category' terms are associated
1635 + * with a given 'knowledge_base' slug.
1636 + *
1637 + * @param string $knowledge_base_slug The slug of the 'knowledge_base' to count the assignments for.
1638 + *
1639 + * @return int The count of 'doc_category' terms assigned to the specified 'knowledge_base'.
1640 + */
1641 + public function count_doc_categories_for_knowledge_base( $knowledge_base_slug ) {
1642 + $count = 0;
1643 +
1644 + $doc_categories = get_terms(
1645 + array(
1646 + 'taxonomy' => 'doc_category',
1647 + 'hide_empty' => true
1648 + )
1649 + );
1650 +
1651 + if ( ! empty( $doc_categories ) && ! is_wp_error( $doc_categories ) ) {
1652 + foreach ( $doc_categories as $term ) {
1653 + $meta_value = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1654 +
1655 + if ( $meta_value ) {
1656 + $knowledge_bases = maybe_unserialize( $meta_value );
1657 +
1658 + // Check if the specific knowledge base slug is present in the meta value array
1659 + if ( is_array( $knowledge_bases ) && in_array( $knowledge_base_slug, $knowledge_bases ) ) {
1660 + ++$count;
1661 + }
1662 + }
1663 + }
1664 + }
1665 +
1666 + return $count;
809 1667 }
810 1668 }