PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
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 3.5.2 All 199 releases
betterdocs / includes / Core / Query.php

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

1,496 lines 55.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
31 // Invalidate cached doc-category counts on any write that could change them.
32 add_action( 'save_post_docs', array( $this, 'flush_term_counts_cache' ) );
33 add_action( 'deleted_post', array( $this, 'flush_term_counts_cache_on_post' ), 10, 2 );
34 add_action( 'edited_doc_category', array( $this, 'flush_term_counts_cache' ) );
35 add_action( 'created_doc_category', array( $this, 'flush_term_counts_cache' ) );
36 add_action( 'delete_doc_category', array( $this, 'flush_term_counts_cache' ) );
37 add_action( 'set_object_terms', array( $this, 'flush_term_counts_cache_on_set' ), 10, 4 );
38
39 /**
40 * These below filters are hooked for navigation only.
41 *
42 * For old version of this portion.
43 * @see `betterdocs_single_post_nav` filter
44 *
45 * For details:
46 * @see https://developer.wordpress.org/reference/functions/get_next_post/
47 * @see https://developer.wordpress.org/reference/functions/get_previous_post/
48 *
49 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
50 */
51 add_filter( 'get_next_post_where', array( $this, 'next_post_where' ), 99, 5 );
52 add_filter( 'get_previous_post_where', array( $this, 'previous_post_where' ), 99, 5 );
53
54 $this->init();
55
56 /**
57 * Modify Popular Docs Query (For Shortcode & Widget)
58 */
59 add_filter( 'posts_clauses', array( $this, 'mod_query_popular_docs' ), 10, 2 );
60 }
61
62 public function mod_query_popular_docs( $clauses, $wp_query ) {
63 if ( isset( $wp_query->query[ 'meta_key' ] ) ) {
64 if ( '_betterdocs_meta_views' == $wp_query->query[ 'meta_key' ] ) {
65 global $wpdb;
66 $order = isset( $wp_query->query[ 'order' ] ) ? $wp_query->query[ 'order' ] : '';
67 $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" );
68 $clauses[ 'join' ] = "JOIN {$wpdb->prefix}betterdocs_analytics ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}betterdocs_analytics.post_id";
69 $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' ) )";
70 $clauses[ 'orderby' ] = $order_by_query;
71 $clauses[ 'groupby' ] = "{$wpdb->prefix}betterdocs_analytics.post_id";
72 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
73 global $sitepress;
74 if ( $sitepress->is_setup_complete() ) {
75 $constant_language_code = ICL_LANGUAGE_CODE;
76 $clauses[ 'join' ] .= " JOIN {$wpdb->prefix}icl_translations ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}icl_translations.element_id";
77 $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 ) )";
78 }
79 }
80 }
81 }
82 return $clauses;
83 }
84
85 public function init() {
86 }
87
88 /**
89 * Modify terms args to include terms with only private docs for users with read_private_docs capability
90 *
91 * @param array $args
92 * @return array
93 */
94 public function modify_terms_args_for_private_docs( $args ) {
95 // Only modify for users with read_private_docs capability and supported taxonomies
96 if ( ! current_user_can( 'read_private_docs' ) || ! isset( $args[ 'taxonomy' ] ) ) {
97 return $args;
98 }
99
100 // Only support doc_category taxonomy for private docs filtering
101 // knowledge_base terms don't have docs directly assigned to them
102 $supported_taxonomies = array( 'doc_category' );
103 if ( ! in_array( $args[ 'taxonomy' ], $supported_taxonomies ) ) {
104 return $args;
105 }
106
107 // If hide_empty is true, we need to modify the logic to include terms with private docs
108 if ( isset( $args[ 'hide_empty' ] ) && $args[ 'hide_empty' ] ) {
109 // Set hide_empty to false and we'll filter manually later
110 $args[ 'hide_empty' ] = false;
111 // Add a flag to indicate we need to filter manually
112 $args[ '_betterdocs_filter_private' ] = true;
113 }
114
115 return $args;
116 }
117
118 public function parse_term_query( $term_query ) {
119 if ( empty( $term_query->query_vars[ 'taxonomy' ] ) ) {
120 return;
121 }
122
123 if ( ! in_array( 'doc_category', $term_query->query_vars[ 'taxonomy' ], true ) ) {
124 return;
125 }
126
127 global $current_screen;
128
129 if ( null == $current_screen ) {
130 return;
131 }
132
133 if ( 'doc_category' !== $current_screen->taxonomy || 'edit-doc_category' != $current_screen->id ) {
134 return;
135 }
136
137 // Use base meta key for admin listing - fallback logic is handled in set_tax_order
138 $meta_key = 'doc_category_order';
139
140 $term_query->query_vars[ 'meta_query' ] = array(
141 array(
142 'key' => $meta_key,
143 'type' => 'NUMERIC'
144 )
145 );
146
147 $term_query->query_vars[ 'orderby' ] = 'meta_value_num';
148 }
149
150 public function parse_query( &$query ) {
151 // dump( is_single(), $query->query_vars );
152 // if ( is_single() && isset( $query->query_vars['post_type'] ) && $query->query_vars['post_type'] == 'docs' ) {
153 // $query->is_single = false;
154 // $query->is_archive = true;
155 // $query->set( 'knowledge_base', $query->query_vars['docs'] );
156 // }
157 }
158
159 public function pre_get_posts( &$query ) {
160 if ( is_admin() || ! $query->is_main_query() ) {
161 return;
162 }
163
164 if ( is_tax( 'doc_category' ) ) {
165 $query->set( 'post_type', 'docs' );
166 $query->set( 'posts_per_archive_page', -1 );
167
168 $term = get_term_by( 'slug', $query->get( 'doc_category', '' ), 'doc_category' );
169
170 if ( $term && isset( $term->term_id ) ) {
171 $post__in = $this->get_docs_order_by_terms( $term->term_id );
172 } else {
173 $post__in = array();
174 }
175 if ( ! empty( $post__in ) ) {
176 $query->set( 'orderby', 'post__in' );
177 $query->set( 'post__in', $post__in );
178 }
179
180 // if ( ! empty( $query->query_vars['knowledge_base'] ) ) {
181 // $query->betterdocs_terms = get_terms( [
182 // 'taxonomy' => 'doc_category',
183 // 'parent' => 0,
184 // 'hide_empty' => true,
185 // 'meta_query' => [
186 // 'relation' => 'OR',
187 // [
188 // 'key' => 'doc_category_knowledge_base',
189 // 'value' => $query->query_vars['knowledge_base'],
190 // 'compare' => 'LIKE'
191 // ]
192 // ]
193 // ] );
194 // }
195 }
196
197 // dump( is_archive(), $query->query_vars );
198 }
199
200 /**
201 * Get docs orders by term_id.
202 *
203 * @since 2.5.0
204 * @param int $term_id
205 *
206 * @return array
207 */
208 public function get_docs_order_by_terms( $term_id ) {
209 global $wpdb;
210
211 // Get language-specific meta key with fallback
212 $meta_key = \WPDeveloper\BetterDocs\Utils\Helper::get_meta_key_with_fallback( '_docs_order', $term_id );
213 $_docs_order = get_term_meta( $term_id, $meta_key, true );
214
215 $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
216 $query_key = "docs_order_by_terms_{$term_id}_{$meta_key}_" . md5( $query );
217
218 if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
219 return $results;
220 }
221
222 if ( ! empty( $_docs_order ) ) {
223 $_docs_order = explode( ',', $_docs_order );
224 $new_ids = array();
225
226 // $query is prepared upstream; results are cached via $this->database->get_cache above (line 210).
227 $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
228
229 if ( is_array( $results ) && ! empty( $results ) ) {
230 $object_ids = array_filter(
231 $results,
232 function ( $value ) use ( $_docs_order ) {
233 return ! in_array( $value->object_id, $_docs_order );
234 }
235 );
236
237 if ( ! empty( $object_ids ) ) {
238 array_walk(
239 $object_ids,
240 function ( $value ) use ( &$new_ids ) {
241 $new_ids[ ] = $value->object_id;
242 }
243 );
244 }
245 }
246
247 $_docs_order = array_merge( $new_ids, $_docs_order );
248 $this->database->set_cache( $query_key, $_docs_order, 1 );
249
250 return $_docs_order;
251 }
252
253 return array();
254 }
255
256 /**
257 * For determine the next post ID
258 *
259 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
260 *
261 * @param mixed $where
262 * @param mixed $in_same_term
263 * @param mixed $excluded_terms
264 * @param mixed $taxonomy
265 * @param mixed $post
266 * @return mixed
267 */
268 public function next_post_where( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {
269 return $this->get_adjacent_post_id( 'next', $where, $post, $taxonomy );
270 }
271
272 /**
273 * For determine the previous post ID
274 *
275 * @link https://developer.wordpress.org/reference/hooks/get_adjacent_post_where/
276 *
277 * @param mixed $where
278 * @param mixed $in_same_term
279 * @param mixed $excluded_terms
280 * @param mixed $taxonomy
281 * @param mixed $post
282 * @return mixed
283 */
284 public function previous_post_where( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {
285 return $this->get_adjacent_post_id( 'previous', $where, $post, $taxonomy );
286 }
287
288 /**
289 * Get where clause for next/previous post ID mainly used in post navigation.
290 *
291 * @see `previous_post_where` and `next_post_where` methods.
292 *
293 * @param mixed $adjacent
294 * @param mixed $where
295 * @param mixed $post
296 * @param mixed $taxonomy
297 * @return mixed
298 */
299 private function get_adjacent_post_id( $adjacent, $where, $post, $taxonomy ) {
300 if ( 'doc_category' !== $taxonomy ) {
301 return $where;
302 }
303
304 $_id = null;
305 $_terms = get_the_terms( $post->ID, 'doc_category' );
306 if ( empty( $_terms ) ) {
307 return $where;
308 }
309
310 global $wp_query, $wpdb;
311
312 $_docs_order = $this->get_docs_order_by_terms( $_terms[ 0 ]->term_id );
313
314 $_orderby = $this->settings->get( 'alphabetically_order_post', 'betterdocs_order' );
315 $_order = $this->settings->get( 'docs_order', 'ASC' );
316 $_docs_order = 'betterdocs_order' === $_orderby ? $_docs_order : array();
317
318 if ( empty( $_docs_order ) ) {
319 $statuses = array( 'publish' );
320
321 if ( current_user_can( 'read_private_docs' ) ) {
322 $statuses[ ] = 'private';
323 }
324
325 $_args = array(
326 'post_status' => $statuses,
327 'term_id' => $_terms[ 0 ]->term_id
328 );
329 if ( isset( $wp_query->query_vars[ 'doc_category' ] ) ) {
330 $_args[ 'tax_query' ][ ] = array(
331 'taxonomy' => 'doc_category',
332 'field' => 'slug',
333 'terms' => $wp_query->query_vars[ 'doc_category' ],
334 'operator' => 'AND',
335 'include_children' => false
336 );
337 } elseif ( isset( $wp_query->query_vars[ 'name' ] ) && isset( $wp_query->query_vars[ 'post_type' ] ) && 'docs' == $wp_query->query_vars[ 'post_type' ] ) {
338 $_post = get_page_by_path( $wp_query->query_vars[ 'name' ], OBJECT, 'docs' );
339 $doc_terms = array();
340
341 if ( isset( $_post->ID ) ) {
342 $terms = get_the_terms( $_post->ID, 'doc_category' );
343 if ( ! empty( $terms ) ) {
344 $doc_terms = wp_list_pluck( $terms, 'term_id' );
345 }
346 }
347
348 if ( ! empty( $doc_terms ) ) {
349 $_args[ 'tax_query' ][ ] = array(
350 'taxonomy' => 'doc_category',
351 'field' => 'term_id',
352 'terms' => $doc_terms,
353 'operator' => 'AND',
354 'include_children' => false
355 );
356 $_args[ 'term_id' ] = $doc_terms[ 0 ];
357 }
358 } else {
359 // Fallback: use the term from current post
360 $_args[ 'tax_query' ][ ] = array(
361 'taxonomy' => 'doc_category',
362 'field' => 'term_id',
363 'terms' => $_terms[ 0 ]->term_id,
364 'operator' => 'AND',
365 'include_children' => false
366 );
367 }
368
369 $_args[ 'orderby' ] = $_orderby;
370 if ( 'betterdocs_order' != $_orderby ) {
371 $_args[ 'order' ] = $_order;
372 }
373
374 /**
375 * Before Query
376 */
377 do_action_ref_array( 'betterdocs_navigation_docs_query', array( &$_args ) );
378 $docs = $this->get_posts( $this->docs_query_args( $_args ) );
379
380 $_docs = array();
381 if ( $docs->have_posts() ) {
382 array_map(
383 function ( $_post ) use ( &$_docs ) {
384 $_docs[ ] = $_post->ID;
385 },
386 $docs->posts
387 );
388 }
389
390 $_docs_order = $_docs;
391 }
392
393 $_docs_order = apply_filters( 'betterdocs_adjacent_docs_order', $_docs_order, $_terms );
394
395 // Ensure consistent type comparison by converting all IDs to integers
396 $_docs_order = array_map( 'intval', $_docs_order );
397 $_docs_order = array_values( $_docs_order ); // Re-index array after type conversion
398
399 $_id_index = array_search( (int) $post->ID, $_docs_order, true );
400
401 // If current post not found in order list, return original where clause
402 if ( false === $_id_index ) {
403 return $where;
404 }
405
406 $_id_index = 'next' === $adjacent ? $_id_index + 1 : $_id_index - 1;
407 $_id = isset( $_docs_order[ $_id_index ] ) ? (int) $_docs_order[ $_id_index ] : null;
408
409 // Fix: replace only ID comparison in where clause using regex
410 if ( $_id ) {
411 // This replaces any 'p.ID < N', 'p.ID > N', 'p.ID <= N', or 'p.ID >= N' etc, with 'p.ID = $_id'
412 $where = preg_replace( '/p\.ID\s*[<>!=]+\s*\d+/', 'p.ID = ' . (int) $_id, $where );
413 }
414
415 return $where;
416 }
417
418 public function parse_terms_args( $args = array() ) {
419 $_default_args = array(
420 'hide_empty' => true,
421 'taxonomy' => 'doc_category'
422 );
423
424 // OrderBy & Order
425 $_orderby = ! empty( $args[ 'orderby' ] ) && 1 != $args[ 'orderby' ] ? $args[ 'orderby' ] : 'name';
426 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
427
428 if ( 'betterdocs_order' == $_orderby ) {
429 // Use base meta key - fallback logic will be handled in the terms_clauses filter
430 $args[ 'meta_key' ] = 'doc_category_order';
431 $args[ 'orderby' ] = 'meta_value_num';
432 $args[ 'order' ] = 'ASC';
433 } else {
434 $args[ 'orderby' ] = $_orderby;
435 $args[ 'order' ] = $_order;
436 }
437
438 // Nested Sub Category
439 // $args['parent'] = 0;
440 // if ( $nested_subcategory == true ) {
441 // }
442
443 if ( ! isset( $args[ 'number' ] ) ) {
444 global $wp_query;
445 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
446 $args[ 'number' ] = 4;
447 }
448 }
449
450 // Includes
451 if ( isset( $args[ 'include' ] ) ) {
452 $_include = ! is_array( $args[ 'include' ] ) ? explode( ',', $args[ 'include' ] ) : $args[ 'include' ];
453 $args[ 'include' ] = $_include;
454 $args[ 'orderby' ] = 'include';
455
456 unset( $args[ 'parent' ] );
457 }
458
459 $_meta_query = ! empty( $args[ 'meta_query' ] ) ? $args[ 'meta_query' ] : array();
460 $args[ 'meta_query' ] = apply_filters( 'betterdocs_taxonomy_object_meta_query', $_meta_query, $args );
461
462 return apply_filters( 'betterdocs_category_terms_object', wp_parse_args( $args, $_default_args ), $args );
463 }
464
465 public function get_terms( $args ) {
466 $parsed_args = $this->parse_terms_args( $args );
467 $terms = get_terms( $parsed_args );
468
469 // Filter terms manually if we need to consider private docs for users with read_private_docs capability
470 if ( isset( $parsed_args[ '_betterdocs_filter_private' ] ) && $parsed_args[ '_betterdocs_filter_private' ] && current_user_can( 'read_private_docs' ) ) {
471 $terms = array_filter( $terms, function ( $term ) {
472 // Get the actual count including private docs for users with read_private_docs capability
473 $actual_count = $this->get_docs_count( $term, false );
474 return $actual_count > 0;
475 } );
476 }
477
478 return $terms;
479 }
480
481 public function get_child_terms( $args ) {
482 if ( ! isset( $args[ 'number' ] ) ) {
483 global $wp_query;
484 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
485 $args[ 'number' ] = 1;
486 }
487 }
488
489 return $this->get_terms( $args );
490 }
491
492 /**
493 * Get POSTs of Docs type.
494 *
495 * @param mixed $args
496 * @return WP_Query
497 */
498 public function get_posts( $args, $ignore = false ) {
499 if ( ! $ignore ) {
500 $args = $this->docs_query_args( $args );
501 }
502
503 return new WP_Query( $args );
504 }
505
506 public function get_taxonomy( $tax = '' ) {
507 global $wp_query;
508 if ( is_tax( 'knowledge_base' ) ) {
509 $_tax = $wp_query->tax_query->queried_terms;
510 if ( array_key_exists( 'doc_category', $_tax ) ) {
511 $tax = 'doc_category';
512 } else {
513 $tax = 'knowledge_base';
514 }
515 } elseif ( is_tax( 'doc_category' ) ) {
516 $tax = 'doc_category';
517 }
518
519 return $tax;
520 }
521
522 public function terms_query( $args = array() ) {
523 global $wp_query;
524 $_origin_args = $args;
525
526 $default_args = array(
527 'hide_empty' => true,
528 'taxonomy' => 'doc_category',
529 'orderby' => 'name'
530 );
531
532 /**
533 * Number Set
534 *
535 * @FIX: If Built-in Docs page off and docs_page in use, then Terms Query Number 4|1 set.
536 */
537 // if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
538 // $default_args['number'] = 1;
539 // }
540
541 /**
542 * Nested Sub Category
543 */
544 if ( isset( $args[ 'nested_subcategory' ] ) && true == $args[ 'nested_subcategory' ] ) {
545 $default_args[ 'parent' ] = 0;
546 unset( $args[ 'nested_subcategory' ] );
547 // if ( $wp_query->query === NULL || ( isset( $wp_query->query['post_type'] ) && $wp_query->query['post_type'] != 'docs' ) ) {
548 // $default_args['number'] = 4;
549 // }
550 }
551
552 /**
553 * OrderBy and Order
554 */
555 if ( ! isset( $args[ 'orderby' ] ) ) {
556 $_orderby = $this->settings->get( 'terms_orderby', 'name' );
557 $_order = $this->settings->get( 'terms_order', '' );
558 } else {
559 $_orderby = $args[ 'orderby' ];
560 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : '';
561 }
562
563 if ( 'betterdocs_order' === $_orderby ) {
564 // Use different meta keys for different taxonomies
565 if ( isset( $args[ 'taxonomy' ] ) && 'knowledge_base' === $args[ 'taxonomy' ] ) {
566 $default_args[ 'meta_key' ] = 'kb_order';
567 } else {
568 $default_args[ 'meta_key' ] = 'doc_category_order';
569 }
570 $_orderby = 'meta_value_num';
571 $_order = 'ASC';
572 } elseif ( true === $_orderby ) {
573 $_orderby = 'name';
574 }
575
576 $args[ 'orderby' ] = $_orderby;
577 if ( ! empty( $_order ) ) {
578 $args[ 'order' ] = $_order;
579 }
580
581 /**
582 * @todo old hook
583 * hook: betterdocs_child_taxonomy_meta_query
584 */
585 $_multiple_kb = apply_filters(
586 'betterdocs_query_args_multiple_kb_enabled',
587 isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
588 $_origin_args
589 );
590
591 $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
592
593 unset( $args[ 'multiple_kb' ] );
594 unset( $args[ 'kb_slug' ] );
595
596 $meta_query = ! empty( $args[ 'meta_query' ] ) ? $args[ 'meta_query' ] : array();
597 $meta_query = apply_filters( 'betterdocs_terms_meta_query_args', $meta_query, $_multiple_kb, $_kb_slug, $_origin_args );
598
599 if ( ! empty( $meta_query ) ) {
600 $default_args[ 'meta_query' ] = $meta_query;
601 }
602
603 if ( ! empty( $args[ 'terms' ] ) ) {
604 $args[ 'include' ] = explode( ',', $args[ 'terms' ] );
605 $args[ 'orderby' ] = 'include';
606 $args[ 'order' ] = 'ASC';
607
608 unset( $default_args[ 'parent' ] );
609 unset( $args[ 'parent' ] );
610 unset( $args[ 'terms' ] );
611 }
612
613 $_query_args = wp_parse_args( $args, $default_args );
614 $_query_args = apply_filters( 'betterdocs_terms_query_args', $_query_args, $_origin_args );
615
616 // Apply private docs logic for logged-in users
617 $_query_args = $this->modify_terms_args_for_private_docs( $_query_args );
618
619 return $_query_args;
620 }
621
622 public function get_term_parents( $term_id, $taxonomy = 'doc_category', $args = array() ) {
623 $term = get_term( $term_id, $taxonomy );
624 if ( is_wp_error( $term ) ) {
625 return $term;
626 }
627
628 if ( ! $term || ! isset( $term->term_id ) ) {
629 return array();
630 }
631
632 $_lists = array();
633 $origin_term = $term_id;
634 $term_id = $term->term_id;
635
636 $defaults = array(
637 'format' => 'name',
638 'inclusive' => true
639 );
640
641 $args = wp_parse_args( $args, $defaults );
642
643 $args[ 'inclusive' ] = wp_validate_boolean( $args[ 'inclusive' ] );
644
645 $parents = get_ancestors( $term_id, $taxonomy );
646
647 if ( $args[ 'inclusive' ] ) {
648 array_unshift( $parents, $term_id );
649 }
650
651 foreach ( array_reverse( $parents ) as $term_id ) {
652 $parent = get_term( $term_id, $taxonomy );
653 $name = ( 'slug' === $args[ 'format' ] ) ? $parent->slug : $parent->name;
654 $term_permalink = get_term_link( $parent->term_id, $taxonomy );
655 $term_permalink = apply_filters( 'betterdocs_breadcrumb_term_permalink', $term_permalink, $term_id );
656
657 $_item = array(
658 'url' => $term_permalink,
659 'text' => $name
660 );
661
662 $_lists[ ] = $_item;
663 }
664
665 return apply_filters( 'betterdocs_breadcrumb_archive_lists', $_lists, $origin_term );
666 }
667
668 /**
669 * Get all non-empty child term IDs recursively for a given taxonomy and parent term.
670 *
671 * This function retrieves all child terms for a specified taxonomy and parent term,
672 * recursively fetching child terms of child terms, and returns only those with a non-zero post count.
673 *
674 * @param string $taxonomy The taxonomy name (e.g., 'doc_category').
675 * @param int $parent_id The ID of the parent term to start retrieving children from.
676 *
677 * @return array An array of non-empty child term IDs.
678 */
679 public function get_all_child_term_ids( $taxonomy, $parent_id ) {
680 $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
681 $cache_key = "bd_term_counts_v{$version}_child_ids_{$taxonomy}_{$parent_id}";
682
683 $cached = wp_cache_get( $cache_key, 'betterdocs' );
684 if ( false !== $cached ) {
685 return $cached;
686 }
687
688 // get_terms( child_of => X ) walks the full descendant tree using WP's
689 // internally cached term hierarchy, replacing the previous recursive
690 // get_term()-in-a-loop pattern with one call.
691 $args = apply_filters(
692 'betterdocs_get_child_term_ids_args',
693 array(
694 'taxonomy' => $taxonomy,
695 'child_of' => $parent_id,
696 'hide_empty' => true,
697 'fields' => 'ids',
698 )
699 );
700
701 $ids = get_terms( $args );
702 $ids = ( is_wp_error( $ids ) || ! is_array( $ids ) ) ? array() : array_map( 'intval', $ids );
703
704 wp_cache_set( $cache_key, $ids, 'betterdocs', HOUR_IN_SECONDS * 6 );
705
706 return $ids;
707 }
708
709 /**
710 * Get all nested child term IDs of a specific parent term in a taxonomy and return as a comma-separated string.
711 *
712 * @param string $taxonomy The taxonomy.
713 * @param int $parent_id The parent term ID.
714 * @return string The comma-separated term IDs.
715 */
716 public function get_child_term_ids_by_parent_id( $taxonomy, $parent_id ) {
717 $term_ids = $this->get_all_child_term_ids( $taxonomy, $parent_id );
718 return implode( ',', $term_ids );
719 }
720
721 public function get_terms_children( $taxonomy, $parent_id ) {
722 $children = get_term_children( $parent_id, $taxonomy );
723 $non_empty_children = array();
724 if ( ! is_wp_error( $children ) && ! empty( $children ) ) {
725 foreach ( $children as $child_id ) {
726 $child_term = get_term( $child_id, $taxonomy );
727
728 // Only include non-empty terms
729 if ( $child_term && $child_term->count > 0 ) {
730 $non_empty_children[ ] = $child_term;
731 }
732 }
733 }
734
735 return $non_empty_children;
736 }
737
738 public function get_terms_children_ids( $taxonomy, $parent_id ) {
739 $children = $this->get_terms_children( $taxonomy, $parent_id );
740 $term_ids = wp_list_pluck( $children, 'term_id' );
741
742 return implode( ',', $term_ids );
743 }
744
745 public function count_terms_children( $taxonomy, $parent_id ) {
746 return count( $this->get_terms_children( $taxonomy, $parent_id ) );
747 }
748
749 public function is_inner_templates() {
750 if ( is_tax( 'knowledge_base' ) || is_tax( 'doc_category' ) || is_tax( 'doc_tag' ) || is_singular( 'docs' ) ) {
751 return true;
752 }
753 return false;
754 }
755
756 /**
757 * Summary of docs_query_args
758 * @param mixed $args
759 * @throws \Exception
760 * @return mixed
761 */
762 public function docs_query_args( $args, $filter = array() ) {
763 $_origin_args = $args;
764
765 $default_args = array(
766 'post_type' => 'docs'
767 );
768
769 if ( ! empty( $args[ 'post_type' ] ) && trim( $args[ 'post_type' ] ) === 'docs_any' ) {
770 $default_args[ 'post_type' ] = 'docs';
771 $default_args[ 'post_status' ] = 'any';
772
773 unset( $args[ 'post_type' ] );
774 }
775
776 /**
777 * OrderBy and Order
778 */
779 if ( ! isset( $args[ 'orderby' ] ) ) {
780 $_orderby = $this->settings->get( 'alphabetically_order_post' );
781 $_order = $this->settings->get( 'docs_order', 'ASC' );
782 } else {
783 $_orderby = $args[ 'orderby' ];
784 $_order = ! empty( $args[ 'order' ] ) ? $args[ 'order' ] : 'ASC';
785 }
786
787 if ( 'betterdocs_order' != $_orderby ) {
788 if ( true === $_orderby ) {
789 $args[ 'orderby' ] = 'title';
790 } else {
791 $args[ 'orderby' ] = $_orderby;
792 }
793
794 $args[ 'order' ] = $_order;
795 } elseif ( 'betterdocs_order' == $_orderby ) {
796 unset( $args[ 'orderby' ] );
797 }
798
799 if ( empty( $args[ 'orderby' ] ) ) {
800 unset( $args[ 'order' ] );
801 }
802
803 /**
804 * Term ID
805 */
806 $_term_id = null;
807 if ( ! empty( $args[ 'term_id' ] ) ) {
808 $_term_id = intval( $args[ 'term_id' ] );
809 unset( $args[ 'term_id' ] );
810 }
811
812 // if ( $_term_id == null ) {
813 // throw new \Exception( __( '$args["term_id"] cannot be null.', 'betterdocs' ) );
814 // }
815
816 /**
817 * Term Slug for tax_query
818 */
819 $_term_slug = '';
820 if ( ! empty( $args[ 'term_slug' ] ) ) {
821 $_term_slug = trim( $args[ 'term_slug' ] );
822 unset( $args[ 'term_slug' ] );
823 }
824
825 /**
826 * @todo old hook
827 * hook: betterdocs_cat_template_multikb
828 */
829
830 $_multiple_kb = apply_filters(
831 'betterdocs_enable_multiple_knowledge_base',
832 isset( $args[ 'multiple_kb' ] ) ? (bool) $args[ 'multiple_kb' ] : false,
833 $_origin_args
834 );
835
836 $_kb_slug = isset( $args[ 'kb_slug' ] ) ? trim( $args[ 'kb_slug' ] ) : '';
837
838 unset( $args[ 'multiple_kb' ] );
839 unset( $args[ 'kb_slug' ] );
840
841 $tax_query = array(
842 array(
843 'taxonomy' => 'doc_category',
844 'field' => 'slug',
845 'terms' => $_term_slug,
846 'operator' => 'AND',
847 'include_children' => false
848 )
849 );
850
851 if ( ! isset( $args[ 'orderby' ] ) || 'betterdocs_order' == $args[ 'orderby' ] ) {
852 $args[ 'orderby' ] = 'post__in';
853 $args[ 'post__in' ] = $this->get_docs_order_by_terms( $_term_id );
854 }
855
856 if ( isset( $args[ 'tax_query' ] ) ) {
857 $tax_query = $args[ 'tax_query' ];
858 }
859
860 $args[ 'tax_query' ] = apply_filters(
861 'betterdocs_docs_tax_query_args',
862 $tax_query,
863 $_multiple_kb,
864 $_term_slug,
865 $_kb_slug,
866 $_origin_args,
867 $this->is_inner_templates()
868 );
869 /**
870 * Final parse args
871 */
872 $args = wp_parse_args( $args, $default_args );
873
874 if ( ! empty( $filter ) ) {
875 $filter = array_flip( $filter );
876 $args = array_filter(
877 $args,
878 function ( $item ) use ( $filter ) {
879 return ! array_key_exists( $item, $filter );
880 },
881 ARRAY_FILTER_USE_KEY
882 );
883 }
884
885 $final_args = apply_filters( 'betterdocs_articles_args', $args, $_term_id, $_origin_args );
886
887 // Process betterdocs_order AFTER all filters have run
888 if ( isset( $final_args[ 'orderby' ] ) && 'betterdocs_order' == $final_args[ 'orderby' ] ) {
889 $docs_order = $this->get_docs_order_by_terms( $_term_id );
890
891 if ( ! empty( $docs_order ) ) {
892 $final_args[ 'orderby' ] = 'post__in';
893 $final_args[ 'post__in' ] = $docs_order;
894 } else {
895 $final_args[ 'orderby' ] = 'menu_order';
896 $final_args[ 'order' ] = 'ASC';
897 }
898 }
899
900 return $final_args;
901 }
902
903 /**
904 * The front-end FAQ ordering preference, set via the FAQ Builder header
905 * dropdown (the `.betterdocs-dropdown-select` control, saved to the
906 * `betterdocs_faq_order` option). The front end mirrors the builder so it
907 * shows FAQ groups (and the FAQs inside them) in the same order the admin
908 * sees while building.
909 *
910 * @return string One of: default, most_recent, least_recent, a_to_z, z_to_a, most_questions.
911 */
912 public function get_faq_order_key() {
913 $key = get_option( 'betterdocs_faq_order', 'default' );
914 $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
915
916 if ( ! in_array( $key, $allowed, true ) ) {
917 $key = 'default';
918 }
919
920 return apply_filters( 'betterdocs_faq_order_key', $key );
921 }
922
923 /**
924 * Translate the FAQ order preference into `get_terms()` order clauses for
925 * the FAQ group list. Mirrors the admin builder's ORDER_MAP so the front
926 * end matches what's configured there.
927 *
928 * @return array orderby/order (+ meta_key for the manual `default` order).
929 */
930 public function faq_terms_order_clause() {
931 switch ( $this->get_faq_order_key() ) {
932 case 'most_recent':
933 return array( 'orderby' => 'term_id', 'order' => 'DESC' );
934 case 'least_recent':
935 return array( 'orderby' => 'term_id', 'order' => 'ASC' );
936 case 'a_to_z':
937 return array( 'orderby' => 'name', 'order' => 'ASC' );
938 case 'z_to_a':
939 return array( 'orderby' => 'name', 'order' => 'DESC' );
940 case 'most_questions':
941 return array( 'orderby' => 'count', 'order' => 'DESC' );
942 case 'default':
943 default:
944 // Manual drag-drop order stored in the `order` term meta.
945 return array( 'meta_key' => 'order', 'orderby' => 'meta_value_num', 'order' => 'ASC' );
946 }
947 }
948
949 public function faq_terms_query_args( $includes = '', $excludes = '', $args = array(), $taxonomy = 'betterdocs_faq_category' ) {
950 $_args = array(
951 'taxonomy' => $taxonomy,
952 'include' => $includes,
953 'exclude' => $excludes,
954 'meta_query' => array(
955 array(
956 'key' => 'status',
957 'value' => 1,
958 'compare' => '=='
959 )
960 )
961 );
962
963 $_args = array_merge( $_args, $this->faq_terms_order_clause() );
964
965 if ( 'all' == $_args[ 'include' ] ) {
966 unset( $_args[ 'include' ] );
967 unset( $_args[ 'exclude' ] );
968 }
969
970 if ( empty( $_args[ 'exclude' ] ) ) {
971 unset( $_args[ 'exclude' ] );
972 }
973
974 if ( empty( $_args[ 'include' ] ) ) {
975 unset( $_args[ 'include' ] );
976 }
977
978 return wp_parse_args( $args, $_args );
979 }
980
981 public function get_faq_by_term( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
982 global $wpdb;
983
984 $args = array(
985 'post_type' => 'betterdocs_faq',
986 'post_status' => 'publish',
987 'tax_query' => array(
988 array(
989 'taxonomy' => $taxonomy,
990 'field' => 'term_id',
991 'terms' => $term_id,
992 'operator' => 'AND'
993 )
994 ),
995 'posts_per_page' => -1
996 );
997
998 // Order the FAQs inside the group to mirror the FAQ Builder header
999 // dropdown. `default`/`most_questions` keep the manual drag-drop order
1000 // (the others sort live, just like the admin builder does).
1001 switch ( $this->get_faq_order_key() ) {
1002 case 'most_recent':
1003 $args[ 'orderby' ] = 'ID';
1004 $args[ 'order' ] = 'DESC';
1005 break;
1006 case 'least_recent':
1007 $args[ 'orderby' ] = 'ID';
1008 $args[ 'order' ] = 'ASC';
1009 break;
1010 case 'a_to_z':
1011 $args[ 'orderby' ] = 'title';
1012 $args[ 'order' ] = 'ASC';
1013 break;
1014 case 'z_to_a':
1015 $args[ 'orderby' ] = 'title';
1016 $args[ 'order' ] = 'DESC';
1017 break;
1018 case 'default':
1019 case 'most_questions':
1020 default:
1021 $args[ 'orderby' ] = 'post__in';
1022 $args[ 'post__in' ] = $this->get_faq_orders( $term_id );
1023 break;
1024 }
1025
1026 return new WP_Query( $args );
1027 }
1028
1029 public function get_faq_orders( $term_id = null ) {
1030 global $wpdb;
1031 $faq_order = get_term_meta( $term_id, '_betterdocs_faq_order', true );
1032 $faq_order = explode( ',', $faq_order );
1033
1034 $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}term_relationships WHERE term_taxonomy_id = %d", $term_id );
1035 $query_key = 'betterdocs_faq_order_' . md5( $query );
1036
1037 if ( ( $results = $this->database->get_cache( $query_key ) ) !== false ) {
1038 return $results;
1039 }
1040
1041 if ( ! empty( $faq_order ) ) {
1042 $new_ids = array();
1043 // $query is prepared upstream; results are cached via $this->database->get_cache above (line 980).
1044 $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1045
1046 if ( ! is_null( $results ) && ! empty( $results ) && is_array( $results ) ) {
1047 $object_ids = array_filter(
1048 $results,
1049 function ( $value ) use ( $faq_order ) {
1050 return ! in_array( $value->object_id, $faq_order );
1051 }
1052 );
1053
1054 if ( ! empty( $object_ids ) ) {
1055 array_walk(
1056 $object_ids,
1057 function ( $value ) use ( &$new_ids ) {
1058 $new_ids[ ] = $value->object_id;
1059 }
1060 );
1061 }
1062 }
1063
1064 $faq_order = array_merge( $new_ids, $faq_order );
1065 }
1066
1067 $this->database->set_cache( $query_key, $faq_order, 1 );
1068
1069 return $faq_order;
1070 }
1071
1072 public function get_faq_terms( $terms = array(), $taxonomy = 'betterdocs_faq_category' ) {
1073 $_terms = get_terms(
1074 array(
1075 'taxonomy' => $taxonomy,
1076 'hide_empty' => true,
1077 'orderby' => 'name',
1078 'order' => 'ASC',
1079 'meta_query' => array(
1080 array(
1081 'key' => 'status',
1082 'value' => 1,
1083 'compare' => '=='
1084 )
1085 )
1086 )
1087 );
1088
1089 if ( ! is_wp_error( $_terms ) ) {
1090 foreach ( $_terms as $term ) {
1091 $terms[ $term->term_id ] = $term->name;
1092 }
1093 }
1094
1095 return $terms;
1096 }
1097
1098 public function get_doc_terms( $terms = array() ) {
1099 $_terms = get_terms(
1100 array(
1101 'taxonomy' => 'doc_category',
1102 'hide_empty' => true,
1103 'orderby' => 'name',
1104 'order' => 'ASC'
1105 )
1106 );
1107
1108 if ( ! is_wp_error( $_terms ) ) {
1109 foreach ( $_terms as $term ) {
1110 $terms[ $term->term_id ] = $term->name;
1111 }
1112 }
1113
1114 return $terms;
1115 }
1116
1117 public function flush_term_counts_cache() {
1118 $this->database->bump_cache_version( 'betterdocs_term_counts' );
1119 }
1120
1121 public function flush_term_counts_cache_on_post( $post_id, $post = null ) {
1122 if ( $post && isset( $post->post_type ) && $post->post_type === 'docs' ) {
1123 $this->flush_term_counts_cache();
1124 }
1125 }
1126
1127 public function flush_term_counts_cache_on_set( $object_id, $terms, $tt_ids, $taxonomy ) {
1128 if ( $taxonomy === 'doc_category' ) {
1129 $this->flush_term_counts_cache();
1130 }
1131 }
1132
1133 public function get_docs_count( $term, $nested_subcategory = false, $args = array() ) {
1134 // Validate term object
1135 if ( ! is_object( $term ) ) {
1136 return 0;
1137 }
1138
1139 $counts = isset( $term->count ) ? $term->count : 0;
1140
1141 if ( ! isset( $term->term_id ) || ! is_numeric( $term->term_id ) ) {
1142 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1143 }
1144
1145 $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1146 $can_priv = current_user_can( 'read_private_docs' ) ? 1 : 0;
1147 $kb_slug = isset( $args['kb_slug'] ) ? $args['kb_slug'] : '';
1148 $multi = ! empty( $args['multiple_knowledge_base'] ) ? 1 : 0;
1149 $nested = $nested_subcategory ? 1 : 0;
1150 $cache_key = "bd_term_counts_v{$version}_docs_count_{$term->term_id}_{$nested}_{$can_priv}_{$kb_slug}_{$multi}";
1151
1152 $cached = wp_cache_get( $cache_key, 'betterdocs' );
1153 if ( false !== $cached ) {
1154 return apply_filters( 'betterdocs_docs_count', $cached, $term, $nested_subcategory, $args );
1155 }
1156
1157 if ( false == $nested_subcategory ) {
1158 // For non-nested categories, we need to recalculate counts based on user capabilities
1159 // Only proceed if we have a valid term with required properties
1160 if ( isset( $term->taxonomy ) ) {
1161 // Get all post IDs for this term
1162 $post_ids = get_objects_in_term( $term->term_id, $term->taxonomy );
1163
1164 if ( ! empty( $post_ids ) ) {
1165 _prime_post_caches( $post_ids, false, false );
1166
1167 if ( current_user_can( 'read_private_docs' ) ) {
1168 // For users with read_private_docs capability, include both private and public posts
1169 $filtered_post_ids = array_filter( $post_ids, function ( $post_id ) {
1170 $post_status = get_post_status( $post_id );
1171 return 'private' === $post_status || is_post_publicly_viewable( $post_id );
1172 } );
1173 } else {
1174 // For users without read_private_docs capability, only include public posts
1175 $filtered_post_ids = array_filter( $post_ids, function ( $post_id ) {
1176 return is_post_publicly_viewable( $post_id );
1177 } );
1178 }
1179
1180 $counts = count( $filtered_post_ids );
1181 } else {
1182 $counts = 0;
1183 }
1184 }
1185 } else {
1186 $_child_terms_docs_ids = $this->get_doc_ids_by_term( $term, null, $nested_subcategory );
1187 if ( is_array( $_child_terms_docs_ids ) ) {
1188 $counts = count( $_child_terms_docs_ids );
1189 }
1190 }
1191
1192 wp_cache_set( $cache_key, $counts, 'betterdocs', HOUR_IN_SECONDS * 6 );
1193
1194 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1195 }
1196
1197 public function get_doc_ids_by_term( $term, $optional = null, $nested_subcategory = false ) {
1198 // Check if term has required properties and is a valid object
1199 if ( ! is_object( $term ) || ! isset( $term->term_id ) || ! isset( $term->taxonomy ) || ! is_numeric( $term->term_id ) ) {
1200 return false;
1201 }
1202
1203 $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1204 $can_priv = current_user_can( 'read_private_docs' ) ? 1 : 0;
1205 $nested = $nested_subcategory ? 1 : 0;
1206 $optional_id = is_object( $optional ) && isset( $optional->term_id ) ? (int) $optional->term_id : 0;
1207 $cache_key = "bd_term_counts_v{$version}_doc_ids_{$term->term_id}_{$nested}_{$optional_id}_{$can_priv}";
1208
1209 $cached = wp_cache_get( $cache_key, 'betterdocs' );
1210 if ( false !== $cached ) {
1211 return $cached;
1212 }
1213
1214 $args = array(
1215 'taxonomy' => $term->taxonomy,
1216 'include' => $term->term_id,
1217 );
1218 if ( $nested_subcategory ) {
1219 $args[ 'child_of' ] = $term->term_id;
1220 unset( $args[ 'include' ] );
1221 }
1222 $_child_terms = get_terms( $args );
1223
1224 if ( ! is_array( $_child_terms ) ) {
1225 return false;
1226 }
1227
1228 array_unshift( $_child_terms, $term );
1229
1230 $_child_terms_ids = array_column( $_child_terms, 'term_id' );
1231 $_child_terms_taxs = array_column( $_child_terms, 'taxonomy' );
1232 $_child_terms_docs_ids = get_objects_in_term( $_child_terms_ids, $_child_terms_taxs );
1233
1234 if ( null !== $optional ) {
1235 $_optional_doc_ids = get_objects_in_term( $optional->term_id, $optional->taxonomy );
1236 $_child_terms_docs_ids = array_intersect( $_child_terms_docs_ids, $_optional_doc_ids );
1237 }
1238
1239 if ( ! empty( $_child_terms_docs_ids ) ) {
1240 _prime_post_caches( $_child_terms_docs_ids, false, false );
1241 }
1242
1243 $filtered = array_filter( $_child_terms_docs_ids, function ( $doc_id ) {
1244 if ( ! current_user_can( 'read_private_docs' ) ) {
1245 return is_post_publicly_viewable( $doc_id );
1246 }
1247
1248 $_status = get_post_status( $doc_id );
1249 return 'private' == $_status || is_post_publicly_viewable( $doc_id );
1250 } );
1251
1252 wp_cache_set( $cache_key, $filtered, 'betterdocs', HOUR_IN_SECONDS * 6 );
1253
1254 return $filtered;
1255 }
1256
1257 /**
1258 * Get the common query arguments for WP_Query.
1259 *
1260 * @param string $terms The taxonomy.
1261 * @param string $term_slug The taxonomy term slug.
1262 * @param array $additional_args Additional arguments to merge with the common arguments.
1263 * @return array The query arguments.
1264 */
1265 private function tax_query_args( $terms, $term_slug, $additional_args = array() ) {
1266 $common_args = array(
1267 'post_type' => 'docs',
1268 'post_status' => 'publish',
1269 'tax_query' => array(
1270 array(
1271 'taxonomy' => $terms,
1272 'field' => 'slug',
1273 'terms' => $term_slug
1274 )
1275 )
1276 );
1277 return array_merge( $common_args, $additional_args );
1278 }
1279
1280 /**
1281 * Get the latest updated date for a specific taxonomy term.
1282 *
1283 * @param string $terms The taxonomy.
1284 * @param string $term_slug The taxonomy term slug.
1285 * @return string|null The latest modified date or null if no posts found.
1286 */
1287 public function latest_updated_date( $terms, $term_slug ) {
1288 $args = $this->tax_query_args(
1289 $terms,
1290 $term_slug,
1291 array(
1292 'posts_per_page' => 1,
1293 'orderby' => 'modified',
1294 'order' => 'DESC'
1295 )
1296 );
1297
1298 $query = new WP_Query( $args );
1299
1300 if ( $query->have_posts() ) {
1301 while ( $query->have_posts() ) {
1302 $query->the_post();
1303 $latest_post = get_the_modified_date();
1304 wp_reset_postdata();
1305 return $latest_post;
1306 }
1307 } else {
1308 return null;
1309 }
1310 }
1311
1312 /**
1313 * Check if there are any new posts within the last 7 days for a specific taxonomy term.
1314 *
1315 * @param string $terms The taxonomy.
1316 * @param string $term_slug The taxonomy term slug.
1317 * @return bool True if there are new posts, false otherwise.
1318 */
1319 public function check_new_posts( $terms, $term_slug ) {
1320 $date_7_days_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
1321
1322 $args = $this->tax_query_args(
1323 $terms,
1324 $term_slug,
1325 array(
1326 'posts_per_page' => 1,
1327 'orderby' => 'modified',
1328 'order' => 'DESC',
1329 'date_query' => array(
1330 array(
1331 'after' => $date_7_days_ago,
1332 'inclusive' => true
1333 )
1334 )
1335 )
1336 );
1337
1338 $query = new WP_Query( $args );
1339
1340 $has_new_posts = $query->have_posts();
1341
1342 wp_reset_postdata();
1343
1344 return $has_new_posts;
1345 }
1346
1347 public function insert_search_keyword( $search_input, $input_not_found ) {
1348 if ( empty( $search_input ) ) {
1349 return false;
1350 }
1351
1352 global $wpdb;
1353
1354 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live search-keyword analytics; cache would defeat the purpose.
1355 // Use BINARY comparison to avoid collation mismatch errors
1356 // This works across all character sets (latin1, utf8, utf8mb4, etc.)
1357 $search = $wpdb->get_results(
1358 $wpdb->prepare(
1359 "SELECT *
1360 FROM {$wpdb->prefix}betterdocs_search_keyword
1361 WHERE BINARY keyword = %s",
1362 $search_input
1363 )
1364 );
1365
1366 if ( ! empty( $search ) ) {
1367 $search_log = $wpdb->get_results(
1368 $wpdb->prepare(
1369 "SELECT *
1370 FROM {$wpdb->prefix}betterdocs_search_log
1371 WHERE created_at = %s AND keyword_id = %d",
1372 gmdate( 'Y-m-d' ),
1373 $search[ 0 ]->id
1374 )
1375 );
1376
1377 if ( ! empty( $search_log ) ) {
1378 if ( ! empty( $input_not_found ) ) {
1379 $tbl_field = 'not_found_count';
1380 $count = $search_log[ 0 ]->not_found_count + 1;
1381 } else {
1382 $tbl_field = 'count';
1383 $count = $search_log[ 0 ]->count + 1;
1384 }
1385 // $tbl_field is validated immediately above to be either 'count' or
1386 // 'not_found_count' — safe to interpolate as a column identifier.
1387 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $tbl_field is an allowlisted column name ('count'|'not_found_count'); safe to interpolate.
1388 $insert = $wpdb->query(
1389 $wpdb->prepare(
1390 "UPDATE {$wpdb->prefix}betterdocs_search_log SET {$tbl_field} = %d WHERE created_at = %s AND keyword_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1391 $count,
1392 $search_log[ 0 ]->created_at,
1393 $search_log[ 0 ]->keyword_id
1394 )
1395 );
1396 } else {
1397 if ( ! empty( $input_not_found ) ) {
1398 $count = 0;
1399 $not_found_count = 1;
1400 } else {
1401 $count = 1;
1402 $not_found_count = 0;
1403 }
1404 $insert = $wpdb->query(
1405 $wpdb->prepare(
1406 "INSERT INTO {$wpdb->prefix}betterdocs_search_log
1407 ( keyword_id, count, not_found_count, created_at )
1408 VALUES ( %d, %d, %d, %s )",
1409 array(
1410 $search[ 0 ]->id,
1411 $count,
1412 $not_found_count,
1413 gmdate( 'Y-m-d' )
1414 )
1415 )
1416 );
1417 }
1418 } else {
1419 $insert = $wpdb->query(
1420 $wpdb->prepare(
1421 "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
1422 ( keyword )
1423 VALUES ( %s )",
1424 array(
1425 $search_input
1426 )
1427 )
1428 );
1429
1430 if ( $insert ) {
1431 if ( ! empty( $input_not_found ) ) {
1432 $count = 0;
1433 $not_found_count = 1;
1434 } else {
1435 $count = 1;
1436 $not_found_count = 0;
1437 }
1438 $insert = $wpdb->query(
1439 $wpdb->prepare(
1440 "INSERT INTO {$wpdb->prefix}betterdocs_search_log
1441 ( keyword_id, count, not_found_count, created_at )
1442 VALUES ( %d, %d, %d, %s )",
1443 array(
1444 $wpdb->insert_id,
1445 $count,
1446 $not_found_count,
1447 gmdate( 'Y-m-d' )
1448 )
1449 )
1450 );
1451 }
1452 }
1453 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1454 return $insert;
1455 }
1456
1457 /**
1458 * Counts the number of 'doc_category' terms assigned to a specific 'knowledge_base' by slug.
1459 *
1460 * This method retrieves all terms in the 'doc_category' taxonomy and checks the term meta
1461 * 'doc_category_knowledge_base' to determine how many 'doc_category' terms are associated
1462 * with a given 'knowledge_base' slug.
1463 *
1464 * @param string $knowledge_base_slug The slug of the 'knowledge_base' to count the assignments for.
1465 *
1466 * @return int The count of 'doc_category' terms assigned to the specified 'knowledge_base'.
1467 */
1468 public function count_doc_categories_for_knowledge_base( $knowledge_base_slug ) {
1469 $count = 0;
1470
1471 $doc_categories = get_terms(
1472 array(
1473 'taxonomy' => 'doc_category',
1474 'hide_empty' => true
1475 )
1476 );
1477
1478 if ( ! empty( $doc_categories ) && ! is_wp_error( $doc_categories ) ) {
1479 foreach ( $doc_categories as $term ) {
1480 $meta_value = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1481
1482 if ( $meta_value ) {
1483 $knowledge_bases = maybe_unserialize( $meta_value );
1484
1485 // Check if the specific knowledge base slug is present in the meta value array
1486 if ( is_array( $knowledge_bases ) && in_array( $knowledge_base_slug, $knowledge_bases ) ) {
1487 ++$count;
1488 }
1489 }
1490 }
1491 }
1492
1493 return $count;
1494 }
1495 }
1496