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/FAQBuilder.php +802 -79 4.5.64.9.2 View file →
@@ -11,8 +11,9 @@
11 11 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
12 12
13 13 use WP_Error;
14 14 use WP_Query;
15 +use WP_REST_Request;
15 16 use WPDeveloper\BetterDocs\Utils\Base;
16 17 use WPDeveloper\BetterDocs\Utils\Helper;
17 18
18 19 class FAQBuilder extends Base {
@@ -24,9 +25,63 @@
24 25 public $post_type = 'betterdocs_faq';
25 26 public $category = 'betterdocs_faq_category';
26 27
27 28 /**
29 + * Product FAQ groups taxonomy. Shares the betterdocs_faq post type with the
30 + * General FAQ groups but is a separate taxonomy so the two never mix in the
31 + * admin or on the front end. Used by the WooCommerce Product FAQ tab.
28 32 *
33 + * @var string
34 + */
35 + public $product_category = 'betterdocs_product_faq_category';
36 +
37 + /**
38 + * Post meta recording which FAQ Builder tab an FAQ belongs to: 'product' or
39 + * 'general'.
40 + *
41 + * A General and a Product FAQ are the same post type, distinguished only by which
42 + * taxonomy their group lives in — so an FAQ with NO group had no scope at all, and
43 + * an ungrouped Product FAQ (its group deleted, or created without one) was
44 + * indistinguishable from a General one. That's why it used to surface under the
45 + * General tab's "Uncategorized". Recording the scope on the post lets each tab own
46 + * its own Uncategorized bucket.
47 + */
48 + const SCOPE_META = '_betterdocs_faq_scope';
49 +
50 + /** Option flag for the one-time backfill of SCOPE_META on pre-existing FAQs. */
51 + const SCOPE_BACKFILL_OPTION = 'betterdocs_faq_scope_backfilled';
52 +
53 + /**
54 + * Term meta on a Product FAQ group holding the product_cat term IDs the
55 + * group is assigned to. Products in those categories inherit the group.
56 + */
57 + const GROUP_PRODUCT_CATS_META = '_betterdocs_faq_group_product_cats';
58 +
59 + /**
60 + * Term meta on a Product FAQ group holding the individual product IDs the
61 + * group is assigned to directly.
62 + */
63 + const GROUP_PRODUCTS_META = '_betterdocs_faq_group_products';
64 +
65 + /**
66 + * Term meta flagging a Product FAQ group as shown on every product (no
67 + * per-product/per-category targeting). Set on the store-aware sample groups
68 + * so they render storefront-wide immediately; cleared the moment the owner
69 + * saves explicit category/product assignments.
70 + */
71 + const GROUP_ALL_PRODUCTS_META = '_betterdocs_faq_group_all_products';
72 +
73 + /**
74 + * Per-request cache of draft FAQ counts keyed by taxonomy → [ term_id => count ].
75 + * Lets the betterdocs_draft_count REST field resolve every term from a single
76 + * grouped query instead of one WP_Query per term (N+1).
77 + *
78 + * @var array<string, array<int, int>>
79 + */
80 + protected $draft_count_cache = [];
81 +
82 + /**
83 + *
29 84 * Initialize the class and start calling our hooks and filters
30 85 *
31 86 * @since 1.0.0
32 87 *
@@ -35,12 +90,97 @@
35 90 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
36 91 add_action( 'init', [ $this, 'register_post' ] );
37 92 // fires after a new betterdocs_faq_category is created
38 93 add_action( 'created_betterdocs_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
94 + add_action( 'created_betterdocs_product_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
39 95 add_action( 'rest_api_init', [ $this, 'register_api_endpoint' ] );
96 + add_action( 'rest_api_init', [ $this, 'register_category_count_fields' ] );
97 +
98 + // Keep each FAQ's scope ('general' | 'product') in sync however its group is
99 + // assigned — the Builder, the sample-FAQ generator, an import, or the classic
100 + // post editor all end up here — so an FAQ that later loses its group is still
101 + // known to belong to its own tab's "Uncategorized" bucket.
102 + add_action( 'set_object_terms', [ $this, 'sync_faq_scope_meta' ], 10, 4 );
103 + // One-time backfill for FAQs that predate the scope meta.
104 + add_action( 'admin_init', [ $this, 'maybe_backfill_faq_scope' ] );
40 105 add_action( 'rest_betterdocs_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
106 + add_action( 'rest_betterdocs_product_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
107 +
108 + // Classic FAQ Group list (edit-tags.php?taxonomy=betterdocs_faq_category):
109 + // enable drag-and-drop ordering + persist it, mirroring the classic Doc
110 + // Categories screen.
111 + add_action( 'admin_enqueue_scripts', [ $this, 'classic_category_scripts' ] );
112 + add_action( 'wp_ajax_update_faq_cat_order', [ $this, 'ajax_update_category_order' ] );
113 + add_action( 'admin_head', [ $this, 'order_admin_terms' ] );
41 114 }
42 115
116 + /**
117 + * Expose a per-group draft FAQ count on the betterdocs_faq_category REST
118 + * response so the FAQ Builder can show "N questions • M draft". The term
119 + * `count` only tracks published FAQs (via _update_post_term_count), so the
120 + * draft count is computed here.
121 + *
122 + * @since 4.4.0
123 + */
124 + public function register_category_count_fields() {
125 + foreach ( [ $this->category, $this->product_category ] as $taxonomy ) {
126 + register_rest_field(
127 + $taxonomy,
128 + 'betterdocs_draft_count',
129 + [
130 + 'get_callback' => function ( $term ) {
131 + $counts = $this->get_draft_counts_by_term( $term['taxonomy'] );
132 + return isset( $counts[ (int) $term['id'] ] ) ? (int) $counts[ (int) $term['id'] ] : 0;
133 + },
134 + 'schema' => [
135 + 'type' => 'integer',
136 + 'context' => [ 'view', 'edit' ],
137 + ],
138 + ]
139 + );
140 + }
141 + }
142 +
143 + /**
144 + * Draft FAQ counts for every term in a taxonomy, keyed by term_id.
145 + *
146 + * Computed once per request with a single grouped query and memoized, so the
147 + * betterdocs_draft_count REST field no longer fires a WP_Query per term when
148 + * a category list is assembled (avoids the N+1 on large group counts).
149 + *
150 + * @param string $taxonomy
151 + * @return array<int, int>
152 + */
153 + protected function get_draft_counts_by_term( $taxonomy ) {
154 + if ( isset( $this->draft_count_cache[ $taxonomy ] ) ) {
155 + return $this->draft_count_cache[ $taxonomy ];
156 + }
157 +
158 + global $wpdb;
159 +
160 + $rows = $wpdb->get_results(
161 + $wpdb->prepare(
162 + "SELECT tt.term_id, COUNT( p.ID ) AS draft_count
163 + FROM {$wpdb->posts} p
164 + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
165 + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
166 + WHERE p.post_type = %s
167 + AND p.post_status = 'draft'
168 + AND tt.taxonomy = %s
169 + GROUP BY tt.term_id",
170 + $this->post_type,
171 + $taxonomy
172 + )
173 + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
174 +
175 + $counts = [];
176 + foreach ( (array) $rows as $row ) {
177 + $counts[ (int) $row->term_id ] = (int) $row->draft_count;
178 + }
179 +
180 + return $this->draft_count_cache[ $taxonomy ] = $counts;
181 + }
182 +
43 183 public function output() {
44 184 betterdocs()->views->get( 'admin/faq-builder' );
45 185 }
46 186
@@ -92,8 +232,62 @@
92 232 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
93 233 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
94 234
95 235 /**
236 + * Register the Product FAQ groups taxonomy (WooCommerce tab). It mirrors
237 + * the General FAQ category taxonomy and shares the betterdocs_faq post
238 + * type, but is kept separate so Product FAQ groups never appear in the
239 + * General FAQ Builder tab.
240 + */
241 + $product_category_labels = [
242 + 'name' => __( 'Product FAQ Categories', 'betterdocs' ),
243 + 'singular_name' => __( 'Product FAQ Category', 'betterdocs' ),
244 + 'all_items' => __( 'Product FAQ Categories', 'betterdocs' ),
245 + 'parent_item' => __( 'Parent Product FAQ Category', 'betterdocs' ),
246 + 'parent_item_colon' => __( 'Parent Product FAQ Category:', 'betterdocs' ),
247 + 'edit_item' => __( 'Edit Product FAQ Category', 'betterdocs' ),
248 + 'update_item' => __( 'Update Product FAQ Category', 'betterdocs' ),
249 + 'add_new_item' => __( 'Add New Product FAQ Category', 'betterdocs' ),
250 + 'new_item_name' => __( 'New Product FAQ Category Name', 'betterdocs' ),
251 + 'menu_name' => __( 'Product FAQ Categories', 'betterdocs' )
252 + ];
253 +
254 + $product_category_args = $category_args;
255 + $product_category_args['labels'] = $product_category_labels;
256 + $product_category_args['show_admin_column'] = false;
257 +
258 + register_taxonomy( $this->product_category, [ $this->post_type ], $product_category_args );
259 + register_term_meta( $this->product_category, 'order', [ 'show_in_rest' => true ] );
260 + register_term_meta( $this->product_category, 'status', [ 'show_in_rest' => true ] );
261 + register_term_meta( $this->product_category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
262 + register_term_meta( $this->product_category, 'faq_group_icon', [ 'show_in_rest' => true ] );
263 +
264 + // Product FAQ group assignments: which WooCommerce product categories and
265 + // which individual products this group is shown on. Stored on the group
266 + // term so the assignment lives in the Create/Update FAQ Group screen.
267 + $assignment_meta_args = [
268 + 'single' => true,
269 + 'type' => 'array',
270 + 'show_in_rest' => [
271 + 'schema' => [
272 + 'type' => 'array',
273 + 'items' => [ 'type' => 'integer' ],
274 + ],
275 + ],
276 + ];
277 + register_term_meta( $this->product_category, self::GROUP_PRODUCT_CATS_META, $assignment_meta_args );
278 + register_term_meta( $this->product_category, self::GROUP_PRODUCTS_META, $assignment_meta_args );
279 + register_term_meta(
280 + $this->product_category,
281 + self::GROUP_ALL_PRODUCTS_META,
282 + [
283 + 'single' => true,
284 + 'type' => 'boolean',
285 + 'show_in_rest' => true,
286 + ]
287 + );
288 +
289 + /**
96 290 * Register post type
97 291 */
98 292 $labels = [
99 293 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
@@ -140,9 +334,11 @@
140 334 *
141 335 * @param string $tax_slug The taxonomy's slug.
142 336 */
143 337 public function action_created_betterdocs_faq_category( $term_id ) {
144 - $order = $this->get_max_taxonomy_order( 'betterdocs_faq_category' );
338 + $term = get_term( $term_id );
339 + $taxonomy = ( $term && ! is_wp_error( $term ) ) ? $term->taxonomy : $this->category;
340 + $order = $this->get_max_taxonomy_order( $taxonomy );
145 341 update_term_meta( $term_id, 'order', $order++ );
146 342 update_term_meta( $term_id, 'status', 1 );
147 343 }
148 344
@@ -196,8 +392,15 @@
196 392 * @param array $taxonomies Array of taxonomy names.
197 393 * @param array $args Array of term query args.
198 394 */
199 395 public function set_tax_order( $pieces, $taxonomies, $args ) {
396 + // Only force the manual drag-drop `order` meta when the FAQ Builder
397 + // header dropdown is on "default". For name/count/id sort modes, let
398 + // get_terms() keep its own orderby so the front end matches the builder.
399 + if ( betterdocs()->query->get_faq_order_key() !== 'default' ) {
400 + return $pieces;
401 + }
402 +
200 403 foreach ( $taxonomies as $taxonomy ) {
201 404 global $wpdb;
202 405
203 406 if ( $taxonomy === 'betterdocs_faq_category' ) {
@@ -234,8 +437,169 @@
234 437 protected function does_substring_exist( $string, $substring ) {
235 438 return strstr( $string, $substring ) !== false;
236 439 }
237 440
441 + /**
442 + * Load the shared drag-and-drop sorter on the classic FAQ Group list
443 + * (edit-tags.php?taxonomy=betterdocs_faq_category). Reuses the generic,
444 + * config-driven admin/js/category-edit.js — the same script the classic Doc
445 + * Categories screen uses — pointed at the FAQ category ordering AJAX action.
446 + *
447 + * jquery + jquery-ui-sortable are declared explicitly so `.sortable()` is
448 + * always defined (the bare script reported jQuery/Sortable as undefined here
449 + * because no sorter was enqueued on this screen at all).
450 + *
451 + * @param string $hook Current admin page hook.
452 + */
453 + public function classic_category_scripts( $hook ) {
454 + if ( 'edit-tags.php' !== $hook ) {
455 + return;
456 + }
457 +
458 + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
459 + if ( ! $screen || $screen->taxonomy !== $this->category ) {
460 + return;
461 + }
462 +
463 + betterdocs()->assets->enqueue(
464 + 'betterdocs-category-edit',
465 + 'admin/js/category-edit.js',
466 + [ 'jquery', 'jquery-ui-sortable' ]
467 + );
468 +
469 + betterdocs()->assets->localize(
470 + 'betterdocs-category-edit',
471 + 'betterdocsCategorySorting',
472 + [
473 + 'action' => 'update_faq_cat_order',
474 + 'selector' => '.taxonomy-' . $this->category,
475 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
476 + 'nonce' => wp_create_nonce( 'faq_cat_order_nonce' ),
477 + // Paged only sets the ordering base offset (the AJAX write itself
478 + // is nonce-protected), so read it directly from the WP term-list
479 + // pagination link so cross-page ordering stays correct.
480 + 'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
481 + 'per_page_id' => "edit_{$this->category}_per_page",
482 + ]
483 + );
484 + }
485 +
486 + /**
487 + * Persist FAQ Group order from the classic drag-and-drop sorter. Writes the
488 + * `order` term meta that the React FAQ Builder and the front end already read,
489 + * so all three stay in sync. Mirrors PostType::update_category_order.
490 + */
491 + public function ajax_update_category_order() {
492 + if ( ! check_ajax_referer( 'faq_cat_order_nonce', 'nonce', false ) ) {
493 + wp_send_json_error( __( 'Nonce Failed', 'betterdocs' ) );
494 + }
495 +
496 + if ( ! $this->can_manage_faqs() ) {
497 + wp_send_json_error( __( 'You don\'t have permission to manage FAQ groups.', 'betterdocs' ) );
498 + }
499 +
500 + $base_index = isset( $_POST['base_index'] ) ? intval( $_POST['base_index'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
501 +
502 + if ( isset( $_POST['data'] ) && is_array( $_POST['data'] ) ) {
503 + $ordering = array_filter(
504 + array_map(
505 + function ( $item ) {
506 + if ( is_array( $item ) && isset( $item['term_id'], $item['order'] ) ) {
507 + return [
508 + 'term_id' => intval( $item['term_id'] ),
509 + 'order' => intval( $item['order'] ),
510 + ];
511 + }
512 + return null;
513 + },
514 + wp_unslash( $_POST['data'] ) // phpcs:ignore
515 + )
516 + );
517 + } else {
518 + $ordering = [];
519 + }
520 +
521 + foreach ( $ordering as $order_data ) {
522 + update_term_meta( $order_data['term_id'], 'order', $order_data['order'] + $base_index );
523 + }
524 +
525 + wp_send_json_success( __( 'Successfully updated.', 'betterdocs' ) );
526 + }
527 +
528 + /**
529 + * Order the classic FAQ Group list by the manual `order` term meta so the
530 + * drag-and-drop order persists across reloads. Seeds the meta for any terms
531 + * missing it first (e.g. groups created before ordering existed). Mirrors
532 + * PostType::order_terms for the Doc Categories screen.
533 + */
534 + public function order_admin_terms() {
535 + global $current_screen;
536 +
537 + if (
538 + ! isset( $_GET['orderby'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
539 + $current_screen &&
540 + ! empty( $current_screen->base ) &&
541 + $current_screen->base === 'edit-tags' &&
542 + $current_screen->taxonomy === $this->category
543 + ) {
544 + $this->default_term_order( $this->category );
545 + add_filter( 'terms_clauses', [ $this, 'admin_order_terms_clauses' ], 10, 3 );
546 + }
547 + }
548 +
549 + /**
550 + * terms_clauses filter (classic FAQ Group admin list only): force ORDER BY
551 + * the `order` term meta. Unlike set_tax_order this is not gated on the
552 + * builder's saved order key — the classic screen is always manual ordering.
553 + */
554 + public function admin_order_terms_clauses( $pieces, $taxonomies, $args ) {
555 + if ( ! in_array( $this->category, (array) $taxonomies, true ) ) {
556 + return $pieces;
557 + }
558 +
559 + global $wpdb;
560 + $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'";
561 +
562 + if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
563 + $pieces['join'] .= $join_statement;
564 + }
565 +
566 + $pieces['orderby'] = 'ORDER BY CAST( bd_faq_order.meta_value AS UNSIGNED )';
567 +
568 + return $pieces;
569 + }
570 +
571 + /**
572 + * Whether the current user may manage FAQ groups and FAQs.
573 + *
574 + * Two capabilities, either of which is enough. Nothing that could reach
575 + * these routes before can be turned away now — the gate only widens.
576 + *
577 + * - `edit_others_posts` is the original gate, kept verbatim for backward
578 + * compatibility: every site that granted FAQ Builder access by granting a
579 + * core editor-level role keeps working exactly as it did.
580 + * - `edit_others_docs` is the BetterDocs-side answer, added in 4.9.0. The
581 + * `betterdocs_faq` post type is registered with
582 + * `capability_type => [ 'doc', 'docs' ]` and `map_meta_cap => true`, so
583 + * WordPress already governs individual FAQ posts with the docs capability
584 + * family — the routes were the one place that asked for a *core* post
585 + * capability instead. A documentation-only role (BetterDocs' own `editor`
586 + * bucket, or anything Pro's `article_roles` grants) could edit every FAQ
587 + * post through `wp/v2/betterdocs_faq` and still get a 403 from the FAQ
588 + * Builder's own API.
589 + *
590 + * It is also what lines the REST routes up with the MCP FAQ abilities, which
591 + * are gated on `edit_others_docs`: an agent that may create an FAQ through
592 + * `bd-create-faq` reaches these same routes underneath.
593 + *
594 + * @since 4.9.0
595 + *
596 + * @return bool
597 + */
598 + private function can_manage_faqs() {
599 + return current_user_can( 'edit_others_posts' ) || current_user_can( 'edit_others_docs' );
600 + }
601 +
238 602 public function register_api_endpoint() {
239 603 register_rest_route(
240 604 $this->namespace,
241 605 '/faq/sample_data',
@@ -242,9 +606,9 @@
242 606 [
243 607 'methods' => [ 'POST' ],
244 608 'callback' => [ $this, 'create_faq_sample' ],
245 609 'permission_callback' => function () {
246 - return current_user_can( 'edit_others_posts' );
610 + return $this->can_manage_faqs();
247 611 }
248 612 ]
249 613 );
250 614
@@ -254,9 +618,9 @@
254 618 [
255 619 'methods' => [ 'GET' ],
256 620 'callback' => [ $this, 'fetch_faq_posts' ],
257 621 'permission_callback' => function () {
258 - return current_user_can( 'edit_others_posts' );
622 + return $this->can_manage_faqs();
259 623 }
260 624 ]
261 625 );
262 626
@@ -266,9 +630,9 @@
266 630 [
267 631 'methods' => [ 'POST' ],
268 632 'callback' => [ $this, 'create_faq_category' ],
269 633 'permission_callback' => function () {
270 - return current_user_can( 'edit_others_posts' );
634 + return $this->can_manage_faqs();
271 635 }
272 636 ]
273 637 );
274 638
@@ -278,9 +642,9 @@
278 642 [
279 643 'methods' => [ 'POST' ],
280 644 'callback' => [ $this, 'update_faq_category' ],
281 645 'permission_callback' => function () {
282 - return current_user_can( 'edit_others_posts' );
646 + return $this->can_manage_faqs();
283 647 }
284 648 ]
285 649 );
286 650
@@ -290,9 +654,9 @@
290 654 [
291 655 'methods' => [ 'POST' ],
292 656 'callback' => [ $this, 'delete_faq_category' ],
293 657 'permission_callback' => function () {
294 - return current_user_can( 'edit_others_posts' );
658 + return $this->can_manage_faqs();
295 659 }
296 660 ]
297 661 );
298 662
@@ -302,9 +666,9 @@
302 666 [
303 667 'methods' => [ 'POST' ],
304 668 'callback' => [ $this, 'create_betterdocs_faq' ],
305 669 'permission_callback' => function () {
306 - return current_user_can( 'edit_others_posts' );
670 + return $this->can_manage_faqs();
307 671 }
308 672 ]
309 673 );
310 674
@@ -314,9 +678,9 @@
314 678 [
315 679 'methods' => [ 'POST' ],
316 680 'callback' => [ $this, 'update_betterdocs_faq' ],
317 681 'permission_callback' => function () {
318 - return current_user_can( 'edit_others_posts' );
682 + return $this->can_manage_faqs();
319 683 }
320 684 ]
321 685 );
322 686
@@ -326,9 +690,9 @@
326 690 [
327 691 'methods' => [ 'POST' ],
328 692 'callback' => [ $this, 'delete_betterdocs_faq' ],
329 693 'permission_callback' => function () {
330 - return current_user_can( 'edit_others_posts' );
694 + return $this->can_manage_faqs();
331 695 }
332 696 ]
333 697 );
334 698
@@ -338,9 +702,9 @@
338 702 [
339 703 'methods' => [ 'POST' ],
340 704 'callback' => [ $this, 'update_category_status' ],
341 705 'permission_callback' => function () {
342 - return current_user_can( 'edit_others_posts' );
706 + return $this->can_manage_faqs();
343 707 }
344 708 ]
345 709 );
346 710
@@ -350,9 +714,9 @@
350 714 [
351 715 'methods' => [ 'POST' ],
352 716 'callback' => [ $this, 'update_faq_category_order' ],
353 717 'permission_callback' => function () {
354 - return current_user_can( 'edit_others_posts' );
718 + return $this->can_manage_faqs();
355 719 }
356 720 ]
357 721 );
358 722
@@ -362,9 +726,9 @@
362 726 [
363 727 'methods' => [ 'POST' ],
364 728 'callback' => [ $this, 'update_faq_order_by_category' ],
365 729 'permission_callback' => function () {
366 - return current_user_can( 'edit_others_posts' );
730 + return $this->can_manage_faqs();
367 731 }
368 732 ]
369 733 );
370 734
@@ -369,14 +733,26 @@
369 733 );
370 734
371 735 register_rest_route(
372 736 $this->namespace,
737 + '/faq/order',
738 + [
739 + 'methods' => [ 'POST' ],
740 + 'callback' => [ $this, 'update_faq_order_preference' ],
741 + 'permission_callback' => function () {
742 + return $this->can_manage_faqs();
743 + }
744 + ]
745 + );
746 +
747 + register_rest_route(
748 + $this->namespace,
373 749 '/faq/uncategorised',
374 750 [
375 751 'methods' => [ 'GET' ],
376 752 'callback' => [ $this, 'get_uncategorised_faq' ],
377 753 'permission_callback' => function () {
378 - return current_user_can( 'edit_others_posts' );
754 + return $this->can_manage_faqs();
379 755 }
380 756 ]
381 757 );
382 758
@@ -386,9 +762,9 @@
386 762 [
387 763 'methods' => [ 'GET' ],
388 764 'callback' => [ $this, 'category_search' ],
389 765 'permission_callback' => function () {
390 - return current_user_can( 'edit_others_posts' );
766 + return $this->can_manage_faqs();
391 767 },
392 768 'args' => [
393 769 'title' => [
394 770 'type' => 'string',
@@ -393,8 +769,13 @@
393 769 'title' => [
394 770 'type' => 'string',
395 771 'required' => true,
396 772 'sanitize_callback' => 'sanitize_text_field'
773 + ],
774 + 'taxonomy' => [
775 + 'type' => 'string',
776 + 'required' => false,
777 + 'sanitize_callback' => 'sanitize_text_field'
397 778 ]
398 779 ]
399 780 ]
400 781 );
@@ -415,27 +796,63 @@
415 796 }
416 797 return true;
417 798 }
418 799
800 + /**
801 + * Resolve the taxonomy from a request, whitelisted to the General and
802 + * Product FAQ category taxonomies. Defaults to the General taxonomy so
803 + * existing callers (which never send a taxonomy) keep their behavior.
804 + *
805 + * @param \WP_REST_Request $params
806 + * @return string
807 + */
808 + private function resolve_taxonomy( $params ) {
809 + $taxonomy = $params->get_param( 'taxonomy' );
810 + return in_array( $taxonomy, [ $this->category, $this->product_category ], true )
811 + ? $taxonomy
812 + : $this->category;
813 + }
814 +
419 815 public function create_faq_category( $params ) {
420 - $title = $params->get_param( 'title' );
816 + // The WP term `name` column is varchar(200); cap the length so an
817 + // over-long title can't trigger a DB insert error (the React form caps
818 + // this too, this is the server-side guard).
819 + $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
421 820 $description = $params->get_param( 'description' );
422 821 $description = ( $description !== 'undefined' ) ? $description : '';
423 822 $slug = $params->get_param( 'slug' );
424 823 $group_icon_url = $params->get_param('group_icon_url');
425 - return $this->insert_betterdocs_faq_category( $title, $description, $group_icon_url, $slug );
824 + $taxonomy = $this->resolve_taxonomy( $params );
825 + $result = $this->insert_betterdocs_faq_category(
826 + $title,
827 + $description,
828 + $group_icon_url,
829 + $slug,
830 + $taxonomy,
831 + (array) $params->get_param( 'product_cats' ),
832 + (array) $params->get_param( 'products' ),
833 + $this->all_products_param( $params )
834 + );
835 +
836 + if ( is_wp_error( $result ) ) {
837 + return $result;
838 + }
839 +
840 + return rest_ensure_response( array( 'success' => true, 'term_id' => (int) $result ) );
426 841 }
427 842
428 843 public function update_faq_category( $params ) {
429 844 $term_id = $params->get_param( 'term_id' );
430 - $title = $params->get_param( 'title' );
845 + // Cap at the varchar(200) term-name limit (server-side guard).
846 + $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
431 847 $description = $params->get_param( 'description' );
432 848 $group_icon_url = $params->get_param('group_icon_url');
433 849 $description = ( $description !== 'undefined' ) ? $description : '';
434 850 $slug = $params->get_param( 'slug' );
851 + $taxonomy = $this->resolve_taxonomy( $params );
435 852 $update = wp_update_term(
436 853 $term_id,
437 - 'betterdocs_faq_category',
854 + $taxonomy,
438 855 [
439 856 'name' => $title,
440 857 'slug' => $slug,
441 858 'description' => $description
@@ -448,8 +865,15 @@
448 865 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
449 866 if( $term_id != 0 ) {
450 867 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
451 868 update_term_meta($term_id, 'faq_group_icon', $group_icon_url, $previous_icon_url);
869 + $this->save_group_assignments(
870 + $term_id,
871 + $taxonomy,
872 + (array) $params->get_param( 'product_cats' ),
873 + (array) $params->get_param( 'products' ),
874 + $this->all_products_param( $params )
875 + );
452 876 }
453 877 return true;
454 878 }
455 879 }
@@ -456,14 +880,15 @@
456 880
457 881 public function delete_faq_category( $params ) {
458 882 $term_id = $params->get_param( 'term_id' );
459 883 $delete_all_docs = $params->get_param('with_all_post');
884 + $taxonomy = $this->resolve_taxonomy( $params );
460 885
461 886 if ( $delete_all_docs ) {
462 - Helper::delete_specific_faq_posts_by_faq_category( $term_id );
887 + Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
463 888 }
464 889
465 - $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
890 + $delete = wp_delete_term( $term_id, $taxonomy );
466 891
467 892 if ( is_wp_error( $delete ) ) {
468 893 return $delete;
469 894 } else {
@@ -470,12 +895,12 @@
470 895 return true;
471 896 }
472 897 }
473 898
474 - public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '' ) {
899 + public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
475 900 $insert_term = wp_insert_term(
476 901 $title,
477 - 'betterdocs_faq_category',
902 + $taxonomy,
478 903 [
479 904 'slug' => $slug,
480 905 'description' => $description
481 906 ]
@@ -486,13 +911,89 @@
486 911 } else {
487 912 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
488 913 if( $term_id != 0 ) {
489 914 update_term_meta($term_id, 'faq_group_icon', $icon_url);
915 + $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
490 916 }
491 - return true;
917 + // Return the new term id so the admin can jump to its pagination
918 + // page and highlight it (mirrors the new-FAQ reveal flow).
919 + return (int) $term_id;
492 920 }
493 921 }
494 922
923 + /**
924 + * Persist a Product FAQ group's targeting. Three mutually-exclusive scopes:
925 + * - all products → GROUP_ALL_PRODUCTS_META, shown on every product page;
926 + * - specific cats/products → GROUP_PRODUCT_CATS_META / GROUP_PRODUCTS_META;
927 + * - none → the group is hidden on the storefront.
928 + *
929 + * No-op for any taxonomy other than the Product FAQ groups taxonomy.
930 + *
931 + * @param int $term_id
932 + * @param string $taxonomy
933 + * @param array $product_cats Incoming product_cat term IDs.
934 + * @param array $products Incoming product post IDs.
935 + * @param bool $all_products When true, show on ALL products and clear the cat/product
936 + * targets (the scopes are mutually exclusive).
937 + */
938 + private function save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products = false ) {
939 + if ( $taxonomy !== $this->product_category ) {
940 + return;
941 + }
942 +
943 + // "Show on all products" wins and clears the specific targets, so a store-wide
944 + // group (e.g. the generated "Shipping, Returns & Payments") can be switched to
945 + // specific categories/products and back, from the same modal.
946 + if ( $all_products ) {
947 + update_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META, true );
948 + delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
949 + delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
950 + return;
951 + }
952 +
953 + $cat_ids = [];
954 + foreach ( (array) $product_cats as $cid ) {
955 + $cid = (int) $cid;
956 + if ( $cid > 0 && term_exists( $cid, 'product_cat' ) ) {
957 + $cat_ids[] = $cid;
958 + }
959 + }
960 + $cat_ids = array_values( array_unique( $cat_ids ) );
961 +
962 + $product_ids = [];
963 + foreach ( (array) $products as $pid ) {
964 + $pid = (int) $pid;
965 + if ( $pid > 0 && 'product' === get_post_type( $pid ) ) {
966 + $product_ids[] = $pid;
967 + }
968 + }
969 + $product_ids = array_values( array_unique( $product_ids ) );
970 +
971 + if ( empty( $cat_ids ) ) {
972 + delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
973 + } else {
974 + update_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META, $cat_ids );
975 + }
976 +
977 + if ( empty( $product_ids ) ) {
978 + delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
979 + } else {
980 + update_term_meta( $term_id, self::GROUP_PRODUCTS_META, $product_ids );
981 + }
982 +
983 + // Explicitly not "all products" → drop the match-all flag.
984 + delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
985 + }
986 +
987 + /**
988 + * Read + normalize the `all_products` REST param (accepts '1'/'0'/true/false).
989 + *
990 + * @return bool
991 + */
992 + private function all_products_param( $params ) {
993 + return filter_var( $params->get_param( 'all_products' ), FILTER_VALIDATE_BOOLEAN );
994 + }
995 +
495 996 public function update_faq_category_order( $params ) {
496 997 $faq_category_order = $params->get_param( 'faq_category_order' );
497 998 $faq_category_order = json_decode( $faq_category_order, true );
498 999
@@ -503,9 +1004,9 @@
503 1004 }
504 1005 return true;
505 1006 }
506 1007
507 - public function insert_betterdocs_faq( $post_title, $post_content, $term_id ) {
1008 + public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
508 1009 $post = wp_insert_post(
509 1010 [
510 1011 'post_type' => 'betterdocs_faq',
511 1012 'post_title' => wp_strip_all_tags( $post_title ),
@@ -513,31 +1014,42 @@
513 1014 'post_status' => 'publish'
514 1015 ]
515 1016 );
516 1017
1018 + // Stamp the scope from the tab this FAQ was created in. Doing it here (and not
1019 + // only in sync_faq_scope_meta) is what makes an FAQ created WITHOUT a group land
1020 + // in the right tab's "Uncategorized" — with no terms assigned, set_object_terms
1021 + // never fires.
1022 + if ( $post && ! is_wp_error( $post ) ) {
1023 + update_post_meta(
1024 + $post,
1025 + self::SCOPE_META,
1026 + $taxonomy === $this->product_category ? 'product' : 'general'
1027 + );
1028 + }
1029 +
517 1030 if ( $term_id ) {
518 - $set_terms = wp_set_object_terms( $post, $term_id, 'betterdocs_faq_category' );
1031 + $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
519 1032 if ( is_wp_error( $set_terms ) ) {
520 1033 return $set_terms;
521 - } else {
522 - return $this->update_faq_order_on_insert( $term_id, $post );
523 1034 }
524 - } else {
525 - return $post;
1035 + $this->update_faq_order_on_insert( $term_id, $post );
526 1036 }
1037 +
1038 + // Always return the new post ID so the admin can reveal/highlight the
1039 + // created FAQ (the grouped path previously returned the term-meta result).
1040 + return $post;
527 1041 }
528 1042
529 1043 public function update_faq_order_on_insert( $term_id, $post ) {
530 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
531 - if ( ! empty( $term_meta ) ) {
532 - $term_meta_arr = explode( ',', $term_meta[0] );
533 - if ( ! in_array( $post, $term_meta_arr ) ) {
534 - array_unshift( $term_meta_arr, $post );
535 - $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
536 - return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
537 - }
538 - } else {
539 - return update_term_meta( $term_id, '_betterdocs_faq_order', $post );
1044 + // QA-008: read the single stored value and strip blanks so the very first
1045 + // insert (no existing meta) doesn't explode(',', null) into a corrupt [''].
1046 + $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1047 + $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1048 + if ( ! in_array( (string) $post, $term_meta_arr, true ) ) {
1049 + array_unshift( $term_meta_arr, $post );
1050 + $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1051 + return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
540 1052 }
541 1053 }
542 1054
543 1055 /**
@@ -549,21 +1061,48 @@
549 1061 $posts = $params->get_param( 'posts' );
550 1062 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
551 1063 }
552 1064
1065 + /**
1066 + * Persist the FAQ Builder header order dropdown so the front end can mirror
1067 + * it. Stored as a single global preference in the `betterdocs_faq_order`
1068 + * option and read back by Query::get_faq_order_key().
1069 + */
1070 + public function update_faq_order_preference( $params ) {
1071 + $order = $params->get_param( 'order' );
1072 + $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
1073 +
1074 + if ( ! in_array( $order, $allowed, true ) ) {
1075 + $order = 'default';
1076 + }
1077 +
1078 + update_option( 'betterdocs_faq_order', $order );
1079 +
1080 + return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1081 + }
1082 +
553 1083 public function create_betterdocs_faq( $params ) {
554 1084 $post_title = $params->get_param( 'post_title' );
555 1085 $post_content = $params->get_param( 'post_content' );
556 1086 $term_id = $params->get_param( 'term_id' );
557 - return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id );
1087 + $taxonomy = $this->resolve_taxonomy( $params );
1088 + return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
558 1089 }
559 1090
560 1091 public function update_betterdocs_faq( $params ) {
561 - $post_id = $params->get_param( 'post_id' );
1092 + $post_id = (int) $params->get_param( 'post_id' );
1093 +
1094 + // QA-001: never let an arbitrary post ID be retyped/overwritten through
1095 + // this endpoint. Only operate on posts that are already BetterDocs FAQs.
1096 + if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1097 + return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1098 + }
1099 +
562 1100 $post_title = $params->get_param( 'post_title' );
563 1101 $post_content = $params->get_param( 'post_content' );
564 1102 $status = $params->get_param( 'status' );
565 - $term_id = $params->get_param( 'term_id' );
1103 + $term_id = (int) $params->get_param( 'term_id' );
1104 + $taxonomy = $this->resolve_taxonomy( $params );
566 1105 if ( $status ) {
567 1106 $data = [
568 1107 'post_type' => 'betterdocs_faq',
569 1108 'ID' => $post_id,
@@ -572,20 +1111,30 @@
572 1111 } else {
573 1112 $data = [
574 1113 'post_type' => 'betterdocs_faq',
575 1114 'ID' => $post_id,
576 - 'post_title' => $post_title,
577 - 'post_content' => $post_content
1115 + // QA-009: sanitize server-side — the editor posts raw TinyMCE
1116 + // HTML. Title is plain text; content keeps post-safe HTML only
1117 + // (scripts / event handlers stripped) even for unfiltered_html users.
1118 + 'post_title' => sanitize_text_field( $post_title ),
1119 + 'post_content' => wp_kses_post( $post_content ),
578 1120 ];
579 1121
580 - if ( $term_id ) {
1122 + // QA-007: only assign a term that actually exists in the resolved FAQ
1123 + // taxonomy — an invalid term_id would silently orphan the FAQ.
1124 + if ( $term_id && term_exists( $term_id, $taxonomy ) ) {
581 1125 $data['tax_input'] = [
582 - 'betterdocs_faq_category' => $term_id
1126 + $taxonomy => [ $term_id ],
583 1127 ];
584 1128
585 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
586 - $term_meta_arr = explode( ',', $term_meta[0] );
587 - if ( ! in_array( $post_id, $term_meta_arr ) ) {
1129 + // QA-008: build the order list defensively. On the first update the
1130 + // term has no _betterdocs_faq_order meta; the old code did
1131 + // explode(',', null) → [''] which corrupted the order with a blank
1132 + // entry (plus a PHP 8.1 "null to explode" deprecation). Read the
1133 + // single value and drop blanks instead.
1134 + $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1135 + $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1136 + if ( ! in_array( (string) $post_id, $term_meta_arr, true ) ) {
588 1137 array_unshift( $term_meta_arr, $post_id );
589 1138 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
590 1139 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
591 1140 }
@@ -595,9 +1144,15 @@
595 1144 return wp_update_post( $data );
596 1145 }
597 1146
598 1147 public function delete_betterdocs_faq( $params ) {
599 - $post_id = $params->get_param( 'post_id' );
1148 + $post_id = (int) $params->get_param( 'post_id' );
1149 +
1150 + // QA-001: refuse to delete anything that isn't a BetterDocs FAQ.
1151 + if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1152 + return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1153 + }
1154 +
600 1155 return wp_delete_post( $post_id );
601 1156 }
602 1157
603 1158 public function faq_post_loop( $args ) {
@@ -668,48 +1223,172 @@
668 1223
669 1224 return $faq;
670 1225 }
671 1226
672 - public function get_uncategorised_faq() {
673 - $available_terms = array_map(
674 - function ( $term ) {
675 - return $term->term_id;
676 - },
677 - get_terms(
1227 + /**
1228 + * Record an FAQ's scope whenever its group is (re)assigned, so the scope survives
1229 + * the group being deleted. Fires for every path that assigns terms.
1230 + *
1231 + * @param int $object_id Post ID.
1232 + * @param array $terms Terms assigned (unused).
1233 + * @param array $tt_ids Term-taxonomy IDs.
1234 + * @param string $taxonomy Taxonomy the terms belong to.
1235 + */
1236 + public function sync_faq_scope_meta( $object_id, $terms, $tt_ids, $taxonomy ) {
1237 + if ( get_post_type( $object_id ) !== $this->post_type ) {
1238 + return;
1239 + }
1240 +
1241 + if ( $taxonomy === $this->product_category && ! empty( $tt_ids ) ) {
1242 + update_post_meta( $object_id, self::SCOPE_META, 'product' );
1243 + return;
1244 + }
1245 +
1246 + // Assigning a General group makes it a General FAQ — but only claim it if it
1247 + // isn't already a Product FAQ that merely also carries a General term.
1248 + if ( $taxonomy === $this->category && ! empty( $tt_ids ) ) {
1249 + if ( get_post_meta( $object_id, self::SCOPE_META, true ) !== 'product' ) {
1250 + update_post_meta( $object_id, self::SCOPE_META, 'general' );
1251 + }
1252 + }
1253 + }
1254 +
1255 + /**
1256 + * One-time backfill: stamp the scope onto FAQs created before SCOPE_META existed.
1257 + * An FAQ holding a Product group is 'product'; everything else is 'general'.
1258 + * Ungrouped legacy FAQs have no way to be identified as product ones, so they stay
1259 + * in the General tab, which is exactly where they are today.
1260 + */
1261 + public function maybe_backfill_faq_scope() {
1262 + if ( get_option( self::SCOPE_BACKFILL_OPTION ) ) {
1263 + return;
1264 + }
1265 +
1266 + $product_faqs = get_posts(
1267 + [
1268 + 'post_type' => $this->post_type,
1269 + 'post_status' => 'any',
1270 + 'posts_per_page' => -1,
1271 + 'fields' => 'ids',
1272 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one-time backfill.
1273 + 'tax_query' => [
1274 + [
1275 + 'taxonomy' => $this->product_category,
1276 + 'operator' => 'EXISTS',
1277 + ],
1278 + ],
1279 + ]
1280 + );
1281 +
1282 + foreach ( $product_faqs as $faq_id ) {
1283 + update_post_meta( $faq_id, self::SCOPE_META, 'product' );
1284 + }
1285 +
1286 + update_option( self::SCOPE_BACKFILL_OPTION, 1 );
1287 + }
1288 +
1289 + /**
1290 + * FAQs with no group, for the tab that asked. Each tab owns its own bucket:
1291 + * the Product tab lists ungrouped FAQs scoped to 'product', the General tab lists
1292 + * everything else that is ungrouped (so an ungrouped Product FAQ no longer leaks
1293 + * into General).
1294 + */
1295 + public function get_uncategorised_faq( $request = null ) {
1296 + $taxonomy = $request instanceof WP_REST_Request ? $this->resolve_taxonomy( $request ) : $this->category;
1297 + $is_product = ( $taxonomy === $this->product_category );
1298 +
1299 + $term_ids = function ( $tax ) {
1300 + $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => false ] );
1301 + return is_wp_error( $terms ) ? [] : array_map(
1302 + function ( $term ) {
1303 + return $term->term_id;
1304 + },
1305 + $terms
1306 + );
1307 + };
1308 +
1309 + // "Ungrouped" always means: no group in THIS tab's taxonomy.
1310 + $tax_query = [
1311 + 'relation' => 'AND',
1312 + [
1313 + 'taxonomy' => $taxonomy,
1314 + 'field' => 'term_id',
1315 + 'terms' => $term_ids( $taxonomy ),
1316 + 'operator' => 'NOT IN'
1317 + ]
1318 + ];
1319 +
1320 + $meta_query = [];
1321 +
1322 + if ( $is_product ) {
1323 + // Product tab: only FAQs that belong to the Product side.
1324 + $meta_query[] = [
1325 + 'key' => self::SCOPE_META,
1326 + 'value' => 'product',
1327 + ];
1328 + } else {
1329 + // General tab: exclude anything scoped to Product…
1330 + $meta_query[] = [
1331 + 'relation' => 'OR',
678 1332 [
679 - 'taxonomy' => 'betterdocs_faq_category',
680 - 'hide_empty' => false
681 - ]
682 - )
683 - );
1333 + 'key' => self::SCOPE_META,
1334 + 'compare' => 'NOT EXISTS',
1335 + ],
1336 + [
1337 + 'key' => self::SCOPE_META,
1338 + 'value' => 'product',
1339 + 'compare' => '!=',
1340 + ],
1341 + ];
684 1342
685 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- intentional NOT-IN scan to find FAQs without any category assignment.
686 - return get_posts(
1343 + // …and, as before, anything still holding a Product group (belt and braces
1344 + // for FAQs whose scope meta never got written).
1345 + $product_terms = $term_ids( $this->product_category );
1346 + if ( ! empty( $product_terms ) ) {
1347 + $tax_query[] = [
1348 + 'taxonomy' => $this->product_category,
1349 + 'field' => 'term_id',
1350 + 'terms' => $product_terms,
1351 + 'operator' => 'NOT IN'
1352 + ];
1353 + }
1354 + }
1355 +
1356 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query, WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- intentional NOT-IN scan to find FAQs without any group.
1357 + $posts = get_posts(
687 1358 [
688 1359 'post_type' => 'betterdocs_faq',
689 - 'post_status' => current_user_can( 'edit_others_posts' ) ? [ 'publish', 'draft' ] : 'publish',
1360 + 'post_status' => $this->can_manage_faqs() ? [ 'publish', 'draft' ] : 'publish',
690 1361 'posts_per_page' => -1,
691 - 'tax_query' => [
692 - 'relation' => 'AND',
693 - [
694 - 'taxonomy' => 'betterdocs_faq_category',
695 - 'field' => 'term_id',
696 - 'terms' => $available_terms,
697 - 'operator' => 'NOT IN'
698 - ]
699 - ]
1362 + 'tax_query' => $tax_query,
1363 + 'meta_query' => $meta_query,
700 1364 ]
701 1365 );
1366 +
1367 + // get_posts() returns raw WP_Post objects without a `meta` property. The
1368 + // FAQ Builder reads `faq.meta.faq_open_by_default` to seed the "Keep It
1369 + // Open By default" switcher, so attach it here to mirror the scalar shape
1370 + // the wp/v2 endpoint returns for categorized FAQs ('' when unset, '1'
1371 + // when enabled). Without this the value is undefined and the switcher
1372 + // defaults to ON for every uncategorized FAQ.
1373 + foreach ( $posts as $post ) {
1374 + $post->meta = [
1375 + 'faq_open_by_default' => get_post_meta( $post->ID, 'faq_open_by_default', true ),
1376 + ];
1377 + }
1378 +
1379 + return $posts;
702 1380 }
703 1381
704 1382 public function category_search( $request ) {
705 1383
706 - $title = $request['title'];
1384 + $title = $request['title'];
1385 + $taxonomy = $this->resolve_taxonomy( $request );
707 1386
708 1387 // Perform the taxonomy search
709 1388 $taxonomy_args = [
710 1389 'name__like' => $title,
711 - 'taxonomy' => 'betterdocs_faq_category',
1390 + 'taxonomy' => $taxonomy,
712 1391 'hide_empty' => false
713 1392 ];
714 1393
715 1394 $taxonomies = get_terms( $taxonomy_args );
@@ -734,12 +1413,56 @@
734 1413 }
735 1414 }
736 1415
737 1416 public function faq_category_orderby_meta( $args, $request ) {
738 - if ( $args['taxonomy'] === 'betterdocs_faq_category' ) {
739 - $args['orderby'] = 'meta_value_num';
740 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- ordering by user-assigned 'order' meta is required UX.
741 - $args['meta_key'] = 'order';
1417 + if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 + return $args;
742 1419 }
1420 +
1421 + // Order (and therefore paginate) the whole group list server-side to
1422 + // match the FAQ Builder header dropdown, so page boundaries are cut on
1423 + // the same sort the rows are shown in — otherwise page 1 could end on
1424 + // "W" and page 2 start on "B". The admin passes the order key explicitly
1425 + // (independent of when the preference option finishes saving); fall back
1426 + // to the saved preference, then to the manual drag-drop order.
1427 + $order_key = $request->get_param( 'betterdocs_faq_order' );
1428 + if ( empty( $order_key ) ) {
1429 + $order_key = get_option( 'betterdocs_faq_order', 'default' );
1430 + }
1431 +
1432 + switch ( $order_key ) {
1433 + case 'most_recent':
1434 + $args['orderby'] = 'term_id';
1435 + $args['order'] = 'DESC';
1436 + unset( $args['meta_key'] );
1437 + break;
1438 + case 'least_recent':
1439 + $args['orderby'] = 'term_id';
1440 + $args['order'] = 'ASC';
1441 + unset( $args['meta_key'] );
1442 + break;
1443 + case 'a_to_z':
1444 + $args['orderby'] = 'name';
1445 + $args['order'] = 'ASC';
1446 + unset( $args['meta_key'] );
1447 + break;
1448 + case 'z_to_a':
1449 + $args['orderby'] = 'name';
1450 + $args['order'] = 'DESC';
1451 + unset( $args['meta_key'] );
1452 + break;
1453 + case 'most_questions':
1454 + $args['orderby'] = 'count';
1455 + $args['order'] = 'DESC';
1456 + unset( $args['meta_key'] );
1457 + break;
1458 + case 'default':
1459 + default:
1460 + $args['orderby'] = 'meta_value_num';
1461 + $args['meta_key'] = 'order';
1462 + $args['order'] = 'ASC';
1463 + break;
1464 + }
1465 +
743 1466 return $args;
744 1467 }
745 1468 }