PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.2.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.2.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
← All changes | includes/Core/FAQBuilder.php +62 -628 4.6.04.2.0 View file →
@@ -1,16 +1,8 @@
1 1 <?php
2 +
2 3 namespace WPDeveloper\BetterDocs\Core;
3 4
4 -if ( ! defined( 'ABSPATH' ) ) {
5 - exit;
6 -}
7 -
8 -// FAQ-by-category lookups require tax_query / meta_key filters by design.
9 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
10 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
11 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
12 -
13 5 use WP_Error;
14 6 use WP_Query;
15 7 use WPDeveloper\BetterDocs\Utils\Base;
16 8 use WPDeveloper\BetterDocs\Utils\Helper;
@@ -24,47 +16,9 @@
24 16 public $post_type = 'betterdocs_faq';
25 17 public $category = 'betterdocs_faq_category';
26 18
27 19 /**
28 - * Product FAQ groups taxonomy. Shares the betterdocs_faq post type with the
29 - * General FAQ groups but is a separate taxonomy so the two never mix in the
30 - * admin or on the front end. Used by the WooCommerce Product FAQ tab.
31 20 *
32 - * @var string
33 - */
34 - public $product_category = 'betterdocs_product_faq_category';
35 -
36 - /**
37 - * Term meta on a Product FAQ group holding the product_cat term IDs the
38 - * group is assigned to. Products in those categories inherit the group.
39 - */
40 - const GROUP_PRODUCT_CATS_META = '_betterdocs_faq_group_product_cats';
41 -
42 - /**
43 - * Term meta on a Product FAQ group holding the individual product IDs the
44 - * group is assigned to directly.
45 - */
46 - const GROUP_PRODUCTS_META = '_betterdocs_faq_group_products';
47 -
48 - /**
49 - * Term meta flagging a Product FAQ group as shown on every product (no
50 - * per-product/per-category targeting). Set on the store-aware sample groups
51 - * so they render storefront-wide immediately; cleared the moment the owner
52 - * saves explicit category/product assignments.
53 - */
54 - const GROUP_ALL_PRODUCTS_META = '_betterdocs_faq_group_all_products';
55 -
56 - /**
57 - * Per-request cache of draft FAQ counts keyed by taxonomy → [ term_id => count ].
58 - * Lets the betterdocs_draft_count REST field resolve every term from a single
59 - * grouped query instead of one WP_Query per term (N+1).
60 - *
61 - * @var array<string, array<int, int>>
62 - */
63 - protected $draft_count_cache = [];
64 -
65 - /**
66 - *
67 21 * Initialize the class and start calling our hooks and filters
68 22 *
69 23 * @since 1.0.0
70 24 *
@@ -73,89 +27,12 @@
73 27 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
74 28 add_action( 'init', [ $this, 'register_post' ] );
75 29 // fires after a new betterdocs_faq_category is created
76 30 add_action( 'created_betterdocs_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
77 - add_action( 'created_betterdocs_product_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
78 31 add_action( 'rest_api_init', [ $this, 'register_api_endpoint' ] );
79 - add_action( 'rest_api_init', [ $this, 'register_category_count_fields' ] );
80 32 add_action( 'rest_betterdocs_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
81 - add_action( 'rest_betterdocs_product_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
82 -
83 - // Classic FAQ Group list (edit-tags.php?taxonomy=betterdocs_faq_category):
84 - // enable drag-and-drop ordering + persist it, mirroring the classic Doc
85 - // Categories screen.
86 - add_action( 'admin_enqueue_scripts', [ $this, 'classic_category_scripts' ] );
87 - add_action( 'wp_ajax_update_faq_cat_order', [ $this, 'ajax_update_category_order' ] );
88 - add_action( 'admin_head', [ $this, 'order_admin_terms' ] );
89 33 }
90 34
91 - /**
92 - * Expose a per-group draft FAQ count on the betterdocs_faq_category REST
93 - * response so the FAQ Builder can show "N questions • M draft". The term
94 - * `count` only tracks published FAQs (via _update_post_term_count), so the
95 - * draft count is computed here.
96 - *
97 - * @since 4.4.0
98 - */
99 - public function register_category_count_fields() {
100 - foreach ( [ $this->category, $this->product_category ] as $taxonomy ) {
101 - register_rest_field(
102 - $taxonomy,
103 - 'betterdocs_draft_count',
104 - [
105 - 'get_callback' => function ( $term ) {
106 - $counts = $this->get_draft_counts_by_term( $term['taxonomy'] );
107 - return isset( $counts[ (int) $term['id'] ] ) ? (int) $counts[ (int) $term['id'] ] : 0;
108 - },
109 - 'schema' => [
110 - 'type' => 'integer',
111 - 'context' => [ 'view', 'edit' ],
112 - ],
113 - ]
114 - );
115 - }
116 - }
117 -
118 - /**
119 - * Draft FAQ counts for every term in a taxonomy, keyed by term_id.
120 - *
121 - * Computed once per request with a single grouped query and memoized, so the
122 - * betterdocs_draft_count REST field no longer fires a WP_Query per term when
123 - * a category list is assembled (avoids the N+1 on large group counts).
124 - *
125 - * @param string $taxonomy
126 - * @return array<int, int>
127 - */
128 - protected function get_draft_counts_by_term( $taxonomy ) {
129 - if ( isset( $this->draft_count_cache[ $taxonomy ] ) ) {
130 - return $this->draft_count_cache[ $taxonomy ];
131 - }
132 -
133 - global $wpdb;
134 -
135 - $rows = $wpdb->get_results(
136 - $wpdb->prepare(
137 - "SELECT tt.term_id, COUNT( p.ID ) AS draft_count
138 - FROM {$wpdb->posts} p
139 - INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
140 - INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
141 - WHERE p.post_type = %s
142 - AND p.post_status = 'draft'
143 - AND tt.taxonomy = %s
144 - GROUP BY tt.term_id",
145 - $this->post_type,
146 - $taxonomy
147 - )
148 - ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
149 -
150 - $counts = [];
151 - foreach ( (array) $rows as $row ) {
152 - $counts[ (int) $row->term_id ] = (int) $row->draft_count;
153 - }
154 -
155 - return $this->draft_count_cache[ $taxonomy ] = $counts;
156 - }
157 -
158 35 public function output() {
159 36 betterdocs()->views->get( 'admin/faq-builder' );
160 37 }
161 38
@@ -207,62 +84,8 @@
207 84 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
208 85 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
209 86
210 87 /**
211 - * Register the Product FAQ groups taxonomy (WooCommerce tab). It mirrors
212 - * the General FAQ category taxonomy and shares the betterdocs_faq post
213 - * type, but is kept separate so Product FAQ groups never appear in the
214 - * General FAQ Builder tab.
215 - */
216 - $product_category_labels = [
217 - 'name' => __( 'Product FAQ Categories', 'betterdocs' ),
218 - 'singular_name' => __( 'Product FAQ Category', 'betterdocs' ),
219 - 'all_items' => __( 'Product FAQ Categories', 'betterdocs' ),
220 - 'parent_item' => __( 'Parent Product FAQ Category', 'betterdocs' ),
221 - 'parent_item_colon' => __( 'Parent Product FAQ Category:', 'betterdocs' ),
222 - 'edit_item' => __( 'Edit Product FAQ Category', 'betterdocs' ),
223 - 'update_item' => __( 'Update Product FAQ Category', 'betterdocs' ),
224 - 'add_new_item' => __( 'Add New Product FAQ Category', 'betterdocs' ),
225 - 'new_item_name' => __( 'New Product FAQ Category Name', 'betterdocs' ),
226 - 'menu_name' => __( 'Product FAQ Categories', 'betterdocs' )
227 - ];
228 -
229 - $product_category_args = $category_args;
230 - $product_category_args['labels'] = $product_category_labels;
231 - $product_category_args['show_admin_column'] = false;
232 -
233 - register_taxonomy( $this->product_category, [ $this->post_type ], $product_category_args );
234 - register_term_meta( $this->product_category, 'order', [ 'show_in_rest' => true ] );
235 - register_term_meta( $this->product_category, 'status', [ 'show_in_rest' => true ] );
236 - register_term_meta( $this->product_category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
237 - register_term_meta( $this->product_category, 'faq_group_icon', [ 'show_in_rest' => true ] );
238 -
239 - // Product FAQ group assignments: which WooCommerce product categories and
240 - // which individual products this group is shown on. Stored on the group
241 - // term so the assignment lives in the Create/Update FAQ Group screen.
242 - $assignment_meta_args = [
243 - 'single' => true,
244 - 'type' => 'array',
245 - 'show_in_rest' => [
246 - 'schema' => [
247 - 'type' => 'array',
248 - 'items' => [ 'type' => 'integer' ],
249 - ],
250 - ],
251 - ];
252 - register_term_meta( $this->product_category, self::GROUP_PRODUCT_CATS_META, $assignment_meta_args );
253 - register_term_meta( $this->product_category, self::GROUP_PRODUCTS_META, $assignment_meta_args );
254 - register_term_meta(
255 - $this->product_category,
256 - self::GROUP_ALL_PRODUCTS_META,
257 - [
258 - 'single' => true,
259 - 'type' => 'boolean',
260 - 'show_in_rest' => true,
261 - ]
262 - );
263 -
264 - /**
265 88 * Register post type
266 89 */
267 90 $labels = [
268 91 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
@@ -309,11 +132,9 @@
309 132 *
310 133 * @param string $tax_slug The taxonomy's slug.
311 134 */
312 135 public function action_created_betterdocs_faq_category( $term_id ) {
313 - $term = get_term( $term_id );
314 - $taxonomy = ( $term && ! is_wp_error( $term ) ) ? $term->taxonomy : $this->category;
315 - $order = $this->get_max_taxonomy_order( $taxonomy );
136 + $order = $this->get_max_taxonomy_order( 'betterdocs_faq_category' );
316 137 update_term_meta( $term_id, 'order', $order++ );
317 138 update_term_meta( $term_id, 'status', 1 );
318 139 }
319 140
@@ -343,14 +164,14 @@
343 164 * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
344 165 */
345 166 private function get_max_taxonomy_order( $tax_slug ) {
346 167 global $wpdb;
347 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live max-order needed when assigning new terms; cache would be stale.
168 + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder
348 169 $max_term_order = $wpdb->get_col(
349 170 $wpdb->prepare(
350 171 "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
351 172 FROM $wpdb->terms t
352 - JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
173 + JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = '%s'
353 174 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
354 175 $tax_slug
355 176 )
356 177 );
@@ -367,15 +188,8 @@
367 188 * @param array $taxonomies Array of taxonomy names.
368 189 * @param array $args Array of term query args.
369 190 */
370 191 public function set_tax_order( $pieces, $taxonomies, $args ) {
371 - // Only force the manual drag-drop `order` meta when the FAQ Builder
372 - // header dropdown is on "default". For name/count/id sort modes, let
373 - // get_terms() keep its own orderby so the front end matches the builder.
374 - if ( betterdocs()->query->get_faq_order_key() !== 'default' ) {
375 - return $pieces;
376 - }
377 -
378 192 foreach ( $taxonomies as $taxonomy ) {
379 193 global $wpdb;
380 194
381 195 if ( $taxonomy === 'betterdocs_faq_category' ) {
@@ -412,138 +226,8 @@
412 226 protected function does_substring_exist( $string, $substring ) {
413 227 return strstr( $string, $substring ) !== false;
414 228 }
415 229
416 - /**
417 - * Load the shared drag-and-drop sorter on the classic FAQ Group list
418 - * (edit-tags.php?taxonomy=betterdocs_faq_category). Reuses the generic,
419 - * config-driven admin/js/category-edit.js — the same script the classic Doc
420 - * Categories screen uses — pointed at the FAQ category ordering AJAX action.
421 - *
422 - * jquery + jquery-ui-sortable are declared explicitly so `.sortable()` is
423 - * always defined (the bare script reported jQuery/Sortable as undefined here
424 - * because no sorter was enqueued on this screen at all).
425 - *
426 - * @param string $hook Current admin page hook.
427 - */
428 - public function classic_category_scripts( $hook ) {
429 - if ( 'edit-tags.php' !== $hook ) {
430 - return;
431 - }
432 -
433 - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
434 - if ( ! $screen || $screen->taxonomy !== $this->category ) {
435 - return;
436 - }
437 -
438 - betterdocs()->assets->enqueue(
439 - 'betterdocs-category-edit',
440 - 'admin/js/category-edit.js',
441 - [ 'jquery', 'jquery-ui-sortable' ]
442 - );
443 -
444 - betterdocs()->assets->localize(
445 - 'betterdocs-category-edit',
446 - 'betterdocsCategorySorting',
447 - [
448 - 'action' => 'update_faq_cat_order',
449 - 'selector' => '.taxonomy-' . $this->category,
450 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
451 - 'nonce' => wp_create_nonce( 'faq_cat_order_nonce' ),
452 - // Paged only sets the ordering base offset (the AJAX write itself
453 - // is nonce-protected), so read it directly from the WP term-list
454 - // pagination link so cross-page ordering stays correct.
455 - 'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
456 - 'per_page_id' => "edit_{$this->category}_per_page",
457 - ]
458 - );
459 - }
460 -
461 - /**
462 - * Persist FAQ Group order from the classic drag-and-drop sorter. Writes the
463 - * `order` term meta that the React FAQ Builder and the front end already read,
464 - * so all three stay in sync. Mirrors PostType::update_category_order.
465 - */
466 - public function ajax_update_category_order() {
467 - if ( ! check_ajax_referer( 'faq_cat_order_nonce', 'nonce', false ) ) {
468 - wp_send_json_error( __( 'Nonce Failed', 'betterdocs' ) );
469 - }
470 -
471 - if ( ! current_user_can( 'edit_others_posts' ) ) {
472 - wp_send_json_error( __( 'You don\'t have permission to manage FAQ groups.', 'betterdocs' ) );
473 - }
474 -
475 - $base_index = isset( $_POST['base_index'] ) ? intval( $_POST['base_index'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
476 -
477 - if ( isset( $_POST['data'] ) && is_array( $_POST['data'] ) ) {
478 - $ordering = array_filter(
479 - array_map(
480 - function ( $item ) {
481 - if ( is_array( $item ) && isset( $item['term_id'], $item['order'] ) ) {
482 - return [
483 - 'term_id' => intval( $item['term_id'] ),
484 - 'order' => intval( $item['order'] ),
485 - ];
486 - }
487 - return null;
488 - },
489 - wp_unslash( $_POST['data'] ) // phpcs:ignore
490 - )
491 - );
492 - } else {
493 - $ordering = [];
494 - }
495 -
496 - foreach ( $ordering as $order_data ) {
497 - update_term_meta( $order_data['term_id'], 'order', $order_data['order'] + $base_index );
498 - }
499 -
500 - wp_send_json_success( __( 'Successfully updated.', 'betterdocs' ) );
501 - }
502 -
503 - /**
504 - * Order the classic FAQ Group list by the manual `order` term meta so the
505 - * drag-and-drop order persists across reloads. Seeds the meta for any terms
506 - * missing it first (e.g. groups created before ordering existed). Mirrors
507 - * PostType::order_terms for the Doc Categories screen.
508 - */
509 - public function order_admin_terms() {
510 - global $current_screen;
511 -
512 - if (
513 - ! isset( $_GET['orderby'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
514 - $current_screen &&
515 - ! empty( $current_screen->base ) &&
516 - $current_screen->base === 'edit-tags' &&
517 - $current_screen->taxonomy === $this->category
518 - ) {
519 - $this->default_term_order( $this->category );
520 - add_filter( 'terms_clauses', [ $this, 'admin_order_terms_clauses' ], 10, 3 );
521 - }
522 - }
523 -
524 - /**
525 - * terms_clauses filter (classic FAQ Group admin list only): force ORDER BY
526 - * the `order` term meta. Unlike set_tax_order this is not gated on the
527 - * builder's saved order key — the classic screen is always manual ordering.
528 - */
529 - public function admin_order_terms_clauses( $pieces, $taxonomies, $args ) {
530 - if ( ! in_array( $this->category, (array) $taxonomies, true ) ) {
531 - return $pieces;
532 - }
533 -
534 - global $wpdb;
535 - $join_statement = " LEFT JOIN $wpdb->termmeta AS bd_faq_order ON t.term_id = bd_faq_order.term_id AND bd_faq_order.meta_key = 'order'";
536 -
537 - if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
538 - $pieces['join'] .= $join_statement;
539 - }
540 -
541 - $pieces['orderby'] = 'ORDER BY CAST( bd_faq_order.meta_value AS UNSIGNED )';
542 -
543 - return $pieces;
544 - }
545 -
546 230 public function register_api_endpoint() {
547 231 register_rest_route(
548 232 $this->namespace,
549 233 '/faq/sample_data',
@@ -561,11 +245,9 @@
561 245 '/faq/posts/(?P<type>\S+)',
562 246 [
563 247 'methods' => [ 'GET' ],
564 248 'callback' => [ $this, 'fetch_faq_posts' ],
565 - 'permission_callback' => function () {
566 - return current_user_can( 'edit_others_posts' );
567 - }
249 + 'permission_callback' => '__return_true'
568 250 ]
569 251 );
570 252
571 253 register_rest_route(
@@ -677,27 +359,13 @@
677 359 );
678 360
679 361 register_rest_route(
680 362 $this->namespace,
681 - '/faq/order',
682 - [
683 - 'methods' => [ 'POST' ],
684 - 'callback' => [ $this, 'update_faq_order_preference' ],
685 - 'permission_callback' => function () {
686 - return current_user_can( 'edit_others_posts' );
687 - }
688 - ]
689 - );
690 -
691 - register_rest_route(
692 - $this->namespace,
693 363 '/faq/uncategorised',
694 364 [
695 365 'methods' => [ 'GET' ],
696 366 'callback' => [ $this, 'get_uncategorised_faq' ],
697 - 'permission_callback' => function () {
698 - return current_user_can( 'edit_others_posts' );
699 - }
367 + 'permission_callback' => '__return_true'
700 368 ]
701 369 );
702 370
703 371 register_rest_route(
@@ -705,21 +373,13 @@
705 373 '/faq/category_search',
706 374 [
707 375 'methods' => [ 'GET' ],
708 376 'callback' => [ $this, 'category_search' ],
709 - 'permission_callback' => function () {
710 - return current_user_can( 'edit_others_posts' );
711 - },
377 + 'permission_callback' => '__return_true',
712 378 'args' => [
713 379 'title' => [
714 - 'type' => 'string',
715 - 'required' => true,
716 - 'sanitize_callback' => 'sanitize_text_field'
717 - ],
718 - 'taxonomy' => [
719 - 'type' => 'string',
720 - 'required' => false,
721 - 'sanitize_callback' => 'sanitize_text_field'
380 + 'type' => 'string',
381 + 'required' => true
722 382 ]
723 383 ]
724 384 ]
725 385 );
@@ -740,62 +400,27 @@
740 400 }
741 401 return true;
742 402 }
743 403
744 - /**
745 - * Resolve the taxonomy from a request, whitelisted to the General and
746 - * Product FAQ category taxonomies. Defaults to the General taxonomy so
747 - * existing callers (which never send a taxonomy) keep their behavior.
748 - *
749 - * @param \WP_REST_Request $params
750 - * @return string
751 - */
752 - private function resolve_taxonomy( $params ) {
753 - $taxonomy = $params->get_param( 'taxonomy' );
754 - return in_array( $taxonomy, [ $this->category, $this->product_category ], true )
755 - ? $taxonomy
756 - : $this->category;
757 - }
758 -
759 404 public function create_faq_category( $params ) {
760 - // The WP term `name` column is varchar(200); cap the length so an
761 - // over-long title can't trigger a DB insert error (the React form caps
762 - // this too, this is the server-side guard).
763 - $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
405 + $title = $params->get_param( 'title' );
764 406 $description = $params->get_param( 'description' );
765 407 $description = ( $description !== 'undefined' ) ? $description : '';
766 408 $slug = $params->get_param( 'slug' );
767 409 $group_icon_url = $params->get_param('group_icon_url');
768 - $taxonomy = $this->resolve_taxonomy( $params );
769 - $result = $this->insert_betterdocs_faq_category(
770 - $title,
771 - $description,
772 - $group_icon_url,
773 - $slug,
774 - $taxonomy,
775 - (array) $params->get_param( 'product_cats' ),
776 - (array) $params->get_param( 'products' )
777 - );
778 -
779 - if ( is_wp_error( $result ) ) {
780 - return $result;
781 - }
782 -
783 - return rest_ensure_response( array( 'success' => true, 'term_id' => (int) $result ) );
410 + return $this->insert_betterdocs_faq_category( $title, $description, $group_icon_url, $slug );
784 411 }
785 412
786 413 public function update_faq_category( $params ) {
787 414 $term_id = $params->get_param( 'term_id' );
788 - // Cap at the varchar(200) term-name limit (server-side guard).
789 - $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
415 + $title = $params->get_param( 'title' );
790 416 $description = $params->get_param( 'description' );
791 417 $group_icon_url = $params->get_param('group_icon_url');
792 418 $description = ( $description !== 'undefined' ) ? $description : '';
793 419 $slug = $params->get_param( 'slug' );
794 - $taxonomy = $this->resolve_taxonomy( $params );
795 420 $update = wp_update_term(
796 421 $term_id,
797 - $taxonomy,
422 + 'betterdocs_faq_category',
798 423 [
799 424 'name' => $title,
800 425 'slug' => $slug,
801 426 'description' => $description
@@ -808,14 +433,8 @@
808 433 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
809 434 if( $term_id != 0 ) {
810 435 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
811 436 update_term_meta($term_id, 'faq_group_icon', $group_icon_url, $previous_icon_url);
812 - $this->save_group_assignments(
813 - $term_id,
814 - $taxonomy,
815 - (array) $params->get_param( 'product_cats' ),
816 - (array) $params->get_param( 'products' )
817 - );
818 437 }
819 438 return true;
820 439 }
821 440 }
@@ -822,15 +441,14 @@
822 441
823 442 public function delete_faq_category( $params ) {
824 443 $term_id = $params->get_param( 'term_id' );
825 444 $delete_all_docs = $params->get_param('with_all_post');
826 - $taxonomy = $this->resolve_taxonomy( $params );
827 445
828 446 if ( $delete_all_docs ) {
829 - Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
447 + Helper::delete_specific_faq_posts_by_faq_category( $term_id );
830 448 }
831 449
832 - $delete = wp_delete_term( $term_id, $taxonomy );
450 + $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
833 451
834 452 if ( is_wp_error( $delete ) ) {
835 453 return $delete;
836 454 } else {
@@ -837,12 +455,12 @@
837 455 return true;
838 456 }
839 457 }
840 458
841 - public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [] ) {
459 + public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '' ) {
842 460 $insert_term = wp_insert_term(
843 461 $title,
844 - $taxonomy,
462 + 'betterdocs_faq_category',
845 463 [
846 464 'slug' => $slug,
847 465 'description' => $description
848 466 ]
@@ -853,67 +471,13 @@
853 471 } else {
854 472 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
855 473 if( $term_id != 0 ) {
856 474 update_term_meta($term_id, 'faq_group_icon', $icon_url);
857 - $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products );
858 475 }
859 - // Return the new term id so the admin can jump to its pagination
860 - // page and highlight it (mirrors the new-FAQ reveal flow).
861 - return (int) $term_id;
476 + return true;
862 477 }
863 478 }
864 479
865 - /**
866 - * Persist a Product FAQ group's product-category and product assignments.
867 - * No-op for any taxonomy other than the Product FAQ groups taxonomy.
868 - *
869 - * @param int $term_id
870 - * @param string $taxonomy
871 - * @param array $product_cats Incoming product_cat term IDs.
872 - * @param array $products Incoming product post IDs.
873 - */
874 - private function save_group_assignments( $term_id, $taxonomy, $product_cats, $products ) {
875 - if ( $taxonomy !== $this->product_category ) {
876 - return;
877 - }
878 -
879 - $cat_ids = [];
880 - foreach ( (array) $product_cats as $cid ) {
881 - $cid = (int) $cid;
882 - if ( $cid > 0 && term_exists( $cid, 'product_cat' ) ) {
883 - $cat_ids[] = $cid;
884 - }
885 - }
886 - $cat_ids = array_values( array_unique( $cat_ids ) );
887 -
888 - $product_ids = [];
889 - foreach ( (array) $products as $pid ) {
890 - $pid = (int) $pid;
891 - if ( $pid > 0 && 'product' === get_post_type( $pid ) ) {
892 - $product_ids[] = $pid;
893 - }
894 - }
895 - $product_ids = array_values( array_unique( $product_ids ) );
896 -
897 - if ( empty( $cat_ids ) ) {
898 - delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
899 - } else {
900 - update_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META, $cat_ids );
901 - }
902 -
903 - if ( empty( $product_ids ) ) {
904 - delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
905 - } else {
906 - update_term_meta( $term_id, self::GROUP_PRODUCTS_META, $product_ids );
907 - }
908 -
909 - // Choosing explicit targets re-scopes a previously "all products" group
910 - // (e.g. a store-aware sample group), so drop the match-all flag.
911 - if ( ! empty( $cat_ids ) || ! empty( $product_ids ) ) {
912 - delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
913 - }
914 - }
915 -
916 480 public function update_faq_category_order( $params ) {
917 481 $faq_category_order = $params->get_param( 'faq_category_order' );
918 482 $faq_category_order = json_decode( $faq_category_order, true );
919 483
@@ -924,9 +488,9 @@
924 488 }
925 489 return true;
926 490 }
927 491
928 - public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
492 + public function insert_betterdocs_faq( $post_title, $post_content, $term_id ) {
929 493 $post = wp_insert_post(
930 494 [
931 495 'post_type' => 'betterdocs_faq',
932 496 'post_title' => wp_strip_all_tags( $post_title ),
@@ -935,29 +499,30 @@
935 499 ]
936 500 );
937 501
938 502 if ( $term_id ) {
939 - $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
503 + $set_terms = wp_set_object_terms( $post, $term_id, 'betterdocs_faq_category' );
940 504 if ( is_wp_error( $set_terms ) ) {
941 505 return $set_terms;
506 + } else {
507 + return $this->update_faq_order_on_insert( $term_id, $post );
942 508 }
943 - $this->update_faq_order_on_insert( $term_id, $post );
509 + } else {
510 + return $post;
944 511 }
945 -
946 - // Always return the new post ID so the admin can reveal/highlight the
947 - // created FAQ (the grouped path previously returned the term-meta result).
948 - return $post;
949 512 }
950 513
951 514 public function update_faq_order_on_insert( $term_id, $post ) {
952 - // QA-008: read the single stored value and strip blanks so the very first
953 - // insert (no existing meta) doesn't explode(',', null) into a corrupt [''].
954 - $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
955 - $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
956 - if ( ! in_array( (string) $post, $term_meta_arr, true ) ) {
957 - array_unshift( $term_meta_arr, $post );
958 - $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
959 - return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
515 + $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
516 + if ( ! empty( $term_meta ) ) {
517 + $term_meta_arr = explode( ',', $term_meta[0] );
518 + if ( ! in_array( $post, $term_meta_arr ) ) {
519 + array_unshift( $term_meta_arr, $post );
520 + $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
521 + return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
522 + }
523 + } else {
524 + return update_term_meta( $term_id, '_betterdocs_faq_order', $post );
960 525 }
961 526 }
962 527
963 528 /**
@@ -969,48 +534,21 @@
969 534 $posts = $params->get_param( 'posts' );
970 535 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
971 536 }
972 537
973 - /**
974 - * Persist the FAQ Builder header order dropdown so the front end can mirror
975 - * it. Stored as a single global preference in the `betterdocs_faq_order`
976 - * option and read back by Query::get_faq_order_key().
977 - */
978 - public function update_faq_order_preference( $params ) {
979 - $order = $params->get_param( 'order' );
980 - $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
981 -
982 - if ( ! in_array( $order, $allowed, true ) ) {
983 - $order = 'default';
984 - }
985 -
986 - update_option( 'betterdocs_faq_order', $order );
987 -
988 - return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
989 - }
990 -
991 538 public function create_betterdocs_faq( $params ) {
992 539 $post_title = $params->get_param( 'post_title' );
993 540 $post_content = $params->get_param( 'post_content' );
994 541 $term_id = $params->get_param( 'term_id' );
995 - $taxonomy = $this->resolve_taxonomy( $params );
996 - return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
542 + return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id );
997 543 }
998 544
999 545 public function update_betterdocs_faq( $params ) {
1000 - $post_id = (int) $params->get_param( 'post_id' );
1001 -
1002 - // QA-001: never let an arbitrary post ID be retyped/overwritten through
1003 - // this endpoint. Only operate on posts that are already BetterDocs FAQs.
1004 - if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1005 - return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1006 - }
1007 -
546 + $post_id = $params->get_param( 'post_id' );
1008 547 $post_title = $params->get_param( 'post_title' );
1009 548 $post_content = $params->get_param( 'post_content' );
1010 549 $status = $params->get_param( 'status' );
1011 - $term_id = (int) $params->get_param( 'term_id' );
1012 - $taxonomy = $this->resolve_taxonomy( $params );
550 + $term_id = $params->get_param( 'term_id' );
1013 551 if ( $status ) {
1014 552 $data = [
1015 553 'post_type' => 'betterdocs_faq',
1016 554 'ID' => $post_id,
@@ -1019,30 +557,20 @@
1019 557 } else {
1020 558 $data = [
1021 559 'post_type' => 'betterdocs_faq',
1022 560 'ID' => $post_id,
1023 - // QA-009: sanitize server-side — the editor posts raw TinyMCE
1024 - // HTML. Title is plain text; content keeps post-safe HTML only
1025 - // (scripts / event handlers stripped) even for unfiltered_html users.
1026 - 'post_title' => sanitize_text_field( $post_title ),
1027 - 'post_content' => wp_kses_post( $post_content ),
561 + 'post_title' => $post_title,
562 + 'post_content' => $post_content
1028 563 ];
1029 564
1030 - // QA-007: only assign a term that actually exists in the resolved FAQ
1031 - // taxonomy — an invalid term_id would silently orphan the FAQ.
1032 - if ( $term_id && term_exists( $term_id, $taxonomy ) ) {
565 + if ( $term_id ) {
1033 566 $data['tax_input'] = [
1034 - $taxonomy => [ $term_id ],
567 + 'betterdocs_faq_category' => $term_id
1035 568 ];
1036 569
1037 - // QA-008: build the order list defensively. On the first update the
1038 - // term has no _betterdocs_faq_order meta; the old code did
1039 - // explode(',', null) → [''] which corrupted the order with a blank
1040 - // entry (plus a PHP 8.1 "null to explode" deprecation). Read the
1041 - // single value and drop blanks instead.
1042 - $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1043 - $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1044 - if ( ! in_array( (string) $post_id, $term_meta_arr, true ) ) {
570 + $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
571 + $term_meta_arr = explode( ',', $term_meta[0] );
572 + if ( ! in_array( $post_id, $term_meta_arr ) ) {
1045 573 array_unshift( $term_meta_arr, $post_id );
1046 574 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1047 575 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1048 576 }
@@ -1052,15 +580,9 @@
1052 580 return wp_update_post( $data );
1053 581 }
1054 582
1055 583 public function delete_betterdocs_faq( $params ) {
1056 - $post_id = (int) $params->get_param( 'post_id' );
1057 -
1058 - // QA-001: refuse to delete anything that isn't a BetterDocs FAQ.
1059 - if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1060 - return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1061 - }
1062 -
584 + $post_id = $params->get_param( 'post_id' );
1063 585 return wp_delete_post( $post_id );
1064 586 }
1065 587
1066 588 public function faq_post_loop( $args ) {
@@ -1096,9 +618,8 @@
1096 618 );
1097 619
1098 620 if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
1099 621 foreach ( $taxonomy_objects as $term ) :
1100 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- core FAQ-by-category query; tax filtering is required functionality.
1101 622 $args = [
1102 623 'post_type' => 'betterdocs_faq',
1103 624 'post_status' => 'publish',
1104 625 'post_per_page' => -1,
@@ -1138,82 +659,40 @@
1138 659 return $term->term_id;
1139 660 },
1140 661 get_terms(
1141 662 [
1142 - 'taxonomy' => $this->category,
663 + 'taxonomy' => 'betterdocs_faq_category',
1143 664 'hide_empty' => false
1144 665 ]
1145 666 )
1146 667 );
1147 668
1148 - $tax_query = [
1149 - 'relation' => 'AND',
669 + return get_posts(
1150 670 [
1151 - 'taxonomy' => $this->category,
1152 - 'field' => 'term_id',
1153 - 'terms' => $available_terms,
1154 - 'operator' => 'NOT IN'
1155 - ]
1156 - ];
1157 -
1158 - // Keep Product FAQ posts out of the General "Uncategorized" group: they
1159 - // have no General category, so without this they would leak into the
1160 - // General FAQ Builder tab.
1161 - $product_terms = array_map(
1162 - function ( $term ) {
1163 - return $term->term_id;
1164 - },
1165 - get_terms(
1166 - [
1167 - 'taxonomy' => $this->product_category,
1168 - 'hide_empty' => false
1169 - ]
1170 - )
1171 - );
1172 -
1173 - if ( ! empty( $product_terms ) ) {
1174 - $tax_query[] = [
1175 - 'taxonomy' => $this->product_category,
1176 - 'field' => 'term_id',
1177 - 'terms' => $product_terms,
1178 - 'operator' => 'NOT IN'
1179 - ];
1180 - }
1181 -
1182 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- intentional NOT-IN scan to find FAQs without any category assignment.
1183 - $posts = get_posts(
1184 - [
1185 671 'post_type' => 'betterdocs_faq',
1186 - 'post_status' => current_user_can( 'edit_others_posts' ) ? [ 'publish', 'draft' ] : 'publish',
672 + 'post_status' => [ 'publish', 'draft' ],
1187 673 'posts_per_page' => -1,
1188 - 'tax_query' => $tax_query
674 + 'tax_query' => [
675 + 'relation' => 'AND',
676 + [
677 + 'taxonomy' => 'betterdocs_faq_category',
678 + 'field' => 'term_id',
679 + 'terms' => $available_terms,
680 + 'operator' => 'NOT IN'
681 + ]
682 + ]
1189 683 ]
1190 684 );
1191 -
1192 - // get_posts() returns raw WP_Post objects without a `meta` property. The
1193 - // FAQ Builder reads `faq.meta.faq_open_by_default` to seed the "Keep It
1194 - // Open By default" switcher, so attach it here to mirror the scalar shape
1195 - // the wp/v2 endpoint returns for categorized FAQs ('' when unset, '1'
1196 - // when enabled). Without this the value is undefined and the switcher
1197 - // defaults to ON for every uncategorized FAQ.
1198 - foreach ( $posts as $post ) {
1199 - $post->meta = [
1200 - 'faq_open_by_default' => get_post_meta( $post->ID, 'faq_open_by_default', true ),
1201 - ];
1202 - }
1203 -
1204 - return $posts;
1205 685 }
1206 686
1207 687 public function category_search( $request ) {
1208 688
1209 - $title = $request['title'];
1210 - $taxonomy = $this->resolve_taxonomy( $request );
689 + $title = $request['title'];
1211 690
1212 691 // Perform the taxonomy search
1213 692 $taxonomy_args = [
1214 693 'name__like' => $title,
1215 - 'taxonomy' => $taxonomy,
694 + 'taxonomy' => 'betterdocs_faq_category',
1216 695 'hide_empty' => false
1217 696 ];
1218 697
1219 698 $taxonomies = get_terms( $taxonomy_args );
@@ -1238,56 +717,11 @@
1238 717 }
1239 718 }
1240 719
1241 720 public function faq_category_orderby_meta( $args, $request ) {
1242 - if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1243 - return $args;
721 + if ( $args['taxonomy'] === 'betterdocs_faq_category' ) {
722 + $args['orderby'] = 'meta_value_num';
723 + $args['meta_key'] = 'order';
1244 724 }
1245 -
1246 - // Order (and therefore paginate) the whole group list server-side to
1247 - // match the FAQ Builder header dropdown, so page boundaries are cut on
1248 - // the same sort the rows are shown in — otherwise page 1 could end on
1249 - // "W" and page 2 start on "B". The admin passes the order key explicitly
1250 - // (independent of when the preference option finishes saving); fall back
1251 - // to the saved preference, then to the manual drag-drop order.
1252 - $order_key = $request->get_param( 'betterdocs_faq_order' );
1253 - if ( empty( $order_key ) ) {
1254 - $order_key = get_option( 'betterdocs_faq_order', 'default' );
1255 - }
1256 -
1257 - switch ( $order_key ) {
1258 - case 'most_recent':
1259 - $args['orderby'] = 'term_id';
1260 - $args['order'] = 'DESC';
1261 - unset( $args['meta_key'] );
1262 - break;
1263 - case 'least_recent':
1264 - $args['orderby'] = 'term_id';
1265 - $args['order'] = 'ASC';
1266 - unset( $args['meta_key'] );
1267 - break;
1268 - case 'a_to_z':
1269 - $args['orderby'] = 'name';
1270 - $args['order'] = 'ASC';
1271 - unset( $args['meta_key'] );
1272 - break;
1273 - case 'z_to_a':
1274 - $args['orderby'] = 'name';
1275 - $args['order'] = 'DESC';
1276 - unset( $args['meta_key'] );
1277 - break;
1278 - case 'most_questions':
1279 - $args['orderby'] = 'count';
1280 - $args['order'] = 'DESC';
1281 - unset( $args['meta_key'] );
1282 - break;
1283 - case 'default':
1284 - default:
1285 - $args['orderby'] = 'meta_value_num';
1286 - $args['meta_key'] = 'order';
1287 - $args['order'] = 'ASC';
1288 - break;
1289 - }
1290 -
1291 725 return $args;
1292 726 }
1293 727 }