PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.8.9
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.8.9
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 +82 -823 4.9.13.8.9 View file →
@@ -1,19 +1,10 @@
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 -use WP_REST_Request;
16 7 use WPDeveloper\BetterDocs\Utils\Base;
17 8 use WPDeveloper\BetterDocs\Utils\Helper;
18 9
19 10 class FAQBuilder extends Base {
@@ -25,63 +16,9 @@
25 16 public $post_type = 'betterdocs_faq';
26 17 public $category = 'betterdocs_faq_category';
27 18
28 19 /**
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.
32 20 *
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 - *
84 21 * Initialize the class and start calling our hooks and filters
85 22 *
86 23 * @since 1.0.0
87 24 *
@@ -90,97 +27,12 @@
90 27 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
91 28 add_action( 'init', [ $this, 'register_post' ] );
92 29 // fires after a new betterdocs_faq_category is created
93 30 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 );
95 31 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' ] );
105 32 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' ] );
114 33 }
115 34
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 -
183 35 public function output() {
184 36 betterdocs()->views->get( 'admin/faq-builder' );
185 37 }
186 38
@@ -232,62 +84,8 @@
232 84 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
233 85 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
234 86
235 87 /**
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 - /**
290 88 * Register post type
291 89 */
292 90 $labels = [
293 91 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
@@ -334,11 +132,9 @@
334 132 *
335 133 * @param string $tax_slug The taxonomy's slug.
336 134 */
337 135 public function action_created_betterdocs_faq_category( $term_id ) {
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 );
136 + $order = $this->get_max_taxonomy_order( 'betterdocs_faq_category' );
341 137 update_term_meta( $term_id, 'order', $order++ );
342 138 update_term_meta( $term_id, 'status', 1 );
343 139 }
344 140
@@ -368,14 +164,14 @@
368 164 * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
369 165 */
370 166 private function get_max_taxonomy_order( $tax_slug ) {
371 167 global $wpdb;
372 - // 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
373 169 $max_term_order = $wpdb->get_col(
374 170 $wpdb->prepare(
375 171 "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
376 172 FROM $wpdb->terms t
377 - 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'
378 174 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
379 175 $tax_slug
380 176 )
381 177 );
@@ -392,15 +188,8 @@
392 188 * @param array $taxonomies Array of taxonomy names.
393 189 * @param array $args Array of term query args.
394 190 */
395 191 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 -
403 192 foreach ( $taxonomies as $taxonomy ) {
404 193 global $wpdb;
405 194
406 195 if ( $taxonomy === 'betterdocs_faq_category' ) {
@@ -437,169 +226,8 @@
437 226 protected function does_substring_exist( $string, $substring ) {
438 227 return strstr( $string, $substring ) !== false;
439 228 }
440 229
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 -
602 230 public function register_api_endpoint() {
603 231 register_rest_route(
604 232 $this->namespace,
605 233 '/faq/sample_data',
@@ -606,9 +234,9 @@
606 234 [
607 235 'methods' => [ 'POST' ],
608 236 'callback' => [ $this, 'create_faq_sample' ],
609 237 'permission_callback' => function () {
610 - return $this->can_manage_faqs();
238 + return current_user_can( 'edit_others_posts' );
611 239 }
612 240 ]
613 241 );
614 242
@@ -617,11 +245,9 @@
617 245 '/faq/posts/(?P<type>\S+)',
618 246 [
619 247 'methods' => [ 'GET' ],
620 248 'callback' => [ $this, 'fetch_faq_posts' ],
621 - 'permission_callback' => function () {
622 - return $this->can_manage_faqs();
623 - }
249 + 'permission_callback' => '__return_true'
624 250 ]
625 251 );
626 252
627 253 register_rest_route(
@@ -630,9 +256,9 @@
630 256 [
631 257 'methods' => [ 'POST' ],
632 258 'callback' => [ $this, 'create_faq_category' ],
633 259 'permission_callback' => function () {
634 - return $this->can_manage_faqs();
260 + return current_user_can( 'edit_others_posts' );
635 261 }
636 262 ]
637 263 );
638 264
@@ -642,9 +268,9 @@
642 268 [
643 269 'methods' => [ 'POST' ],
644 270 'callback' => [ $this, 'update_faq_category' ],
645 271 'permission_callback' => function () {
646 - return $this->can_manage_faqs();
272 + return current_user_can( 'edit_others_posts' );
647 273 }
648 274 ]
649 275 );
650 276
@@ -654,9 +280,9 @@
654 280 [
655 281 'methods' => [ 'POST' ],
656 282 'callback' => [ $this, 'delete_faq_category' ],
657 283 'permission_callback' => function () {
658 - return $this->can_manage_faqs();
284 + return current_user_can( 'edit_others_posts' );
659 285 }
660 286 ]
661 287 );
662 288
@@ -666,9 +292,9 @@
666 292 [
667 293 'methods' => [ 'POST' ],
668 294 'callback' => [ $this, 'create_betterdocs_faq' ],
669 295 'permission_callback' => function () {
670 - return $this->can_manage_faqs();
296 + return current_user_can( 'edit_others_posts' );
671 297 }
672 298 ]
673 299 );
674 300
@@ -678,9 +304,9 @@
678 304 [
679 305 'methods' => [ 'POST' ],
680 306 'callback' => [ $this, 'update_betterdocs_faq' ],
681 307 'permission_callback' => function () {
682 - return $this->can_manage_faqs();
308 + return current_user_can( 'edit_others_posts' );
683 309 }
684 310 ]
685 311 );
686 312
@@ -690,9 +316,9 @@
690 316 [
691 317 'methods' => [ 'POST' ],
692 318 'callback' => [ $this, 'delete_betterdocs_faq' ],
693 319 'permission_callback' => function () {
694 - return $this->can_manage_faqs();
320 + return current_user_can( 'edit_others_posts' );
695 321 }
696 322 ]
697 323 );
698 324
@@ -702,9 +328,9 @@
702 328 [
703 329 'methods' => [ 'POST' ],
704 330 'callback' => [ $this, 'update_category_status' ],
705 331 'permission_callback' => function () {
706 - return $this->can_manage_faqs();
332 + return current_user_can( 'edit_others_posts' );
707 333 }
708 334 ]
709 335 );
710 336
@@ -714,9 +340,9 @@
714 340 [
715 341 'methods' => [ 'POST' ],
716 342 'callback' => [ $this, 'update_faq_category_order' ],
717 343 'permission_callback' => function () {
718 - return $this->can_manage_faqs();
344 + return current_user_can( 'edit_others_posts' );
719 345 }
720 346 ]
721 347 );
722 348
@@ -726,9 +352,9 @@
726 352 [
727 353 'methods' => [ 'POST' ],
728 354 'callback' => [ $this, 'update_faq_order_by_category' ],
729 355 'permission_callback' => function () {
730 - return $this->can_manage_faqs();
356 + return current_user_can( 'edit_others_posts' );
731 357 }
732 358 ]
733 359 );
734 360
@@ -733,27 +359,13 @@
733 359 );
734 360
735 361 register_rest_route(
736 362 $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,
749 363 '/faq/uncategorised',
750 364 [
751 365 'methods' => [ 'GET' ],
752 366 'callback' => [ $this, 'get_uncategorised_faq' ],
753 - 'permission_callback' => function () {
754 - return $this->can_manage_faqs();
755 - }
367 + 'permission_callback' => '__return_true'
756 368 ]
757 369 );
758 370
759 371 register_rest_route(
@@ -761,21 +373,13 @@
761 373 '/faq/category_search',
762 374 [
763 375 'methods' => [ 'GET' ],
764 376 'callback' => [ $this, 'category_search' ],
765 - 'permission_callback' => function () {
766 - return $this->can_manage_faqs();
767 - },
377 + 'permission_callback' => '__return_true',
768 378 'args' => [
769 379 'title' => [
770 - 'type' => 'string',
771 - 'required' => true,
772 - 'sanitize_callback' => 'sanitize_text_field'
773 - ],
774 - 'taxonomy' => [
775 - 'type' => 'string',
776 - 'required' => false,
777 - 'sanitize_callback' => 'sanitize_text_field'
380 + 'type' => 'string',
381 + 'required' => true
778 382 ]
779 383 ]
780 384 ]
781 385 );
@@ -796,63 +400,27 @@
796 400 }
797 401 return true;
798 402 }
799 403
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 -
815 404 public function create_faq_category( $params ) {
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 );
405 + $title = $params->get_param( 'title' );
820 406 $description = $params->get_param( 'description' );
821 407 $description = ( $description !== 'undefined' ) ? $description : '';
822 408 $slug = $params->get_param( 'slug' );
823 409 $group_icon_url = $params->get_param('group_icon_url');
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 ) );
410 + return $this->insert_betterdocs_faq_category( $title, $description, $group_icon_url, $slug );
841 411 }
842 412
843 413 public function update_faq_category( $params ) {
844 414 $term_id = $params->get_param( 'term_id' );
845 - // Cap at the varchar(200) term-name limit (server-side guard).
846 - $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
415 + $title = $params->get_param( 'title' );
847 416 $description = $params->get_param( 'description' );
848 417 $group_icon_url = $params->get_param('group_icon_url');
849 418 $description = ( $description !== 'undefined' ) ? $description : '';
850 419 $slug = $params->get_param( 'slug' );
851 - $taxonomy = $this->resolve_taxonomy( $params );
852 420 $update = wp_update_term(
853 421 $term_id,
854 - $taxonomy,
422 + 'betterdocs_faq_category',
855 423 [
856 424 'name' => $title,
857 425 'slug' => $slug,
858 426 'description' => $description
@@ -865,15 +433,8 @@
865 433 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
866 434 if( $term_id != 0 ) {
867 435 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
868 436 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 - );
876 437 }
877 438 return true;
878 439 }
879 440 }
@@ -880,15 +441,14 @@
880 441
881 442 public function delete_faq_category( $params ) {
882 443 $term_id = $params->get_param( 'term_id' );
883 444 $delete_all_docs = $params->get_param('with_all_post');
884 - $taxonomy = $this->resolve_taxonomy( $params );
885 445
886 446 if ( $delete_all_docs ) {
887 - Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
447 + Helper::delete_specific_faq_posts_by_faq_category( $term_id );
888 448 }
889 449
890 - $delete = wp_delete_term( $term_id, $taxonomy );
450 + $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
891 451
892 452 if ( is_wp_error( $delete ) ) {
893 453 return $delete;
894 454 } else {
@@ -895,12 +455,12 @@
895 455 return true;
896 456 }
897 457 }
898 458
899 - public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
459 + public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '' ) {
900 460 $insert_term = wp_insert_term(
901 461 $title,
902 - $taxonomy,
462 + 'betterdocs_faq_category',
903 463 [
904 464 'slug' => $slug,
905 465 'description' => $description
906 466 ]
@@ -911,89 +471,13 @@
911 471 } else {
912 472 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
913 473 if( $term_id != 0 ) {
914 474 update_term_meta($term_id, 'faq_group_icon', $icon_url);
915 - $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
916 475 }
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;
476 + return true;
920 477 }
921 478 }
922 479
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 -
996 480 public function update_faq_category_order( $params ) {
997 481 $faq_category_order = $params->get_param( 'faq_category_order' );
998 482 $faq_category_order = json_decode( $faq_category_order, true );
999 483
@@ -1004,9 +488,9 @@
1004 488 }
1005 489 return true;
1006 490 }
1007 491
1008 - 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 ) {
1009 493 $post = wp_insert_post(
1010 494 [
1011 495 'post_type' => 'betterdocs_faq',
1012 496 'post_title' => wp_strip_all_tags( $post_title ),
@@ -1014,42 +498,31 @@
1014 498 'post_status' => 'publish'
1015 499 ]
1016 500 );
1017 501
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 -
1030 502 if ( $term_id ) {
1031 - $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
503 + $set_terms = wp_set_object_terms( $post, $term_id, 'betterdocs_faq_category' );
1032 504 if ( is_wp_error( $set_terms ) ) {
1033 505 return $set_terms;
506 + } else {
507 + return $this->update_faq_order_on_insert( $term_id, $post );
1034 508 }
1035 - $this->update_faq_order_on_insert( $term_id, $post );
509 + } else {
510 + return $post;
1036 511 }
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;
1041 512 }
1042 513
1043 514 public function update_faq_order_on_insert( $term_id, $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 ) );
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 );
1052 525 }
1053 526 }
1054 527
1055 528 /**
@@ -1061,48 +534,21 @@
1061 534 $posts = $params->get_param( 'posts' );
1062 535 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
1063 536 }
1064 537
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 -
1083 538 public function create_betterdocs_faq( $params ) {
1084 539 $post_title = $params->get_param( 'post_title' );
1085 540 $post_content = $params->get_param( 'post_content' );
1086 541 $term_id = $params->get_param( 'term_id' );
1087 - $taxonomy = $this->resolve_taxonomy( $params );
1088 - return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
542 + return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id );
1089 543 }
1090 544
1091 545 public function update_betterdocs_faq( $params ) {
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 -
546 + $post_id = $params->get_param( 'post_id' );
1100 547 $post_title = $params->get_param( 'post_title' );
1101 548 $post_content = $params->get_param( 'post_content' );
1102 549 $status = $params->get_param( 'status' );
1103 - $term_id = (int) $params->get_param( 'term_id' );
1104 - $taxonomy = $this->resolve_taxonomy( $params );
550 + $term_id = $params->get_param( 'term_id' );
1105 551 if ( $status ) {
1106 552 $data = [
1107 553 'post_type' => 'betterdocs_faq',
1108 554 'ID' => $post_id,
@@ -1111,30 +557,20 @@
1111 557 } else {
1112 558 $data = [
1113 559 'post_type' => 'betterdocs_faq',
1114 560 'ID' => $post_id,
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 ),
561 + 'post_title' => $post_title,
562 + 'post_content' => $post_content
1120 563 ];
1121 564
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 ) ) {
565 + if ( $term_id ) {
1125 566 $data['tax_input'] = [
1126 - $taxonomy => [ $term_id ],
567 + 'betterdocs_faq_category' => $term_id
1127 568 ];
1128 569
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 ) ) {
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 ) ) {
1137 573 array_unshift( $term_meta_arr, $post_id );
1138 574 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1139 575 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1140 576 }
@@ -1144,15 +580,9 @@
1144 580 return wp_update_post( $data );
1145 581 }
1146 582
1147 583 public function delete_betterdocs_faq( $params ) {
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 -
584 + $post_id = $params->get_param( 'post_id' );
1155 585 return wp_delete_post( $post_id );
1156 586 }
1157 587
1158 588 public function faq_post_loop( $args ) {
@@ -1188,9 +618,8 @@
1188 618 );
1189 619
1190 620 if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
1191 621 foreach ( $taxonomy_objects as $term ) :
1192 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- core FAQ-by-category query; tax filtering is required functionality.
1193 622 $args = [
1194 623 'post_type' => 'betterdocs_faq',
1195 624 'post_status' => 'publish',
1196 625 'post_per_page' => -1,
@@ -1223,172 +652,47 @@
1223 652
1224 653 return $faq;
1225 654 }
1226 655
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 - }
656 + public function get_uncategorised_faq() {
657 + $available_terms = array_map(
658 + function ( $term ) {
659 + return $term->term_id;
660 + },
661 + get_terms(
662 + [
663 + 'taxonomy' => 'betterdocs_faq_category',
664 + 'hide_empty' => false
665 + ]
666 + )
667 + );
1240 668
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(
669 + return get_posts(
1267 670 [
1268 - 'post_type' => $this->post_type,
1269 - 'post_status' => 'any',
671 + 'post_type' => 'betterdocs_faq',
672 + 'post_status' => [ 'publish', 'draft' ],
1270 673 'posts_per_page' => -1,
1271 - 'fields' => 'ids',
1272 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one-time backfill.
1273 674 'tax_query' => [
675 + 'relation' => 'AND',
1274 676 [
1275 - 'taxonomy' => $this->product_category,
1276 - 'operator' => 'EXISTS',
1277 - ],
1278 - ],
677 + 'taxonomy' => 'betterdocs_faq_category',
678 + 'field' => 'term_id',
679 + 'terms' => $available_terms,
680 + 'operator' => 'NOT IN'
681 + ]
682 + ]
1279 683 ]
1280 684 );
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 685 }
1288 686
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',
1332 - [
1333 - 'key' => self::SCOPE_META,
1334 - 'compare' => 'NOT EXISTS',
1335 - ],
1336 - [
1337 - 'key' => self::SCOPE_META,
1338 - 'value' => 'product',
1339 - 'compare' => '!=',
1340 - ],
1341 - ];
1342 -
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(
1358 - [
1359 - 'post_type' => 'betterdocs_faq',
1360 - 'post_status' => $this->can_manage_faqs() ? [ 'publish', 'draft' ] : 'publish',
1361 - 'posts_per_page' => -1,
1362 - 'tax_query' => $tax_query,
1363 - 'meta_query' => $meta_query,
1364 - ]
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;
1380 - }
1381 -
1382 687 public function category_search( $request ) {
1383 688
1384 - $title = $request['title'];
1385 - $taxonomy = $this->resolve_taxonomy( $request );
689 + $title = $request['title'];
1386 690
1387 691 // Perform the taxonomy search
1388 692 $taxonomy_args = [
1389 693 'name__like' => $title,
1390 - 'taxonomy' => $taxonomy,
694 + 'taxonomy' => 'betterdocs_faq_category',
1391 695 'hide_empty' => false
1392 696 ];
1393 697
1394 698 $taxonomies = get_terms( $taxonomy_args );
@@ -1413,56 +717,11 @@
1413 717 }
1414 718 }
1415 719
1416 720 public function faq_category_orderby_meta( $args, $request ) {
1417 - if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 - return $args;
721 + if ( $args['taxonomy'] === 'betterdocs_faq_category' ) {
722 + $args['orderby'] = 'meta_value_num';
723 + $args['meta_key'] = 'order';
1419 724 }
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 -
1466 725 return $args;
1467 726 }
1468 727 }