PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/Query.php +213 -40 4.8.14.9.2 View file →
@@ -26,8 +26,11 @@
26 26 add_action( 'parse_term_query', array( $this, 'parse_term_query' ) );
27 27 // add_action( 'parse_query', [$this, 'parse_query'], 1 );
28 28 add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1 );
29 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 );
30 33
31 34 // Invalidate cached doc-category counts on any write that could change them.
32 35 add_action( 'save_post_docs', array( $this, 'flush_term_counts_cache' ) );
33 36 add_action( 'deleted_post', array( $this, 'flush_term_counts_cache_on_post' ), 10, 2 );
@@ -34,8 +37,12 @@
34 37 add_action( 'edited_doc_category', array( $this, 'flush_term_counts_cache' ) );
35 38 add_action( 'created_doc_category', array( $this, 'flush_term_counts_cache' ) );
36 39 add_action( 'delete_doc_category', array( $this, 'flush_term_counts_cache' ) );
37 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 );
38 45
39 46 /**
40 47 * These below filters are hooked for navigation only.
41 48 *
@@ -85,26 +92,53 @@
85 92 public function init() {
86 93 }
87 94
88 95 /**
89 - * Modify terms args to include terms with only private docs for users with read_private_docs capability
96 + * Modify terms args to include terms whose only docs are private, for users allowed to read them.
90 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 + *
91 102 * @param array $args
92 103 * @return array
93 104 */
94 105 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' ] ) ) {
106 + // Logged-out visitors always get WordPress' stock `hide_empty` behaviour.
107 + if ( ! is_user_logged_in() || ! isset( $args[ 'taxonomy' ] ) ) {
97 108 return $args;
98 109 }
99 110
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 ) ) {
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' ] ) ) {
104 126 return $args;
105 127 }
106 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 +
107 141 // If hide_empty is true, we need to modify the logic to include terms with private docs
108 142 if ( isset( $args[ 'hide_empty' ] ) && $args[ 'hide_empty' ] ) {
109 143 // Set hide_empty to false and we'll filter manually later
110 144 $args[ 'hide_empty' ] = false;
@@ -317,14 +351,18 @@
317 351
318 352 if ( empty( $_docs_order ) ) {
319 353 $statuses = array( 'publish' );
320 354
321 - if ( current_user_can( 'read_private_docs' ) ) {
355 + if ( is_user_logged_in() ) {
322 356 $statuses[ ] = 'private';
323 357 }
324 358
325 359 $_args = array(
326 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',
327 365 'term_id' => $_terms[ 0 ]->term_id
328 366 );
329 367 if ( isset( $wp_query->query_vars[ 'doc_category' ] ) ) {
330 368 $_args[ 'tax_query' ][ ] = array(
@@ -465,14 +503,13 @@
465 503 public function get_terms( $args ) {
466 504 $parsed_args = $this->parse_terms_args( $args );
467 505 $terms = get_terms( $parsed_args );
468 506
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' ) ) {
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() ) {
471 509 $terms = array_filter( $terms, function ( $term ) {
472 510 // 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;
511 + return $this->get_private_inclusive_docs_count( $term ) > 0;
475 512 } );
476 513 }
477 514
478 515 return $terms;
@@ -477,8 +514,124 @@
477 514
478 515 return $terms;
479 516 }
480 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 +
481 634 public function get_child_terms( $args ) {
482 635 if ( ! isset( $args[ 'number' ] ) ) {
483 636 global $wp_query;
484 637 if ( null === $wp_query->query || ( isset( $wp_query->query[ 'post_type' ] ) && 'docs' != $wp_query->query[ 'post_type' ] ) ) {
@@ -1129,8 +1282,31 @@
1129 1282 $this->flush_term_counts_cache();
1130 1283 }
1131 1284 }
1132 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 +
1133 1309 public function get_docs_count( $term, $nested_subcategory = false, $args = array() ) {
1134 1310 // Validate term object
1135 1311 if ( ! is_object( $term ) ) {
1136 1312 return 0;
@@ -1142,13 +1318,15 @@
1142 1318 return apply_filters( 'betterdocs_docs_count', $counts, $term, $nested_subcategory, $args );
1143 1319 }
1144 1320
1145 1321 $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1146 - $can_priv = current_user_can( 'read_private_docs' ) ? 1 : 0;
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();
1147 1325 $kb_slug = isset( $args['kb_slug'] ) ? $args['kb_slug'] : '';
1148 1326 $multi = ! empty( $args['multiple_knowledge_base'] ) ? 1 : 0;
1149 1327 $nested = $nested_subcategory ? 1 : 0;
1150 - $cache_key = "bd_term_counts_v{$version}_docs_count_{$term->term_id}_{$nested}_{$can_priv}_{$kb_slug}_{$multi}";
1328 + $cache_key = "bd_term_counts_v{$version}_docs_count_{$term->term_id}_{$nested}_{$viewer}_{$kb_slug}_{$multi}";
1151 1329
1152 1330 $cached = wp_cache_get( $cache_key, 'betterdocs' );
1153 1331 if ( false !== $cached ) {
1154 1332 return apply_filters( 'betterdocs_docs_count', $cached, $term, $nested_subcategory, $args );
@@ -1163,20 +1341,13 @@
1163 1341
1164 1342 if ( ! empty( $post_ids ) ) {
1165 1343 _prime_post_caches( $post_ids, false, false );
1166 1344
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 - }
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 + } );
1179 1350
1180 1351 $counts = count( $filtered_post_ids );
1181 1352 } else {
1182 1353 $counts = 0;
@@ -1200,12 +1371,12 @@
1200 1371 return false;
1201 1372 }
1202 1373
1203 1374 $version = $this->database->get_cache_version( 'betterdocs_term_counts' );
1204 - $can_priv = current_user_can( 'read_private_docs' ) ? 1 : 0;
1375 + $viewer = $this->count_cache_viewer_key();
1205 1376 $nested = $nested_subcategory ? 1 : 0;
1206 1377 $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}";
1378 + $cache_key = "bd_term_counts_v{$version}_doc_ids_{$term->term_id}_{$nested}_{$optional_id}_{$viewer}";
1208 1379
1209 1380 $cached = wp_cache_get( $cache_key, 'betterdocs' );
1210 1381 if ( false !== $cached ) {
1211 1382 return $cached;
@@ -1240,14 +1411,9 @@
1240 1411 _prime_post_caches( $_child_terms_docs_ids, false, false );
1241 1412 }
1242 1413
1243 1414 $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 );
1415 + return $this->can_read_doc( $doc_id );
1250 1416 } );
1251 1417
1252 1418 wp_cache_set( $cache_key, $filtered, 'betterdocs', HOUR_IN_SECONDS * 6 );
1253 1419
@@ -1350,16 +1516,22 @@
1350 1516 }
1351 1517
1352 1518 global $wpdb;
1353 1519
1520 + $keyword_hash = md5( $search_input );
1521 +
1354 1522 // 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.)
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.
1357 1528 $search = $wpdb->get_results(
1358 1529 $wpdb->prepare(
1359 1530 "SELECT *
1360 1531 FROM {$wpdb->prefix}betterdocs_search_keyword
1361 - WHERE BINARY keyword = %s",
1532 + WHERE keyword_hash = %s AND BINARY keyword = %s",
1533 + $keyword_hash,
1362 1534 $search_input
1363 1535 )
1364 1536 );
1365 1537
@@ -1418,12 +1590,13 @@
1418 1590 } else {
1419 1591 $insert = $wpdb->query(
1420 1592 $wpdb->prepare(
1421 1593 "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
1422 - ( keyword )
1423 - VALUES ( %s )",
1594 + ( keyword, keyword_hash )
1595 + VALUES ( %s, %s )",
1424 1596 array(
1425 - $search_input
1597 + $search_input,
1598 + $keyword_hash
1426 1599 )
1427 1600 )
1428 1601 );
1429 1602