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
betterdocs / includes / Core / Query.php

Query.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Core/Query.php

1,669 lines 62.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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;
5
6 if ( ! defined( 'ABSPATH' ) ) {
7 exit;
8 }
9
10
11 use WP_Query;
12 use WPDeveloper\BetterDocs\Utils\Base;
13 use WPDeveloper\BetterDocs\Utils\Database;
14 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
15
16 class Query extends Base {
17 private $container;
18 protected $database;
19 protected $settings;
20
21 public function __construct( Container $container, Database $database, Settings $settings ) {
22 $this->container = $container;
23 $this->database = $database;
24 $this->settings = $settings;
25
26 add_action( 'parse_term_query', array( $this, 'parse_term_query' ) );
27 // add_action( 'parse_query', [$this, 'parse_query'], 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 );
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
46 /**
47 * These below filters are hooked for navigation only.
48 *
49 * For old version of this portion.
50 * @see `betterdocs_single_post_nav` filter
51 *
52 * For details:
53 * @see https://developer.wordpress.org/reference/functions/get_next_post/
54 * @see https://developer.wordpress.org/reference/functions/get_previous_post/
55 *
56 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
57 */
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 );
60
61 $this->init();
62
63 /**
64 * Modify Popular Docs Query (For Shortcode & Widget)
65 */
66 add_filter( 'posts_clauses', array( $this, 'mod_query_popular_docs' ), 10, 2 );
67 }
68
69 public function mod_query_popular_docs( $clauses, $wp_query ) {
70 if ( isset( $wp_query->query[ 'meta_key' ] ) ) {
71 if ( '_betterdocs_meta_views' == $wp_query->query[ 'meta_key' ] ) {
72 global $wpdb;
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 }
87 }
88 }
89 return $clauses;
90 }
91
92 public function init() {
93 }
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
152 public function parse_term_query( $term_query ) {
153 if ( empty( $term_query->query_vars[ 'taxonomy' ] ) ) {
154 return;
155 }
156
157 if ( ! in_array( 'doc_category', $term_query->query_vars[ 'taxonomy' ], true ) ) {
158 return;
159 }
160
161 global $current_screen;
162
163 if ( null == $current_screen ) {
164 return;
165 }
166
167 if ( 'doc_category' !== $current_screen->taxonomy || 'edit-doc_category' != $current_screen->id ) {
168 return;
169 }
170
171 // Use base meta key for admin listing - fallback logic is handled in set_tax_order
172 $meta_key = 'doc_category_order';
173
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';
182 }
183
184 public function parse_query( &$query ) {
185 // dump( is_single(), $query->query_vars );
186 // if ( is_single() && isset( $query->query_vars['post_type'] ) && $query->query_vars['post_type'] == 'docs' ) {
187 // $query->is_single = false;
188 // $query->is_archive = true;
189 // $query->set( 'knowledge_base', $query->query_vars['docs'] );
190 // }
191 }
192
193 public function pre_get_posts( &$query ) {
194 if ( is_admin() || ! $query->is_main_query() ) {
195 return;
196 }
197
198 if ( is_tax( 'doc_category' ) ) {
199 $query->set( 'post_type', 'docs' );
200 $query->set( 'posts_per_archive_page', -1 );
201
202 $term = get_term_by( 'slug', $query->get( 'doc_category', '' ), 'doc_category' );
203
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 }
209 if ( ! empty( $post__in ) ) {
210 $query->set( 'orderby', 'post__in' );
211 $query->set( 'post__in', $post__in );
212 }
213
214 // if ( ! empty( $query->query_vars['knowledge_base'] ) ) {
215 // $query->betterdocs_terms = get_terms( [
216 // 'taxonomy' => 'doc_category',
217 // 'parent' => 0,
218 // 'hide_empty' => true,
219 // 'meta_query' => [
220 // 'relation' => 'OR',
221 // [
222 // 'key' => 'doc_category_knowledge_base',
223 // 'value' => $query->query_vars['knowledge_base'],
224 // 'compare' => 'LIKE'
225 // ]
226 // ]
227 // ] );
228 // }
229 }
230
231 // dump( is_archive(), $query->query_vars );
232 }
233
234 /**
235 * Get docs orders by term_id.
236 *
237 * @since 2.5.0
238 * @param int $term_id
239 *
240 * @return array
241 */
242 public function get_docs_order_by_terms( $term_id ) {
243 global $wpdb;
244
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 ) {
253 return $results;
254 }
255
256 if ( ! empty( $_docs_order ) ) {
257 $_docs_order = explode( ',', $_docs_order );
258 $new_ids = array();
259
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
262
263 if ( is_array( $results ) && ! empty( $results ) ) {
264 $object_ids = array_filter(
265 $results,
266 function ( $value ) use ( $_docs_order ) {
267 return ! in_array( $value->object_id, $_docs_order );
268 }
269 );
270
271 if ( ! empty( $object_ids ) ) {
272 array_walk(
273 $object_ids,
274 function ( $value ) use ( &$new_ids ) {
275 $new_ids[ ] = $value->object_id;
276 }
277 );
278 }
279 }
280
281 $_docs_order = array_merge( $new_ids, $_docs_order );
282 $this->database->set_cache( $query_key, $_docs_order, 1 );
283
284 return $_docs_order;
285 }
286
287 return array();
288 }
289
290 /**
291 * For determine the next post ID
292 *
293 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
294 *
295 * @param mixed $where
296 * @param mixed $in_same_term
297 * @param mixed $excluded_terms
298 * @param mixed $taxonomy
299 * @param mixed $post
300 * @return mixed
301 */
302 public function next_post_where( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {
303 return $this->get_adjacent_post_id( 'next', $where, $post, $taxonomy );
304 }
305
306 /**
307 * For determine the previous post ID
308 *
309 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
310 *
311 * @param mixed $where
312 * @param mixed $in_same_term
313 * @param mixed $excluded_terms
314 * @param mixed $taxonomy
315 * @param mixed $post
316 * @return mixed
317 */
318 public function previous_post_where( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {
319 return $this->get_adjacent_post_id( 'previous', $where, $post, $taxonomy );
320 }
321
322 /**
323 * Get where clause for next/previous post ID mainly used in post navigation.
324 *
325 * @see `previous_post_where` and `next_post_where` methods.
326 *
327 * @param mixed $adjacent
328 * @param mixed $where
329 * @param mixed $post
330 * @param mixed $taxonomy
331 * @return mixed
332 */
333 private function get_adjacent_post_id( $adjacent, $where, $post, $taxonomy ) {
334 if ( 'doc_category' !== $taxonomy ) {
335 return $where;
336 }
337
338 $_id = null;
339 $_terms = get_the_terms( $post->ID, 'doc_category' );
340 if ( empty( $_terms ) ) {
341 return $where;
342 }
343
344 global $wp_query, $wpdb;
345
346 $_docs_order = $this->get_docs_order_by_terms( $_terms[ 0 ]->term_id );
347
348 $_orderby = $this->settings->get( 'alphabetically_order_post', 'betterdocs_order' );
349 $_order = $this->settings->get( 'docs_order', 'ASC' );
350 $_docs_order = 'betterdocs_order' === $_orderby ? $_docs_order : array();
351
352 if ( empty( $_docs_order ) ) {
353 $statuses = array( 'publish' );
354
355 if ( is_user_logged_in() ) {
356 $statuses[ ] = 'private';
357 }
358
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',
373 'include_children' => false
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 );
405 }
406
407 $_args[ 'orderby' ] = $_orderby;
408 if ( 'betterdocs_order' != $_orderby ) {
409 $_args[ 'order' ] = $_order;
410 }
411
412 /**
413 * Before Query
414 */
415 do_action_ref_array( 'betterdocs_navigation_docs_query', array( &$_args ) );
416 $docs = $this->get_posts( $this->docs_query_args( $_args ) );
417
418 $_docs = array();
419 if ( $docs->have_posts() ) {
420 array_map(
421 function ( $_post ) use ( &$_docs ) {
422 $_docs[ ] = $_post->ID;
423 },
424 $docs->posts
425 );
426 }
427
428 $_docs_order = $_docs;
429 }
430
431 $_docs_order = apply_filters( 'betterdocs_adjacent_docs_order', $_docs_order, $_terms );
432
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
436
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;
454 }
455
456 public function parse_terms_args( $args = array() ) {
457 $_default_args = array(
458 'hide_empty' => true,
459 'taxonomy' => 'doc_category'
460 );
461
462 // OrderBy & Order
463 $_orderby = ! empty( $args[ 'orderby' ] ) && 1 != $args[ 'orderby' ] ? $args[ 'orderby' ] : 'name';
464 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
465
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';
471 } else {
472 $args[ 'orderby' ] = $_orderby;
473 $args[ 'order' ] = $_order;
474 }
475
476 // Nested Sub Category
477 // $args['parent'] = 0;
478 // if ( $nested_subcategory == true ) {
479 // }
480
481 if ( ! isset( $args[ 'number' ] ) ) {
482 global $wp_query;
483 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
484 $args[ 'number' ] = 4;
485 }
486 }
487
488 // Includes
489 if ( isset( $args[ 'include' ] ) ) {
490 $_include = ! is_array( $args[ 'include' ] ) ? explode( ',', $args[ 'include' ] ) : $args[ 'include' ];
491 $args[ 'include' ] = $_include;
492 $args[ 'orderby' ] = 'include';
493
494 unset( $args[ 'parent' ] );
495 }
496
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 );
499
500 return apply_filters( 'betterdocs_category_terms_object', wp_parse_args( $args, $_default_args ), $args );
501 }
502
503 public function get_terms( $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;
516 }
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
634 public function get_child_terms( $args ) {
635 if ( ! isset( $args[ 'number' ] ) ) {
636 global $wp_query;
637 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
638 $args[ 'number' ] = 1;
639 }
640 }
641
642 return $this->get_terms( $args );
643 }
644
645 /**
646 * Get POSTs of Docs type.
647 *
648 * @param mixed $args
649 * @return WP_Query
650 */
651 public function get_posts( $args, $ignore = false ) {
652 if ( ! $ignore ) {
653 $args = $this->docs_query_args( $args );
654 }
655
656 return new WP_Query( $args );
657 }
658
659 public function get_taxonomy( $tax = '' ) {
660 global $wp_query;
661 if ( is_tax( 'knowledge_base' ) ) {
662 $_tax = $wp_query->tax_query->queried_terms;
663 if ( array_key_exists( 'doc_category', $_tax ) ) {
664 $tax = 'doc_category';
665 } else {
666 $tax = 'knowledge_base';
667 }
668 } elseif ( is_tax( 'doc_category' ) ) {
669 $tax = 'doc_category';
670 }
671
672 return $tax;
673 }
674
675 public function terms_query( $args = array() ) {
676 global $wp_query;
677 $_origin_args = $args;
678
679 $default_args = array(
680 'hide_empty' => true,
681 'taxonomy' => 'doc_category',
682 'orderby' => 'name'
683 );
684
685 /**
686 * Number Set
687 *
688 * @FIX: If Built-in Docs page off and docs_page in use, then Terms Query Number 4|1 set.
689 */
690 // if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
691 // $default_args['number'] = 1;
692 // }
693
694 /**
695 * Nested Sub Category
696 */
697 if ( isset( $args[ 'nested_subcategory' ] ) && true == $args[ 'nested_subcategory' ] ) {
698 $default_args[ 'parent' ] = 0;
699 unset( $args[ 'nested_subcategory' ] );
700 // if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
701 // $default_args['number'] = 4;
702 // }
703 }
704
705 /**
706 * OrderBy and Order
707 */
708 if ( ! isset( $args[ 'orderby' ] ) ) {
709 $_orderby = $this->settings->get( 'terms_orderby', 'name' );
710 $_order = $this->settings->get( 'terms_order', '' );
711 } else {
712 $_orderby = $args[ 'orderby' ];
713 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
714 }
715
716 if ( 'betterdocs_order' === $_orderby ) {
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 ) {
726 $_orderby = 'name';
727 }
728
729 $args[ 'orderby' ] = $_orderby;
730 if ( ! empty( $_order ) ) {
731 $args[ 'order' ] = $_order;
732 }
733
734 /**
735 * @todo old hook
736 * hook: betterdocs_child_taxonomy_meta_query
737 */
738 $_multiple_kb = apply_filters(
739 'betterdocs_query_args_multiple_kb_enabled',
740 isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
741 $_origin_args
742 );
743
744 $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
745
746 unset( $args[ 'multiple_kb' ] );
747 unset( $args[ 'kb_slug' ] );
748
749 $meta_query = ! empty( $args[ 'meta_query' ] ) ? $args[ 'meta_query' ] : array();
750 $meta_query = apply_filters( 'betterdocs_terms_meta_query_args', $meta_query, $_multiple_kb, $_kb_slug, $_origin_args );
751
752 if ( ! empty( $meta_query ) ) {
753 $default_args[ 'meta_query' ] = $meta_query;
754 }
755
756 if ( ! empty( $args[ 'terms' ] ) ) {
757 $args[ 'include' ] = explode( ',', $args[ 'terms' ] );
758 $args[ 'orderby' ] = 'include';
759 $args[ 'order' ] = 'ASC';
760
761 unset( $default_args[ 'parent' ] );
762 unset( $args[ 'parent' ] );
763 unset( $args[ 'terms' ] );
764 }
765
766 $_query_args = wp_parse_args( $args, $default_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;
773 }
774
775 public function get_term_parents( $term_id, $taxonomy = 'doc_category', $args = array() ) {
776 $term = get_term( $term_id, $taxonomy );
777 if ( is_wp_error( $term ) ) {
778 return $term;
779 }
780
781 if ( ! $term || ! isset( $term->term_id ) ) {
782 return array();
783 }
784
785 $_lists = array();
786 $origin_term = $term_id;
787 $term_id = $term->term_id;
788
789 $defaults = array(
790 'format' => 'name',
791 'inclusive' => true
792 );
793
794 $args = wp_parse_args( $args, $defaults );
795
796 $args[ 'inclusive' ] = wp_validate_boolean( $args[ 'inclusive' ] );
797
798 $parents = get_ancestors( $term_id, $taxonomy );
799
800 if ( $args[ 'inclusive' ] ) {
801 array_unshift( $parents, $term_id );
802 }
803
804 foreach ( array_reverse( $parents ) as $term_id ) {
805 $parent = get_term( $term_id, $taxonomy );
806 $name = ( 'slug' === $args[ 'format' ] ) ? $parent->slug : $parent->name;
807 $term_permalink = get_term_link( $parent->term_id, $taxonomy );
808 $term_permalink = apply_filters( 'betterdocs_breadcrumb_term_permalink', $term_permalink, $term_id );
809
810 $_item = array(
811 'url' => $term_permalink,
812 'text' => $name
813 );
814
815 $_lists[ ] = $_item;
816 }
817
818 return apply_filters( 'betterdocs_breadcrumb_archive_lists', $_lists, $origin_term );
819 }
820
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 /**
910 * Summary of docs_query_args
911 * @param mixed $args
912 * @throws \Exception
913 * @return mixed
914 */
915 public function docs_query_args( $args, $filter = array() ) {
916 $_origin_args = $args;
917
918 $default_args = array(
919 'post_type' => 'docs'
920 );
921
922 if ( ! empty( $args[ 'post_type' ] ) && trim( $args[ 'post_type' ] ) === 'docs_any' ) {
923 $default_args[ 'post_type' ] = 'docs';
924 $default_args[ 'post_status' ] = 'any';
925
926 unset( $args[ 'post_type' ] );
927 }
928
929 /**
930 * OrderBy and Order
931 */
932 if ( ! isset( $args[ 'orderby' ] ) ) {
933 $_orderby = $this->settings->get( 'alphabetically_order_post' );
934 $_order = $this->settings->get( 'docs_order', 'ASC' );
935 } else {
936 $_orderby = $args[ 'orderby' ];
937 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : 'ASC';
938 }
939
940 if ( 'betterdocs_order' != $_orderby ) {
941 if ( true === $_orderby ) {
942 $args[ 'orderby' ] = 'title';
943 } else {
944 $args[ 'orderby' ] = $_orderby;
945 }
946
947 $args[ 'order' ] = $_order;
948 } elseif ( 'betterdocs_order' == $_orderby ) {
949 unset( $args[ 'orderby' ] );
950 }
951
952 if ( empty( $args[ 'orderby' ] ) ) {
953 unset( $args[ 'order' ] );
954 }
955
956 /**
957 * Term ID
958 */
959 $_term_id = null;
960 if ( ! empty( $args[ 'term_id' ] ) ) {
961 $_term_id = intval( $args[ 'term_id' ] );
962 unset( $args[ 'term_id' ] );
963 }
964
965 // if ( $_term_id == null ) {
966 // throw new \Exception( __( '$args["term_id"] cannot be null.', 'betterdocs' ) );
967 // }
968
969 /**
970 * Term Slug for tax_query
971 */
972 $_term_slug = '';
973 if ( ! empty( $args[ 'term_slug' ] ) ) {
974 $_term_slug = trim( $args[ 'term_slug' ] );
975 unset( $args[ 'term_slug' ] );
976 }
977
978 /**
979 * @todo old hook
980 * hook: betterdocs_cat_template_multikb
981 */
982
983 $_multiple_kb = apply_filters(
984 'betterdocs_enable_multiple_knowledge_base',
985 isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
986 $_origin_args
987 );
988
989 $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
990
991 unset( $args[ 'multiple_kb' ] );
992 unset( $args[ 'kb_slug' ] );
993
994 $tax_query = array(
995 array(
996 'taxonomy' => 'doc_category',
997 'field' => 'slug',
998 'terms' => $_term_slug,
999 'operator' => 'AND',
1000 'include_children' => false
1001 )
1002 );
1003
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 );
1007 }
1008
1009 if ( isset( $args[ 'tax_query' ] ) ) {
1010 $tax_query = $args[ 'tax_query' ];
1011 }
1012
1013 $args[ 'tax_query' ] = apply_filters(
1014 'betterdocs_docs_tax_query_args',
1015 $tax_query,
1016 $_multiple_kb,
1017 $_term_slug,
1018 $_kb_slug,
1019 $_origin_args,
1020 $this->is_inner_templates()
1021 );
1022 /**
1023 * Final parse args
1024 */
1025 $args = wp_parse_args( $args, $default_args );
1026
1027 if ( ! empty( $filter ) ) {
1028 $filter = array_flip( $filter );
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 );
1036 }
1037
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;
1054 }
1055
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,
1111 'compare' => '=='
1112 )
1113 )
1114 );
1115
1116 $_args = array_merge( $_args, $this->faq_terms_order_clause() );
1117
1118 if ( 'all' == $_args[ 'include' ] ) {
1119 unset( $_args[ 'include' ] );
1120 unset( $_args[ 'exclude' ] );
1121 }
1122
1123 if ( empty( $_args[ 'exclude' ] ) ) {
1124 unset( $_args[ 'exclude' ] );
1125 }
1126
1127 if ( empty( $_args[ 'include' ] ) ) {
1128 unset( $_args[ 'include' ] );
1129 }
1130
1131 return wp_parse_args( $args, $_args );
1132 }
1133
1134 public function get_faq_by_term( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1135 global $wpdb;
1136
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,
1145 'operator' => 'AND'
1146 )
1147 ),
1148 'posts_per_page' => -1
1149 );
1150
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 }
1178
1179 return new WP_Query( $args );
1180 }
1181
1182 public function get_faq_orders( $term_id = null ) {
1183 global $wpdb;
1184 $faq_order = get_term_meta( $term_id, '_betterdocs_faq_order', true );
1185 $faq_order = explode( ',', $faq_order );
1186
1187 $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
1188 $query_key = 'betterdocs_faq_order_' . md5( $query );
1189
1190 if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
1191 return $results;
1192 }
1193
1194 if ( ! empty( $faq_order ) ) {
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
1198
1199 if ( ! is_null( $results ) && ! empty( $results ) && is_array( $results ) ) {
1200 $object_ids = array_filter(
1201 $results,
1202 function ( $value ) use ( $faq_order ) {
1203 return ! in_array( $value->object_id, $faq_order );
1204 }
1205 );
1206
1207 if ( ! empty( $object_ids ) ) {
1208 array_walk(
1209 $object_ids,
1210 function ( $value ) use ( &$new_ids ) {
1211 $new_ids[ ] = $value->object_id;
1212 }
1213 );
1214 }
1215 }
1216
1217 $faq_order = array_merge( $new_ids, $faq_order );
1218 }
1219
1220 $this->database->set_cache( $query_key, $faq_order, 1 );
1221
1222 return $faq_order;
1223 }
1224
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 );
1241
1242 if ( ! is_wp_error( $_terms ) ) {
1243 foreach ( $_terms as $term ) {
1244 $terms[ $term->term_id ] = $term->name;
1245 }
1246 }
1247
1248 return $terms;
1249 }
1250
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
1315 $counts = isset( $term->count ) ? $term->count : 0;
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
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
1365 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1366 }
1367
1368 public function get_doc_ids_by_term( $term, $optional = null, $nested_subcategory = false ) {
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;
1372 }
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
1395 if ( ! is_array( $_child_terms ) ) {
1396 return false;
1397 }
1398
1399 array_unshift( $_child_terms, $term );
1400
1401 $_child_terms_ids = array_column( $_child_terms, 'term_id' );
1402 $_child_terms_taxs = array_column( $_child_terms, 'taxonomy' );
1403 $_child_terms_docs_ids = get_objects_in_term( $_child_terms_ids, $_child_terms_taxs );
1404
1405 if ( null !== $optional ) {
1406 $_optional_doc_ids = get_objects_in_term( $optional->term_id, $optional->taxonomy );
1407 $_child_terms_docs_ids = array_intersect( $_child_terms_docs_ids, $_optional_doc_ids );
1408 }
1409
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;
1472 }
1473 } else {
1474 return null;
1475 }
1476 }
1477
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;
1667 }
1668 }
1669