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

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

1,438 lines 47.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Core;
3
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 use WP_Error;
14 use WP_Query;
15 use WP_REST_Request;
16 use WPDeveloper\BetterDocs\Utils\Base;
17 use WPDeveloper\BetterDocs\Utils\Helper;
18
19 class FAQBuilder extends Base {
20 /**
21 * REST API namespace
22 * @var string
23 */
24 private $namespace = 'betterdocs';
25 public $post_type = 'betterdocs_faq';
26 public $category = 'betterdocs_faq_category';
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.
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 *
84 * Initialize the class and start calling our hooks and filters
85 *
86 * @since 1.0.0
87 *
88 */
89 public function __construct() {
90 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
91 add_action( 'init', [ $this, 'register_post' ] );
92 // fires after a new betterdocs_faq_category is created
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 );
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' ] );
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' ] );
114 }
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
183 public function output() {
184 betterdocs()->views->get( 'admin/faq-builder' );
185 }
186
187 /**
188 *
189 * Register post type and taxonomies
190 *
191 * @since 1.0.0
192 *
193 */
194 public function register_post() {
195 /**
196 * Register category taxonomy
197 */
198 $category_labels = [
199 'name' => __( 'FAQ Categories', 'betterdocs' ),
200 'singular_name' => __( 'FAQ Category', 'betterdocs' ),
201 'all_items' => __( 'FAQ Categories', 'betterdocs' ),
202 'parent_item' => __( 'Parent FAQ Category', 'betterdocs' ),
203 'parent_item_colon' => __( 'Parent FAQ Category:', 'betterdocs' ),
204 'edit_item' => __( 'Edit Category', 'betterdocs' ),
205 'update_item' => __( 'Update Category', 'betterdocs' ),
206 'add_new_item' => __( 'Add New FAQ Category', 'betterdocs' ),
207 'new_item_name' => __( 'New FAQ Category Name', 'betterdocs' ),
208 'menu_name' => __( 'Categories', 'betterdocs' )
209 ];
210
211 $category_args = [
212 'hierarchical' => true,
213 'public' => false,
214 'labels' => $category_labels,
215 'show_ui' => true,
216 'show_admin_column' => true,
217 'query_var' => true,
218 'show_in_rest' => true,
219 'has_archive' => false,
220 'rewrite' => false,
221 'capabilities' => [
222 'manage_terms' => 'manage_doc_terms',
223 'edit_terms' => 'edit_doc_terms',
224 'delete_terms' => 'delete_doc_terms',
225 'assign_terms' => 'edit_docs'
226 ]
227 ];
228
229 register_taxonomy( $this->category, [ $this->post_type ], $category_args );
230 register_term_meta( $this->category, 'order', [ 'show_in_rest' => true ] );
231 register_term_meta( $this->category, 'status', [ 'show_in_rest' => true ] );
232 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
233 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
234
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 /**
290 * Register post type
291 */
292 $labels = [
293 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
294 'singular_name' => __( 'BetterDocs FAQ', 'betterdocs' ),
295 'menu_name' => __( 'FAQ', 'betterdocs' ),
296 'name_admin_bar' => __( 'FAQ', 'betterdocs' ),
297 'add_new' => __( 'Add New', 'betterdocs' ),
298 'add_new_item' => __( 'Add New FAQ', 'betterdocs' ),
299 'new_item' => __( 'New FAQ', 'betterdocs' ),
300 'edit_item' => __( 'Edit FAQ', 'betterdocs' ),
301 'view_item' => __( 'View FAQ', 'betterdocs' ),
302 'all_items' => __( 'All FAQ', 'betterdocs' ),
303 'search_items' => __( 'Search FAQ', 'betterdocs' ),
304 'parent_item_colorn' => null,
305 'not_found' => __( 'No FAQ found', 'betterdocs' ),
306 'not_found_in_trash' => __( 'No FAQ found in trash', 'betterdocs' )
307 ];
308
309 $args = [
310 'labels' => $labels,
311 'description' => __( 'Add new faq from here', 'betterdocs' ),
312 'public' => false,
313 'public_queryable' => true,
314 'exclude_from_search' => false,
315 'show_ui' => true,
316 'show_in_menu' => false,
317 'query_var' => true,
318 'capability_type' => [ 'doc', 'docs' ],
319 'hierarchical' => true,
320 'map_meta_cap' => true,
321 'has_archive' => false,
322 'rewrite' => false,
323 'show_in_rest' => true,
324 'menu_icon' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg' ),
325 'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'author', 'revisions', 'custom-fields', 'comments' ]
326 ];
327
328 register_post_type( $this->post_type, $args );
329 register_post_meta( $this->post_type, 'faq_open_by_default', [ 'show_in_rest' => true, 'single' => true ] );
330 }
331
332 /**
333 * Default the taxonomy's terms' order if it's not set.
334 *
335 * @param string $tax_slug The taxonomy's slug.
336 */
337 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 );
341 update_term_meta( $term_id, 'order', $order++ );
342 update_term_meta( $term_id, 'status', 1 );
343 }
344
345 /**
346 * Default the taxonomy's terms' order if it's not set.
347 *
348 * @param string $tax_slug The taxonomy's slug.
349 */
350 public function default_term_order( $tax_slug ) {
351 $terms = get_terms(
352 [
353 'taxonomy' => $tax_slug,
354 'hide_empty' => false,
355 ]
356 );
357 $order = $this->get_max_taxonomy_order( $tax_slug );
358
359 foreach ( $terms as $term ) {
360 if ( ! get_term_meta( $term->term_id, 'order', true ) ) {
361 update_term_meta( $term->term_id, 'order', $order );
362 ++$order;
363 }
364 }
365 }
366
367 /**
368 * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
369 */
370 private function get_max_taxonomy_order( $tax_slug ) {
371 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.
373 $max_term_order = $wpdb->get_col(
374 $wpdb->prepare(
375 "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
376 FROM $wpdb->terms t
377 JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
378 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
379 $tax_slug
380 )
381 );
382
383 $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
384
385 return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
386 }
387
388 /**
389 * Re-Order the taxonomies based on the order value.
390 *
391 * @param array $pieces Array of SQL query clauses.
392 * @param array $taxonomies Array of taxonomy names.
393 * @param array $args Array of term query args.
394 */
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
403 foreach ( $taxonomies as $taxonomy ) {
404 global $wpdb;
405
406 if ( $taxonomy === 'betterdocs_faq_category' ) {
407 $join_statement = " LEFT JOIN $wpdb->termmeta AS term_meta ON t.term_id = term_meta.term_id AND term_meta.meta_key = 'order'";
408
409 if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
410 $pieces['join'] .= $join_statement;
411 }
412
413 $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
414 }
415 }
416
417 return $pieces;
418 }
419
420 /**
421 * Order the taxonomies on the front end.
422 */
423 public function front_end_order_terms() {
424 if ( ! is_admin() ) {
425 add_filter( 'terms_clauses', [ $this, 'set_tax_order' ], 10, 3 );
426 }
427 }
428
429 /**
430 * Check if a substring exists inside a string.
431 *
432 * @param string $string The main string (haystack) we're searching in.
433 * @param string $substring The substring we're searching for.
434 *
435 * @return bool True if substring exists, else false.
436 */
437 protected function does_substring_exist( $string, $substring ) {
438 return strstr( $string, $substring ) !== false;
439 }
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 ( ! current_user_can( 'edit_others_posts' ) ) {
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 public function register_api_endpoint() {
572 register_rest_route(
573 $this->namespace,
574 '/faq/sample_data',
575 [
576 'methods' => [ 'POST' ],
577 'callback' => [ $this, 'create_faq_sample' ],
578 'permission_callback' => function () {
579 return current_user_can( 'edit_others_posts' );
580 }
581 ]
582 );
583
584 register_rest_route(
585 $this->namespace,
586 '/faq/posts/(?P<type>\S+)',
587 [
588 'methods' => [ 'GET' ],
589 'callback' => [ $this, 'fetch_faq_posts' ],
590 'permission_callback' => function () {
591 return current_user_can( 'edit_others_posts' );
592 }
593 ]
594 );
595
596 register_rest_route(
597 $this->namespace,
598 '/faq/create_category',
599 [
600 'methods' => [ 'POST' ],
601 'callback' => [ $this, 'create_faq_category' ],
602 'permission_callback' => function () {
603 return current_user_can( 'edit_others_posts' );
604 }
605 ]
606 );
607
608 register_rest_route(
609 $this->namespace,
610 '/faq/update_category',
611 [
612 'methods' => [ 'POST' ],
613 'callback' => [ $this, 'update_faq_category' ],
614 'permission_callback' => function () {
615 return current_user_can( 'edit_others_posts' );
616 }
617 ]
618 );
619
620 register_rest_route(
621 $this->namespace,
622 '/faq/delete_category',
623 [
624 'methods' => [ 'POST' ],
625 'callback' => [ $this, 'delete_faq_category' ],
626 'permission_callback' => function () {
627 return current_user_can( 'edit_others_posts' );
628 }
629 ]
630 );
631
632 register_rest_route(
633 $this->namespace,
634 '/faq/create_post',
635 [
636 'methods' => [ 'POST' ],
637 'callback' => [ $this, 'create_betterdocs_faq' ],
638 'permission_callback' => function () {
639 return current_user_can( 'edit_others_posts' );
640 }
641 ]
642 );
643
644 register_rest_route(
645 $this->namespace,
646 '/faq/update_post',
647 [
648 'methods' => [ 'POST' ],
649 'callback' => [ $this, 'update_betterdocs_faq' ],
650 'permission_callback' => function () {
651 return current_user_can( 'edit_others_posts' );
652 }
653 ]
654 );
655
656 register_rest_route(
657 $this->namespace,
658 '/faq/delete_post',
659 [
660 'methods' => [ 'POST' ],
661 'callback' => [ $this, 'delete_betterdocs_faq' ],
662 'permission_callback' => function () {
663 return current_user_can( 'edit_others_posts' );
664 }
665 ]
666 );
667
668 register_rest_route(
669 $this->namespace,
670 '/faq/category_status',
671 [
672 'methods' => [ 'POST' ],
673 'callback' => [ $this, 'update_category_status' ],
674 'permission_callback' => function () {
675 return current_user_can( 'edit_others_posts' );
676 }
677 ]
678 );
679
680 register_rest_route(
681 $this->namespace,
682 '/faq/category_order',
683 [
684 'methods' => [ 'POST' ],
685 'callback' => [ $this, 'update_faq_category_order' ],
686 'permission_callback' => function () {
687 return current_user_can( 'edit_others_posts' );
688 }
689 ]
690 );
691
692 register_rest_route(
693 $this->namespace,
694 '/faq/update_order_by_category',
695 [
696 'methods' => [ 'POST' ],
697 'callback' => [ $this, 'update_faq_order_by_category' ],
698 'permission_callback' => function () {
699 return current_user_can( 'edit_others_posts' );
700 }
701 ]
702 );
703
704 register_rest_route(
705 $this->namespace,
706 '/faq/order',
707 [
708 'methods' => [ 'POST' ],
709 'callback' => [ $this, 'update_faq_order_preference' ],
710 'permission_callback' => function () {
711 return current_user_can( 'edit_others_posts' );
712 }
713 ]
714 );
715
716 register_rest_route(
717 $this->namespace,
718 '/faq/uncategorised',
719 [
720 'methods' => [ 'GET' ],
721 'callback' => [ $this, 'get_uncategorised_faq' ],
722 'permission_callback' => function () {
723 return current_user_can( 'edit_others_posts' );
724 }
725 ]
726 );
727
728 register_rest_route(
729 $this->namespace,
730 '/faq/category_search',
731 [
732 'methods' => [ 'GET' ],
733 'callback' => [ $this, 'category_search' ],
734 'permission_callback' => function () {
735 return current_user_can( 'edit_others_posts' );
736 },
737 'args' => [
738 'title' => [
739 'type' => 'string',
740 'required' => true,
741 'sanitize_callback' => 'sanitize_text_field'
742 ],
743 'taxonomy' => [
744 'type' => 'string',
745 'required' => false,
746 'sanitize_callback' => 'sanitize_text_field'
747 ]
748 ]
749 ]
750 );
751 }
752
753 public function create_faq_sample( $params ) {
754 $sample_data = json_decode( $params->get_param( 'sample_data' ), true );
755 foreach ( $sample_data as $key => $value ) {
756 $insert_term = wp_insert_term(
757 $key,
758 'betterdocs_faq_category'
759 );
760 if ( $insert_term ) {
761 foreach ( $value['posts'] as $key => $value ) {
762 $this->insert_betterdocs_faq( $value['post_title'], $value['post_content'], $insert_term['term_id'] );
763 }
764 }
765 }
766 return true;
767 }
768
769 /**
770 * Resolve the taxonomy from a request, whitelisted to the General and
771 * Product FAQ category taxonomies. Defaults to the General taxonomy so
772 * existing callers (which never send a taxonomy) keep their behavior.
773 *
774 * @param \WP_REST_Request $params
775 * @return string
776 */
777 private function resolve_taxonomy( $params ) {
778 $taxonomy = $params->get_param( 'taxonomy' );
779 return in_array( $taxonomy, [ $this->category, $this->product_category ], true )
780 ? $taxonomy
781 : $this->category;
782 }
783
784 public function create_faq_category( $params ) {
785 // The WP term `name` column is varchar(200); cap the length so an
786 // over-long title can't trigger a DB insert error (the React form caps
787 // this too, this is the server-side guard).
788 $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
789 $description = $params->get_param( 'description' );
790 $description = ( $description !== 'undefined' ) ? $description : '';
791 $slug = $params->get_param( 'slug' );
792 $group_icon_url = $params->get_param('group_icon_url');
793 $taxonomy = $this->resolve_taxonomy( $params );
794 $result = $this->insert_betterdocs_faq_category(
795 $title,
796 $description,
797 $group_icon_url,
798 $slug,
799 $taxonomy,
800 (array) $params->get_param( 'product_cats' ),
801 (array) $params->get_param( 'products' ),
802 $this->all_products_param( $params )
803 );
804
805 if ( is_wp_error( $result ) ) {
806 return $result;
807 }
808
809 return rest_ensure_response( array( 'success' => true, 'term_id' => (int) $result ) );
810 }
811
812 public function update_faq_category( $params ) {
813 $term_id = $params->get_param( 'term_id' );
814 // Cap at the varchar(200) term-name limit (server-side guard).
815 $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
816 $description = $params->get_param( 'description' );
817 $group_icon_url = $params->get_param('group_icon_url');
818 $description = ( $description !== 'undefined' ) ? $description : '';
819 $slug = $params->get_param( 'slug' );
820 $taxonomy = $this->resolve_taxonomy( $params );
821 $update = wp_update_term(
822 $term_id,
823 $taxonomy,
824 [
825 'name' => $title,
826 'slug' => $slug,
827 'description' => $description
828 ]
829 );
830
831 if ( is_wp_error( $update ) ) {
832 return $update;
833 } else {
834 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
835 if( $term_id != 0 ) {
836 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
837 update_term_meta($term_id, 'faq_group_icon', $group_icon_url, $previous_icon_url);
838 $this->save_group_assignments(
839 $term_id,
840 $taxonomy,
841 (array) $params->get_param( 'product_cats' ),
842 (array) $params->get_param( 'products' ),
843 $this->all_products_param( $params )
844 );
845 }
846 return true;
847 }
848 }
849
850 public function delete_faq_category( $params ) {
851 $term_id = $params->get_param( 'term_id' );
852 $delete_all_docs = $params->get_param('with_all_post');
853 $taxonomy = $this->resolve_taxonomy( $params );
854
855 if ( $delete_all_docs ) {
856 Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
857 }
858
859 $delete = wp_delete_term( $term_id, $taxonomy );
860
861 if ( is_wp_error( $delete ) ) {
862 return $delete;
863 } else {
864 return true;
865 }
866 }
867
868 public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
869 $insert_term = wp_insert_term(
870 $title,
871 $taxonomy,
872 [
873 'slug' => $slug,
874 'description' => $description
875 ]
876 );
877
878 if ( is_wp_error( $insert_term ) ) {
879 return $insert_term;
880 } else {
881 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
882 if( $term_id != 0 ) {
883 update_term_meta($term_id, 'faq_group_icon', $icon_url);
884 $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
885 }
886 // Return the new term id so the admin can jump to its pagination
887 // page and highlight it (mirrors the new-FAQ reveal flow).
888 return (int) $term_id;
889 }
890 }
891
892 /**
893 * Persist a Product FAQ group's targeting. Three mutually-exclusive scopes:
894 * - all products → GROUP_ALL_PRODUCTS_META, shown on every product page;
895 * - specific cats/products → GROUP_PRODUCT_CATS_META / GROUP_PRODUCTS_META;
896 * - none → the group is hidden on the storefront.
897 *
898 * No-op for any taxonomy other than the Product FAQ groups taxonomy.
899 *
900 * @param int $term_id
901 * @param string $taxonomy
902 * @param array $product_cats Incoming product_cat term IDs.
903 * @param array $products Incoming product post IDs.
904 * @param bool $all_products When true, show on ALL products and clear the cat/product
905 * targets (the scopes are mutually exclusive).
906 */
907 private function save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products = false ) {
908 if ( $taxonomy !== $this->product_category ) {
909 return;
910 }
911
912 // "Show on all products" wins and clears the specific targets, so a store-wide
913 // group (e.g. the generated "Shipping, Returns & Payments") can be switched to
914 // specific categories/products and back, from the same modal.
915 if ( $all_products ) {
916 update_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META, true );
917 delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
918 delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
919 return;
920 }
921
922 $cat_ids = [];
923 foreach ( (array) $product_cats as $cid ) {
924 $cid = (int) $cid;
925 if ( $cid > 0 && term_exists( $cid, 'product_cat' ) ) {
926 $cat_ids[] = $cid;
927 }
928 }
929 $cat_ids = array_values( array_unique( $cat_ids ) );
930
931 $product_ids = [];
932 foreach ( (array) $products as $pid ) {
933 $pid = (int) $pid;
934 if ( $pid > 0 && 'product' === get_post_type( $pid ) ) {
935 $product_ids[] = $pid;
936 }
937 }
938 $product_ids = array_values( array_unique( $product_ids ) );
939
940 if ( empty( $cat_ids ) ) {
941 delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
942 } else {
943 update_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META, $cat_ids );
944 }
945
946 if ( empty( $product_ids ) ) {
947 delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
948 } else {
949 update_term_meta( $term_id, self::GROUP_PRODUCTS_META, $product_ids );
950 }
951
952 // Explicitly not "all products" → drop the match-all flag.
953 delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
954 }
955
956 /**
957 * Read + normalize the `all_products` REST param (accepts '1'/'0'/true/false).
958 *
959 * @return bool
960 */
961 private function all_products_param( $params ) {
962 return filter_var( $params->get_param( 'all_products' ), FILTER_VALIDATE_BOOLEAN );
963 }
964
965 public function update_faq_category_order( $params ) {
966 $faq_category_order = $params->get_param( 'faq_category_order' );
967 $faq_category_order = json_decode( $faq_category_order, true );
968
969 foreach ( $faq_category_order as $order_data ) {
970 if ( (int) $order_data['current_position'] != (int) $order_data['updated_position'] ) {
971 update_term_meta( $order_data['id'], 'order', ( (int) $order_data['updated_position'] ) );
972 }
973 }
974 return true;
975 }
976
977 public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
978 $post = wp_insert_post(
979 [
980 'post_type' => 'betterdocs_faq',
981 'post_title' => wp_strip_all_tags( $post_title ),
982 'post_content' => $post_content,
983 'post_status' => 'publish'
984 ]
985 );
986
987 // Stamp the scope from the tab this FAQ was created in. Doing it here (and not
988 // only in sync_faq_scope_meta) is what makes an FAQ created WITHOUT a group land
989 // in the right tab's "Uncategorized" — with no terms assigned, set_object_terms
990 // never fires.
991 if ( $post && ! is_wp_error( $post ) ) {
992 update_post_meta(
993 $post,
994 self::SCOPE_META,
995 $taxonomy === $this->product_category ? 'product' : 'general'
996 );
997 }
998
999 if ( $term_id ) {
1000 $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
1001 if ( is_wp_error( $set_terms ) ) {
1002 return $set_terms;
1003 }
1004 $this->update_faq_order_on_insert( $term_id, $post );
1005 }
1006
1007 // Always return the new post ID so the admin can reveal/highlight the
1008 // created FAQ (the grouped path previously returned the term-meta result).
1009 return $post;
1010 }
1011
1012 public function update_faq_order_on_insert( $term_id, $post ) {
1013 // QA-008: read the single stored value and strip blanks so the very first
1014 // insert (no existing meta) doesn't explode(',', null) into a corrupt [''].
1015 $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1016 $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1017 if ( ! in_array( (string) $post, $term_meta_arr, true ) ) {
1018 array_unshift( $term_meta_arr, $post );
1019 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1020 return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1021 }
1022 }
1023
1024 /**
1025 * Update _betterdocs_faq_order meta when new post created
1026 */
1027
1028 public function update_faq_order_by_category( $params ) {
1029 $term_id = $params->get_param( 'term_id' );
1030 $posts = $params->get_param( 'posts' );
1031 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
1032 }
1033
1034 /**
1035 * Persist the FAQ Builder header order dropdown so the front end can mirror
1036 * it. Stored as a single global preference in the `betterdocs_faq_order`
1037 * option and read back by Query::get_faq_order_key().
1038 */
1039 public function update_faq_order_preference( $params ) {
1040 $order = $params->get_param( 'order' );
1041 $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
1042
1043 if ( ! in_array( $order, $allowed, true ) ) {
1044 $order = 'default';
1045 }
1046
1047 update_option( 'betterdocs_faq_order', $order );
1048
1049 return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1050 }
1051
1052 public function create_betterdocs_faq( $params ) {
1053 $post_title = $params->get_param( 'post_title' );
1054 $post_content = $params->get_param( 'post_content' );
1055 $term_id = $params->get_param( 'term_id' );
1056 $taxonomy = $this->resolve_taxonomy( $params );
1057 return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
1058 }
1059
1060 public function update_betterdocs_faq( $params ) {
1061 $post_id = (int) $params->get_param( 'post_id' );
1062
1063 // QA-001: never let an arbitrary post ID be retyped/overwritten through
1064 // this endpoint. Only operate on posts that are already BetterDocs FAQs.
1065 if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1066 return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1067 }
1068
1069 $post_title = $params->get_param( 'post_title' );
1070 $post_content = $params->get_param( 'post_content' );
1071 $status = $params->get_param( 'status' );
1072 $term_id = (int) $params->get_param( 'term_id' );
1073 $taxonomy = $this->resolve_taxonomy( $params );
1074 if ( $status ) {
1075 $data = [
1076 'post_type' => 'betterdocs_faq',
1077 'ID' => $post_id,
1078 'status' => $status
1079 ];
1080 } else {
1081 $data = [
1082 'post_type' => 'betterdocs_faq',
1083 'ID' => $post_id,
1084 // QA-009: sanitize server-side — the editor posts raw TinyMCE
1085 // HTML. Title is plain text; content keeps post-safe HTML only
1086 // (scripts / event handlers stripped) even for unfiltered_html users.
1087 'post_title' => sanitize_text_field( $post_title ),
1088 'post_content' => wp_kses_post( $post_content ),
1089 ];
1090
1091 // QA-007: only assign a term that actually exists in the resolved FAQ
1092 // taxonomy — an invalid term_id would silently orphan the FAQ.
1093 if ( $term_id && term_exists( $term_id, $taxonomy ) ) {
1094 $data['tax_input'] = [
1095 $taxonomy => [ $term_id ],
1096 ];
1097
1098 // QA-008: build the order list defensively. On the first update the
1099 // term has no _betterdocs_faq_order meta; the old code did
1100 // explode(',', null) → [''] which corrupted the order with a blank
1101 // entry (plus a PHP 8.1 "null to explode" deprecation). Read the
1102 // single value and drop blanks instead.
1103 $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1104 $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1105 if ( ! in_array( (string) $post_id, $term_meta_arr, true ) ) {
1106 array_unshift( $term_meta_arr, $post_id );
1107 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1108 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1109 }
1110 }
1111 }
1112
1113 return wp_update_post( $data );
1114 }
1115
1116 public function delete_betterdocs_faq( $params ) {
1117 $post_id = (int) $params->get_param( 'post_id' );
1118
1119 // QA-001: refuse to delete anything that isn't a BetterDocs FAQ.
1120 if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1121 return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1122 }
1123
1124 return wp_delete_post( $post_id );
1125 }
1126
1127 public function faq_post_loop( $args ) {
1128 $posts = [];
1129 $query = new WP_Query( $args );
1130 if ( $query->have_posts() ) :
1131 while ( $query->have_posts() ) :
1132 $query->the_post();
1133 $posts[ get_the_ID() ]['title'] = get_the_title();
1134 $posts[ get_the_ID() ]['content'] = get_the_content();
1135 endwhile;
1136 endif;
1137
1138 return $posts;
1139 }
1140
1141 public function update_category_status( $params ) {
1142 $term_id = $params->get_param( 'term_id' );
1143 $status = $params->get_param( 'status' );
1144 return update_term_meta( $term_id, 'status', $status );
1145 }
1146
1147 public function fetch_faq_posts( $params ) {
1148 $faq = [];
1149 $type = $params->get_param( 'type' );
1150
1151 if ( $type == 'category' ) {
1152 $taxonomy_objects = get_terms(
1153 [
1154 'taxonomy' => 'betterdocs_faq_category',
1155 'hide_empty' => false
1156 ]
1157 );
1158
1159 if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
1160 foreach ( $taxonomy_objects as $term ) :
1161 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- core FAQ-by-category query; tax filtering is required functionality.
1162 $args = [
1163 'post_type' => 'betterdocs_faq',
1164 'post_status' => 'publish',
1165 'post_per_page' => -1,
1166 'tax_query' => [
1167 [
1168 'taxonomy' => 'betterdocs_faq_category',
1169 'field' => 'term_id',
1170 'terms' => $term->term_id
1171 ]
1172 ]
1173 ];
1174
1175 $posts = $this->faq_post_loop( $args );
1176
1177 $faq[ $term->slug ] = [
1178 (array) $term,
1179 'posts' => $posts
1180 ];
1181 endforeach;
1182 endif;
1183 } else {
1184 $args = [
1185 'post_type' => 'betterdocs_faq',
1186 'post_status' => 'publish',
1187 'post_per_page' => -1
1188 ];
1189 $posts = $this->faq_post_loop( $args );
1190 $faq['posts'] = $posts;
1191 }
1192
1193 return $faq;
1194 }
1195
1196 /**
1197 * Record an FAQ's scope whenever its group is (re)assigned, so the scope survives
1198 * the group being deleted. Fires for every path that assigns terms.
1199 *
1200 * @param int $object_id Post ID.
1201 * @param array $terms Terms assigned (unused).
1202 * @param array $tt_ids Term-taxonomy IDs.
1203 * @param string $taxonomy Taxonomy the terms belong to.
1204 */
1205 public function sync_faq_scope_meta( $object_id, $terms, $tt_ids, $taxonomy ) {
1206 if ( get_post_type( $object_id ) !== $this->post_type ) {
1207 return;
1208 }
1209
1210 if ( $taxonomy === $this->product_category && ! empty( $tt_ids ) ) {
1211 update_post_meta( $object_id, self::SCOPE_META, 'product' );
1212 return;
1213 }
1214
1215 // Assigning a General group makes it a General FAQ — but only claim it if it
1216 // isn't already a Product FAQ that merely also carries a General term.
1217 if ( $taxonomy === $this->category && ! empty( $tt_ids ) ) {
1218 if ( get_post_meta( $object_id, self::SCOPE_META, true ) !== 'product' ) {
1219 update_post_meta( $object_id, self::SCOPE_META, 'general' );
1220 }
1221 }
1222 }
1223
1224 /**
1225 * One-time backfill: stamp the scope onto FAQs created before SCOPE_META existed.
1226 * An FAQ holding a Product group is 'product'; everything else is 'general'.
1227 * Ungrouped legacy FAQs have no way to be identified as product ones, so they stay
1228 * in the General tab, which is exactly where they are today.
1229 */
1230 public function maybe_backfill_faq_scope() {
1231 if ( get_option( self::SCOPE_BACKFILL_OPTION ) ) {
1232 return;
1233 }
1234
1235 $product_faqs = get_posts(
1236 [
1237 'post_type' => $this->post_type,
1238 'post_status' => 'any',
1239 'posts_per_page' => -1,
1240 'fields' => 'ids',
1241 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one-time backfill.
1242 'tax_query' => [
1243 [
1244 'taxonomy' => $this->product_category,
1245 'operator' => 'EXISTS',
1246 ],
1247 ],
1248 ]
1249 );
1250
1251 foreach ( $product_faqs as $faq_id ) {
1252 update_post_meta( $faq_id, self::SCOPE_META, 'product' );
1253 }
1254
1255 update_option( self::SCOPE_BACKFILL_OPTION, 1 );
1256 }
1257
1258 /**
1259 * FAQs with no group, for the tab that asked. Each tab owns its own bucket:
1260 * the Product tab lists ungrouped FAQs scoped to 'product', the General tab lists
1261 * everything else that is ungrouped (so an ungrouped Product FAQ no longer leaks
1262 * into General).
1263 */
1264 public function get_uncategorised_faq( $request = null ) {
1265 $taxonomy = $request instanceof WP_REST_Request ? $this->resolve_taxonomy( $request ) : $this->category;
1266 $is_product = ( $taxonomy === $this->product_category );
1267
1268 $term_ids = function ( $tax ) {
1269 $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => false ] );
1270 return is_wp_error( $terms ) ? [] : array_map(
1271 function ( $term ) {
1272 return $term->term_id;
1273 },
1274 $terms
1275 );
1276 };
1277
1278 // "Ungrouped" always means: no group in THIS tab's taxonomy.
1279 $tax_query = [
1280 'relation' => 'AND',
1281 [
1282 'taxonomy' => $taxonomy,
1283 'field' => 'term_id',
1284 'terms' => $term_ids( $taxonomy ),
1285 'operator' => 'NOT IN'
1286 ]
1287 ];
1288
1289 $meta_query = [];
1290
1291 if ( $is_product ) {
1292 // Product tab: only FAQs that belong to the Product side.
1293 $meta_query[] = [
1294 'key' => self::SCOPE_META,
1295 'value' => 'product',
1296 ];
1297 } else {
1298 // General tab: exclude anything scoped to Product…
1299 $meta_query[] = [
1300 'relation' => 'OR',
1301 [
1302 'key' => self::SCOPE_META,
1303 'compare' => 'NOT EXISTS',
1304 ],
1305 [
1306 'key' => self::SCOPE_META,
1307 'value' => 'product',
1308 'compare' => '!=',
1309 ],
1310 ];
1311
1312 // …and, as before, anything still holding a Product group (belt and braces
1313 // for FAQs whose scope meta never got written).
1314 $product_terms = $term_ids( $this->product_category );
1315 if ( ! empty( $product_terms ) ) {
1316 $tax_query[] = [
1317 'taxonomy' => $this->product_category,
1318 'field' => 'term_id',
1319 'terms' => $product_terms,
1320 'operator' => 'NOT IN'
1321 ];
1322 }
1323 }
1324
1325 // 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.
1326 $posts = get_posts(
1327 [
1328 'post_type' => 'betterdocs_faq',
1329 'post_status' => current_user_can( 'edit_others_posts' ) ? [ 'publish', 'draft' ] : 'publish',
1330 'posts_per_page' => -1,
1331 'tax_query' => $tax_query,
1332 'meta_query' => $meta_query,
1333 ]
1334 );
1335
1336 // get_posts() returns raw WP_Post objects without a `meta` property. The
1337 // FAQ Builder reads `faq.meta.faq_open_by_default` to seed the "Keep It
1338 // Open By default" switcher, so attach it here to mirror the scalar shape
1339 // the wp/v2 endpoint returns for categorized FAQs ('' when unset, '1'
1340 // when enabled). Without this the value is undefined and the switcher
1341 // defaults to ON for every uncategorized FAQ.
1342 foreach ( $posts as $post ) {
1343 $post->meta = [
1344 'faq_open_by_default' => get_post_meta( $post->ID, 'faq_open_by_default', true ),
1345 ];
1346 }
1347
1348 return $posts;
1349 }
1350
1351 public function category_search( $request ) {
1352
1353 $title = $request['title'];
1354 $taxonomy = $this->resolve_taxonomy( $request );
1355
1356 // Perform the taxonomy search
1357 $taxonomy_args = [
1358 'name__like' => $title,
1359 'taxonomy' => $taxonomy,
1360 'hide_empty' => false
1361 ];
1362
1363 $taxonomies = get_terms( $taxonomy_args );
1364
1365 if ( ! empty( $taxonomies ) ) {
1366 $result = [];
1367 foreach ( $taxonomies as $taxonomy ) {
1368 $result[] = [
1369 'id' => $taxonomy->term_id,
1370 'count' => $taxonomy->count,
1371 'description' => $taxonomy->description,
1372 'name' => $taxonomy->name,
1373 'slug' => $taxonomy->slug
1374 // Add more fields as needed
1375 ];
1376 }
1377 // Return the taxonomy data
1378 return $result;
1379 } else {
1380 // Taxonomy not found
1381 return new WP_Error( 'taxonomy_not_found', 'Taxonomy not found.', [ 'status' => 404 ] );
1382 }
1383 }
1384
1385 public function faq_category_orderby_meta( $args, $request ) {
1386 if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1387 return $args;
1388 }
1389
1390 // Order (and therefore paginate) the whole group list server-side to
1391 // match the FAQ Builder header dropdown, so page boundaries are cut on
1392 // the same sort the rows are shown in — otherwise page 1 could end on
1393 // "W" and page 2 start on "B". The admin passes the order key explicitly
1394 // (independent of when the preference option finishes saving); fall back
1395 // to the saved preference, then to the manual drag-drop order.
1396 $order_key = $request->get_param( 'betterdocs_faq_order' );
1397 if ( empty( $order_key ) ) {
1398 $order_key = get_option( 'betterdocs_faq_order', 'default' );
1399 }
1400
1401 switch ( $order_key ) {
1402 case 'most_recent':
1403 $args['orderby'] = 'term_id';
1404 $args['order'] = 'DESC';
1405 unset( $args['meta_key'] );
1406 break;
1407 case 'least_recent':
1408 $args['orderby'] = 'term_id';
1409 $args['order'] = 'ASC';
1410 unset( $args['meta_key'] );
1411 break;
1412 case 'a_to_z':
1413 $args['orderby'] = 'name';
1414 $args['order'] = 'ASC';
1415 unset( $args['meta_key'] );
1416 break;
1417 case 'z_to_a':
1418 $args['orderby'] = 'name';
1419 $args['order'] = 'DESC';
1420 unset( $args['meta_key'] );
1421 break;
1422 case 'most_questions':
1423 $args['orderby'] = 'count';
1424 $args['order'] = 'DESC';
1425 unset( $args['meta_key'] );
1426 break;
1427 case 'default':
1428 default:
1429 $args['orderby'] = 'meta_value_num';
1430 $args['meta_key'] = 'order';
1431 $args['order'] = 'ASC';
1432 break;
1433 }
1434
1435 return $args;
1436 }
1437 }
1438