PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/FAQBuilder.php +1364 -551 3.4.14.9.2 View file →
@@ -1,655 +1,1468 @@
1 1 <?php
2 +namespace WPDeveloper\BetterDocs\Core;
2 3
3 -namespace WPDeveloper\BetterDocs\Core;
4 +if ( ! defined( 'ABSPATH' ) ) {
5 + exit;
6 +}
4 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;
5 14 use WP_Query;
6 -use WP_Error;
15 +use WP_REST_Request;
7 16 use WPDeveloper\BetterDocs\Utils\Base;
17 +use WPDeveloper\BetterDocs\Utils\Helper;
8 18
9 19 class FAQBuilder extends Base {
10 - /**
11 - * REST API namespace
12 - * @var string
13 - */
14 - private $namespace = 'betterdocs';
15 - public $post_type = 'betterdocs_faq';
16 - public $category = 'betterdocs_faq_category';
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';
17 27
18 - /**
19 - *
20 - * Initialize the class and start calling our hooks and filters
21 - *
22 - * @since 1.0.0
23 - *
24 - */
25 - public function __construct() {
26 - // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
27 - add_action( 'init', [$this, 'register_post'] );
28 - // fires after a new betterdocs_faq_category is created
29 - add_action( 'created_betterdocs_faq_category', [$this, 'action_created_betterdocs_faq_category'], 10, 2 );
30 - add_action( 'rest_api_init', [$this, 'register_api_endpoint'] );
31 - add_action('rest_betterdocs_faq_category_query', array($this, 'faq_category_orderby_meta'), 10, 2);
32 - // Enqueue Scripts
33 - add_action( 'admin_enqueue_scripts', [$this, 'enqueue'] );
34 - }
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';
35 36
36 - public function enqueue( $hook ) {
37 - if ( $hook === 'betterdocs_page_betterdocs-faq' ) {
38 - betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/css/faq.css' );
39 - betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/js/faq.js' );
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';
40 49
41 - // removing emoji support
42 - remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
43 - remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
50 + /** Option flag for the one-time backfill of SCOPE_META on pre-existing FAQs. */
51 + const SCOPE_BACKFILL_OPTION = 'betterdocs_faq_scope_backfilled';
44 52
45 - betterdocs()->assets->localize( 'betterdocs-admin-faq', 'betterdocs', [
46 - 'dir_url' => BETTERDOCS_ABSURL,
47 - 'rest_url' => esc_url_raw( rest_url() ),
48 - 'free_version' => betterdocs()->version,
49 - 'nonce' => wp_create_nonce( 'wp_rest' )
50 - ] );
51 - }
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';
53 58
54 - public function output() {
55 - betterdocs()->views->get( 'admin/faq-builder' );
56 - }
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';
57 64
58 - /**
59 - *
60 - * Register post type and taxonomies
61 - *
62 - * @since 1.0.0
63 - *
64 - */
65 - public function register_post() {
66 - /**
67 - * Register category taxonomy
68 - */
69 - $category_labels = [
70 - 'name' => __( 'FAQ Categories', 'betterdocs' ),
71 - 'singular_name' => __( 'FAQ Category', 'betterdocs' ),
72 - 'all_items' => __( 'FAQ Categories', 'betterdocs' ),
73 - 'parent_item' => __( 'Parent FAQ Category', 'betterdocs' ),
74 - 'parent_item_colon' => __( 'Parent FAQ Category:', 'betterdocs' ),
75 - 'edit_item' => __( 'Edit Category', 'betterdocs' ),
76 - 'update_item' => __( 'Update Category', 'betterdocs' ),
77 - 'add_new_item' => __( 'Add New FAQ Category', 'betterdocs' ),
78 - 'new_item_name' => __( 'New FAQ Category Name', 'betterdocs' ),
79 - 'menu_name' => __( 'Categories', 'betterdocs' )
80 - ];
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';
81 72
82 - $category_args = [
83 - 'hierarchical' => true,
84 - 'public' => false,
85 - 'labels' => $category_labels,
86 - 'show_ui' => true,
87 - 'show_admin_column' => true,
88 - 'query_var' => true,
89 - 'show_in_rest' => true,
90 - 'has_archive' => false,
91 - 'rewrite' => false,
92 - 'capabilities' => [
93 - 'manage_terms' => 'manage_doc_terms',
94 - 'edit_terms' => 'edit_doc_terms',
95 - 'delete_terms' => 'delete_doc_terms',
96 - 'assign_terms' => 'edit_docs'
97 - ]
98 - ];
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 = [];
99 81
100 - register_taxonomy( $this->category, [$this->post_type], $category_args );
101 - register_term_meta( $this->category, 'order', ['show_in_rest' => true] );
102 - register_term_meta( $this->category, 'status', ['show_in_rest' => true] );
103 - register_term_meta( $this->category, '_betterdocs_faq_order', ['show_in_rest' => true] );
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' ] );
104 97
105 - /**
106 - * Register post type
107 - */
108 - $labels = [
109 - 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
110 - 'singular_name' => __( 'BetterDocs FAQ', 'betterdocs' ),
111 - 'menu_name' => __( 'FAQ', 'betterdocs' ),
112 - 'name_admin_bar' => __( 'FAQ', 'betterdocs' ),
113 - 'add_new' => __( 'Add New', 'betterdocs' ),
114 - 'add_new_item' => __( 'Add New FAQ', 'betterdocs' ),
115 - 'new_item' => __( 'New FAQ', 'betterdocs' ),
116 - 'edit_item' => __( 'Edit FAQ', 'betterdocs' ),
117 - 'view_item' => __( 'View FAQ', 'betterdocs' ),
118 - 'all_items' => __( 'All FAQ', 'betterdocs' ),
119 - 'search_items' => __( 'Search FAQ', 'betterdocs' ),
120 - 'parent_item_colorn' => null,
121 - 'not_found' => __( 'No FAQ found', 'betterdocs' ),
122 - 'not_found_in_trash' => __( 'No FAQ found in trash', 'betterdocs' )
123 - ];
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 );
124 107
125 - $args = [
126 - 'labels' => $labels,
127 - 'description' => __( 'Add new faq from here', 'betterdocs' ),
128 - 'public' => false,
129 - 'public_queryable' => true,
130 - 'exclude_from_search' => false,
131 - 'show_ui' => true,
132 - 'show_in_menu' => false,
133 - 'query_var' => true,
134 - 'capability_type' => ['doc', 'docs'],
135 - 'hierarchical' => true,
136 - 'map_meta_cap' => true,
137 - 'has_archive' => false,
138 - 'rewrite' => false,
139 - 'show_in_rest' => true,
140 - 'menu_icon' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg' ),
141 - 'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'author', 'revisions', 'custom-fields', 'comments']
142 - ];
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 + }
143 115
144 - register_post_type( $this->post_type, $args );
145 - }
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 + }
146 142
147 - /**
148 - * Default the taxonomy's terms' order if it's not set.
149 - *
150 - * @param string $tax_slug The taxonomy's slug.
151 - */
152 - public function action_created_betterdocs_faq_category( $term_id ) {
153 - $order = $this->get_max_taxonomy_order( 'betterdocs_faq_category' );
154 - update_term_meta( $term_id, 'order', $order++ );
155 - update_term_meta( $term_id, 'status', 1 );
156 - }
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 157
158 - /**
159 - * Default the taxonomy's terms' order if it's not set.
160 - *
161 - * @param string $tax_slug The taxonomy's slug.
162 - */
163 - public function default_term_order( $tax_slug ) {
164 - $terms = get_terms( $tax_slug, ['hide_empty' => false] );
165 - $order = $this->get_max_taxonomy_order( $tax_slug );
158 + global $wpdb;
166 159
167 - foreach ( $terms as $term ) {
168 - if ( ! get_term_meta( $term->term_id, 'order', true ) ) {
169 - update_term_meta( $term->term_id, 'order', $order );
170 - $order++;
171 - }
172 - }
173 - }
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 174
175 - /**
176 - * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
177 - */
178 - private function get_max_taxonomy_order( $tax_slug ) {
179 - global $wpdb;
175 + $counts = [];
176 + foreach ( (array) $rows as $row ) {
177 + $counts[ (int) $row->term_id ] = (int) $row->draft_count;
178 + }
180 179
181 - $max_term_order = $wpdb->get_col(
182 - $wpdb->prepare(
183 - "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
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 ) )
184 376 FROM $wpdb->terms t
185 - JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = '%s'
377 + JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
186 378 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
187 - $tax_slug
188 - )
189 - );
379 + $tax_slug
380 + )
381 + );
190 382
191 - $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
383 + $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
192 384
193 - return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
194 - }
385 + return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
386 + }
195 387
196 - /**
197 - * Re-Order the taxonomies based on the order value.
198 - *
199 - * @param array $pieces Array of SQL query clauses.
200 - * @param array $taxonomies Array of taxonomy names.
201 - * @param array $args Array of term query args.
202 - */
203 - public function set_tax_order( $pieces, $taxonomies, $args ) {
204 - foreach ( $taxonomies as $taxonomy ) {
205 - global $wpdb;
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 + }
206 402
207 - if ( $taxonomy === 'betterdocs_faq_category' ) {
208 - $join_statement = " LEFT JOIN $wpdb->termmeta AS term_meta ON t.term_id = term_meta.term_id AND term_meta.meta_key = 'order'";
403 + foreach ( $taxonomies as $taxonomy ) {
404 + global $wpdb;
209 405
210 - if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
211 - $pieces['join'] .= $join_statement;
212 - }
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'";
213 408
214 - $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
215 - }
216 - }
409 + if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
410 + $pieces['join'] .= $join_statement;
411 + }
217 412
218 - return $pieces;
219 - }
413 + $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
414 + }
415 + }
220 416
221 - /**
222 - * Order the taxonomies on the front end.
223 - */
224 - public function front_end_order_terms() {
225 - if ( ! is_admin() ) {
226 - add_filter( 'terms_clauses', [$this, 'set_tax_order'], 10, 3 );
227 - }
228 - }
417 + return $pieces;
418 + }
229 419
230 - /**
231 - * Check if a substring exists inside a string.
232 - *
233 - * @param string $string The main string (haystack) we're searching in.
234 - * @param string $substring The substring we're searching for.
235 - *
236 - * @return bool True if substring exists, else false.
237 - */
238 - protected function does_substring_exist( $string, $substring ) {
239 - return strstr( $string, $substring ) !== false;
240 - }
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 + }
241 428
242 - public function register_api_endpoint() {
243 - register_rest_route( $this->namespace, '/faq/sample_data', [
244 - 'methods' => ['POST'],
245 - 'callback' => [$this, 'create_faq_sample'],
246 - 'permission_callback' => function () {
247 - return current_user_can( 'edit_others_posts' );
248 - }
249 - ] );
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 + }
250 440
251 - register_rest_route( $this->namespace, '/faq/posts/(?P<type>\S+)', [
252 - 'methods' => ['GET'],
253 - 'callback' => [$this, 'fetch_faq_posts'],
254 - 'permission_callback' => '__return_true'
255 - ] );
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 + }
256 457
257 - register_rest_route( $this->namespace, '/faq/create_category', [
258 - 'methods' => ['POST'],
259 - 'callback' => [$this, 'create_faq_category'],
260 - 'permission_callback' => function () {
261 - return current_user_can( 'edit_others_posts' );
262 - }
263 - ] );
458 + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
459 + if ( ! $screen || $screen->taxonomy !== $this->category ) {
460 + return;
461 + }
264 462
265 - register_rest_route( $this->namespace, '/faq/update_category', [
266 - 'methods' => ['POST'],
267 - 'callback' => [$this, 'update_faq_category'],
268 - 'permission_callback' => function () {
269 - return current_user_can( 'edit_others_posts' );
270 - }
271 - ] );
463 + betterdocs()->assets->enqueue(
464 + 'betterdocs-category-edit',
465 + 'admin/js/category-edit.js',
466 + [ 'jquery', 'jquery-ui-sortable' ]
467 + );
272 468
273 - register_rest_route( $this->namespace, '/faq/delete_category', [
274 - 'methods' => ['POST'],
275 - 'callback' => [$this, 'delete_faq_category'],
276 - 'permission_callback' => function () {
277 - return current_user_can( 'edit_others_posts' );
278 - }
279 - ] );
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 + }
280 485
281 - register_rest_route( $this->namespace, '/faq/create_post', [
282 - 'methods' => ['POST'],
283 - 'callback' => [$this, 'create_betterdocs_faq'],
284 - 'permission_callback' => function () {
285 - return current_user_can( 'edit_others_posts' );
286 - }
287 - ] );
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 + }
288 495
289 - register_rest_route( $this->namespace, '/faq/update_post', [
290 - 'methods' => ['POST'],
291 - 'callback' => [$this, 'update_betterdocs_faq'],
292 - 'permission_callback' => function () {
293 - return current_user_can( 'edit_others_posts' );
294 - }
295 - ] );
496 + if ( ! $this->can_manage_faqs() ) {
497 + wp_send_json_error( __( 'You don\'t have permission to manage FAQ groups.', 'betterdocs' ) );
498 + }
296 499
297 - register_rest_route( $this->namespace, '/faq/delete_post', [
298 - 'methods' => ['POST'],
299 - 'callback' => [$this, 'delete_betterdocs_faq'],
300 - 'permission_callback' => function () {
301 - return current_user_can( 'edit_others_posts' );
302 - }
303 - ] );
500 + $base_index = isset( $_POST['base_index'] ) ? intval( $_POST['base_index'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
304 501
305 - register_rest_route( $this->namespace, '/faq/category_status', [
306 - 'methods' => ['POST'],
307 - 'callback' => [$this, 'update_category_status'],
308 - 'permission_callback' => function () {
309 - return current_user_can( 'edit_others_posts' );
310 - }
311 - ] );
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 + }
312 520
313 - register_rest_route( $this->namespace, '/faq/category_order', [
314 - 'methods' => ['POST'],
315 - 'callback' => [$this, 'update_faq_category_order'],
316 - 'permission_callback' => function () {
317 - return current_user_can( 'edit_others_posts' );
318 - }
319 - ] );
521 + foreach ( $ordering as $order_data ) {
522 + update_term_meta( $order_data['term_id'], 'order', $order_data['order'] + $base_index );
523 + }
320 524
321 - register_rest_route( $this->namespace, '/faq/update_order_by_category', [
322 - 'methods' => ['POST'],
323 - 'callback' => [$this, 'update_faq_order_by_category'],
324 - 'permission_callback' => function () {
325 - return current_user_can( 'edit_others_posts' );
326 - }
327 - ] );
525 + wp_send_json_success( __( 'Successfully updated.', 'betterdocs' ) );
526 + }
328 527
329 - register_rest_route( $this->namespace, '/faq/uncategorised', [
330 - 'methods' => ['GET'],
331 - 'callback' => [$this, 'get_uncategorised_faq'],
332 - 'permission_callback' => '__return_true'
333 - ] );
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;
334 536
335 - register_rest_route( $this->namespace, '/faq/category_search', [
336 - 'methods' => ['GET'],
337 - 'callback' => [$this, 'category_search'],
338 - 'permission_callback' => '__return_true',
339 - 'args' => array(
340 - 'title' => array(
341 - 'type' => 'string',
342 - 'required' => true
343 - ),
344 - ),
345 - ] );
346 - }
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 + }
347 548
348 - public function create_faq_sample( $params ) {
349 - $sample_data = json_decode( $params->get_param( 'sample_data' ), true );
350 - foreach ( $sample_data as $key => $value ) {
351 - $insert_term = wp_insert_term(
352 - $key,
353 - 'betterdocs_faq_category'
354 - );
355 - if ( $insert_term ) {
356 - foreach ( $value['posts'] as $key => $value ) {
357 - $this->insert_betterdocs_faq( $value['post_title'], $value['post_content'], $insert_term['term_id'] );
358 - }
359 - }
360 - }
361 - return true;
362 - }
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 + }
363 558
364 - public function create_faq_category( $params ) {
365 - $title = $params->get_param( 'title' );
366 - $description = $params->get_param( 'description' );
367 - $description = ( $description !== 'undefined' ) ? $description : '';
368 - $slug = $params->get_param( 'slug' );
369 - return $this->insert_betterdocs_faq_category( $title, $description, $slug );
370 - }
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'";
371 561
372 - public function update_faq_category( $params ) {
373 - $term_id = $params->get_param( 'term_id' );
374 - $title = $params->get_param( 'title' );
375 - $description = $params->get_param( 'description' );
376 - $description = ( $description !== 'undefined' ) ? $description : '';
377 - $slug = $params->get_param( 'slug' );
378 - $update = wp_update_term( $term_id, 'betterdocs_faq_category', [
379 - 'name' => $title,
380 - 'slug' => $slug,
381 - 'description' => $description
382 - ] );
562 + if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
563 + $pieces['join'] .= $join_statement;
564 + }
383 565
384 - if ( is_wp_error( $update ) ) {
385 - return $update;
386 - } else {
387 - return true;
388 - }
389 - }
566 + $pieces['orderby'] = 'ORDER BY CAST( bd_faq_order.meta_value AS UNSIGNED )';
390 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 + public function register_api_endpoint() {
603 + register_rest_route(
604 + $this->namespace,
605 + '/faq/sample_data',
606 + [
607 + 'methods' => [ 'POST' ],
608 + 'callback' => [ $this, 'create_faq_sample' ],
609 + 'permission_callback' => function () {
610 + return $this->can_manage_faqs();
611 + }
612 + ]
613 + );
614 +
615 + register_rest_route(
616 + $this->namespace,
617 + '/faq/posts/(?P<type>\S+)',
618 + [
619 + 'methods' => [ 'GET' ],
620 + 'callback' => [ $this, 'fetch_faq_posts' ],
621 + 'permission_callback' => function () {
622 + return $this->can_manage_faqs();
623 + }
624 + ]
625 + );
626 +
627 + register_rest_route(
628 + $this->namespace,
629 + '/faq/create_category',
630 + [
631 + 'methods' => [ 'POST' ],
632 + 'callback' => [ $this, 'create_faq_category' ],
633 + 'permission_callback' => function () {
634 + return $this->can_manage_faqs();
635 + }
636 + ]
637 + );
638 +
639 + register_rest_route(
640 + $this->namespace,
641 + '/faq/update_category',
642 + [
643 + 'methods' => [ 'POST' ],
644 + 'callback' => [ $this, 'update_faq_category' ],
645 + 'permission_callback' => function () {
646 + return $this->can_manage_faqs();
647 + }
648 + ]
649 + );
650 +
651 + register_rest_route(
652 + $this->namespace,
653 + '/faq/delete_category',
654 + [
655 + 'methods' => [ 'POST' ],
656 + 'callback' => [ $this, 'delete_faq_category' ],
657 + 'permission_callback' => function () {
658 + return $this->can_manage_faqs();
659 + }
660 + ]
661 + );
662 +
663 + register_rest_route(
664 + $this->namespace,
665 + '/faq/create_post',
666 + [
667 + 'methods' => [ 'POST' ],
668 + 'callback' => [ $this, 'create_betterdocs_faq' ],
669 + 'permission_callback' => function () {
670 + return $this->can_manage_faqs();
671 + }
672 + ]
673 + );
674 +
675 + register_rest_route(
676 + $this->namespace,
677 + '/faq/update_post',
678 + [
679 + 'methods' => [ 'POST' ],
680 + 'callback' => [ $this, 'update_betterdocs_faq' ],
681 + 'permission_callback' => function () {
682 + return $this->can_manage_faqs();
683 + }
684 + ]
685 + );
686 +
687 + register_rest_route(
688 + $this->namespace,
689 + '/faq/delete_post',
690 + [
691 + 'methods' => [ 'POST' ],
692 + 'callback' => [ $this, 'delete_betterdocs_faq' ],
693 + 'permission_callback' => function () {
694 + return $this->can_manage_faqs();
695 + }
696 + ]
697 + );
698 +
699 + register_rest_route(
700 + $this->namespace,
701 + '/faq/category_status',
702 + [
703 + 'methods' => [ 'POST' ],
704 + 'callback' => [ $this, 'update_category_status' ],
705 + 'permission_callback' => function () {
706 + return $this->can_manage_faqs();
707 + }
708 + ]
709 + );
710 +
711 + register_rest_route(
712 + $this->namespace,
713 + '/faq/category_order',
714 + [
715 + 'methods' => [ 'POST' ],
716 + 'callback' => [ $this, 'update_faq_category_order' ],
717 + 'permission_callback' => function () {
718 + return $this->can_manage_faqs();
719 + }
720 + ]
721 + );
722 +
723 + register_rest_route(
724 + $this->namespace,
725 + '/faq/update_order_by_category',
726 + [
727 + 'methods' => [ 'POST' ],
728 + 'callback' => [ $this, 'update_faq_order_by_category' ],
729 + 'permission_callback' => function () {
730 + return $this->can_manage_faqs();
731 + }
732 + ]
733 + );
734 +
735 + register_rest_route(
736 + $this->namespace,
737 + '/faq/order',
738 + [
739 + 'methods' => [ 'POST' ],
740 + 'callback' => [ $this, 'update_faq_order_preference' ],
741 + 'permission_callback' => function () {
742 + return $this->can_manage_faqs();
743 + }
744 + ]
745 + );
746 +
747 + register_rest_route(
748 + $this->namespace,
749 + '/faq/uncategorised',
750 + [
751 + 'methods' => [ 'GET' ],
752 + 'callback' => [ $this, 'get_uncategorised_faq' ],
753 + 'permission_callback' => function () {
754 + return $this->can_manage_faqs();
755 + }
756 + ]
757 + );
758 +
759 + register_rest_route(
760 + $this->namespace,
761 + '/faq/category_search',
762 + [
763 + 'methods' => [ 'GET' ],
764 + 'callback' => [ $this, 'category_search' ],
765 + 'permission_callback' => function () {
766 + return $this->can_manage_faqs();
767 + },
768 + 'args' => [
769 + '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'
778 + ]
779 + ]
780 + ]
781 + );
782 + }
783 +
784 + public function create_faq_sample( $params ) {
785 + $sample_data = json_decode( $params->get_param( 'sample_data' ), true );
786 + foreach ( $sample_data as $key => $value ) {
787 + $insert_term = wp_insert_term(
788 + $key,
789 + 'betterdocs_faq_category'
790 + );
791 + if ( $insert_term ) {
792 + foreach ( $value['posts'] as $key => $value ) {
793 + $this->insert_betterdocs_faq( $value['post_title'], $value['post_content'], $insert_term['term_id'] );
794 + }
795 + }
796 + }
797 + return true;
798 + }
799 +
800 + /**
801 + * Resolve the taxonomy from a request, whitelisted to the General and
802 + * Product FAQ category taxonomies. Defaults to the General taxonomy so
803 + * existing callers (which never send a taxonomy) keep their behavior.
804 + *
805 + * @param \WP_REST_Request $params
806 + * @return string
807 + */
808 + private function resolve_taxonomy( $params ) {
809 + $taxonomy = $params->get_param( 'taxonomy' );
810 + return in_array( $taxonomy, [ $this->category, $this->product_category ], true )
811 + ? $taxonomy
812 + : $this->category;
813 + }
814 +
815 + 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 );
820 + $description = $params->get_param( 'description' );
821 + $description = ( $description !== 'undefined' ) ? $description : '';
822 + $slug = $params->get_param( 'slug' );
823 + $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 ) );
841 + }
842 +
843 + public function update_faq_category( $params ) {
844 + $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 );
847 + $description = $params->get_param( 'description' );
848 + $group_icon_url = $params->get_param('group_icon_url');
849 + $description = ( $description !== 'undefined' ) ? $description : '';
850 + $slug = $params->get_param( 'slug' );
851 + $taxonomy = $this->resolve_taxonomy( $params );
852 + $update = wp_update_term(
853 + $term_id,
854 + $taxonomy,
855 + [
856 + 'name' => $title,
857 + 'slug' => $slug,
858 + 'description' => $description
859 + ]
860 + );
861 +
862 + if ( is_wp_error( $update ) ) {
863 + return $update;
864 + } else {
865 + $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
866 + if( $term_id != 0 ) {
867 + $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
868 + update_term_meta($term_id, 'faq_group_icon', $group_icon_url, $previous_icon_url);
869 + $this->save_group_assignments(
870 + $term_id,
871 + $taxonomy,
872 + (array) $params->get_param( 'product_cats' ),
873 + (array) $params->get_param( 'products' ),
874 + $this->all_products_param( $params )
875 + );
876 + }
877 + return true;
878 + }
879 + }
880 +
391 881 public function delete_faq_category( $params ) {
392 882 $term_id = $params->get_param( 'term_id' );
393 - $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
883 + $delete_all_docs = $params->get_param('with_all_post');
884 + $taxonomy = $this->resolve_taxonomy( $params );
394 885
395 - if ( is_wp_error( $delete ) ) {
396 - return $delete;
397 - } else {
398 - return true;
886 + if ( $delete_all_docs ) {
887 + Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
399 888 }
400 - }
401 889
402 - public function insert_betterdocs_faq_category( $title, $description, $slug = '' ) {
403 - $insert_term = wp_insert_term(
404 - $title,
405 - 'betterdocs_faq_category',
406 - [
407 - 'slug' => $slug,
408 - 'description' => $description
409 - ]
410 - );
890 + $delete = wp_delete_term( $term_id, $taxonomy );
411 891
412 - if ( is_wp_error( $insert_term ) ) {
413 - return $insert_term;
414 - } else {
415 - return true;
416 - }
417 - }
892 + if ( is_wp_error( $delete ) ) {
893 + return $delete;
894 + } else {
895 + return true;
896 + }
897 + }
418 898
419 - public function update_faq_category_order( $params ) {
420 - $faq_category_order = $params->get_param( 'faq_category_order' );
421 - $faq_category_order = json_decode( $faq_category_order, true );
899 + public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
900 + $insert_term = wp_insert_term(
901 + $title,
902 + $taxonomy,
903 + [
904 + 'slug' => $slug,
905 + 'description' => $description
906 + ]
907 + );
422 908
423 - foreach ( $faq_category_order as $order_data ) {
424 - if ( (int) $order_data['current_position'] != (int) $order_data['updated_position'] ) {
425 - update_term_meta( $order_data['id'], 'order', ( (int) $order_data['updated_position'] ) );
426 - }
427 - }
428 - return true;
429 - }
909 + if ( is_wp_error( $insert_term ) ) {
910 + return $insert_term;
911 + } else {
912 + $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
913 + if( $term_id != 0 ) {
914 + update_term_meta($term_id, 'faq_group_icon', $icon_url);
915 + $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
916 + }
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;
920 + }
921 + }
430 922
431 - public function insert_betterdocs_faq( $post_title, $post_content, $term_id ) {
432 - $post = wp_insert_post(
433 - [
434 - 'post_type' => 'betterdocs_faq',
435 - 'post_title' => wp_strip_all_tags( $post_title ),
436 - 'post_content' => $post_content,
437 - 'post_status' => 'publish'
438 - ]
439 - );
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 + }
440 942
441 - if ( $term_id ) {
442 - $set_terms = wp_set_object_terms( $post, $term_id, 'betterdocs_faq_category' );
443 - if ( is_wp_error( $set_terms ) ) {
444 - return $set_terms;
445 - } else {
446 - return $this->update_faq_order_on_insert( $term_id, $post );
447 - }
448 - } else {
449 - return $post;
450 - }
451 - }
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 + }
452 952
453 - public function update_faq_order_on_insert( $term_id, $post ) {
454 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
455 - if ( ! empty( $term_meta ) ) {
456 - $term_meta_arr = explode( ",", $term_meta[0] );
457 - if ( ! in_array( $post, $term_meta_arr ) ) {
458 - array_unshift( $term_meta_arr, $post );
459 - $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
460 - return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
461 - }
462 - } else {
463 - return update_term_meta( $term_id, '_betterdocs_faq_order', $post );
464 - }
465 - }
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 ) );
466 961
467 - /**
468 - * Update _betterdocs_faq_order meta when new post created
469 - */
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 ) );
470 970
471 - public function update_faq_order_by_category( $params ) {
472 - $term_id = $params->get_param( 'term_id' );
473 - $posts = $params->get_param( 'posts' );
474 - return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
475 - }
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 + }
476 976
477 - public function create_betterdocs_faq( $params ) {
478 - $post_title = $params->get_param( 'post_title' );
479 - $post_content = $params->get_param( 'post_content' );
480 - $term_id = $params->get_param( 'term_id' );
481 - return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id );
482 - }
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 + }
483 982
484 - public function update_betterdocs_faq( $params ) {
485 - $post_id = $params->get_param( 'post_id' );
486 - $post_title = $params->get_param( 'post_title' );
487 - $post_content = $params->get_param( 'post_content' );
488 - $status = $params->get_param( 'status' );
489 - $term_id = $params->get_param( 'term_id' );
490 - if ( $status ) {
491 - $data = [
492 - 'post_type' => 'betterdocs_faq',
493 - 'ID' => $post_id,
494 - 'status' => $status
495 - ];
496 - } else {
497 - $data = [
498 - 'post_type' => 'betterdocs_faq',
499 - 'ID' => $post_id,
500 - 'post_title' => $post_title,
501 - 'post_content' => $post_content
502 - ];
983 + // Explicitly not "all products" → drop the match-all flag.
984 + delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
985 + }
503 986
504 - if ( $term_id ) {
505 - $data['tax_input'] = [
506 - "betterdocs_faq_category" => $term_id
507 - ];
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 + }
508 995
509 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
510 - $term_meta_arr = explode( ",", $term_meta[0] );
511 - if ( ! in_array( $post_id, $term_meta_arr ) ) {
512 - array_unshift( $term_meta_arr, $post_id );
513 - $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
514 - update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
515 - }
516 - }
517 - }
996 + public function update_faq_category_order( $params ) {
997 + $faq_category_order = $params->get_param( 'faq_category_order' );
998 + $faq_category_order = json_decode( $faq_category_order, true );
518 999
519 - return wp_update_post( $data );
520 - }
1000 + foreach ( $faq_category_order as $order_data ) {
1001 + if ( (int) $order_data['current_position'] != (int) $order_data['updated_position'] ) {
1002 + update_term_meta( $order_data['id'], 'order', ( (int) $order_data['updated_position'] ) );
1003 + }
1004 + }
1005 + return true;
1006 + }
521 1007
522 - public function delete_betterdocs_faq( $params ) {
523 - $post_id = $params->get_param( 'post_id' );
524 - return wp_delete_post( $post_id );
525 - }
1008 + public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1009 + $post = wp_insert_post(
1010 + [
1011 + 'post_type' => 'betterdocs_faq',
1012 + 'post_title' => wp_strip_all_tags( $post_title ),
1013 + 'post_content' => $post_content,
1014 + 'post_status' => 'publish'
1015 + ]
1016 + );
526 1017
527 - public function faq_post_loop( $args ) {
528 - $posts = [];
529 - $query = new WP_Query( $args );
530 - if ( $query->have_posts() ):
531 - while ( $query->have_posts() ): $query->the_post();
532 - $posts[get_the_ID()]['title'] = get_the_title();
533 - $posts[get_the_ID()]['content'] = get_the_content();
534 - endwhile;
535 - endif;
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 + }
536 1029
537 - return $posts;
538 - }
1030 + if ( $term_id ) {
1031 + $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
1032 + if ( is_wp_error( $set_terms ) ) {
1033 + return $set_terms;
1034 + }
1035 + $this->update_faq_order_on_insert( $term_id, $post );
1036 + }
539 1037
540 - public function update_category_status( $params ) {
541 - $term_id = $params->get_param( 'term_id' );
542 - $status = $params->get_param( 'status' );
543 - return update_term_meta( $term_id, 'status', $status );
544 - }
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 + }
545 1042
546 - public function fetch_faq_posts( $params ) {
547 - $faq = [];
548 - $type = $params->get_param( 'type' );
1043 + 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 ) );
1052 + }
1053 + }
549 1054
550 - if ( $type == 'category' ) {
551 - $taxonomy_objects = get_terms( 'betterdocs_faq_category', [
552 - 'hide_empty' => false
553 - ] );
1055 + /**
1056 + * Update _betterdocs_faq_order meta when new post created
1057 + */
554 1058
555 - if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ):
556 - foreach ( $taxonomy_objects as $term ):
557 - $args = [
558 - 'post_type' => 'betterdocs_faq',
559 - 'post_status' => 'publish',
560 - 'post_per_page' => -1,
561 - 'tax_query' => [
562 - [
563 - 'taxonomy' => 'betterdocs_faq_category',
564 - 'field' => 'term_id',
565 - 'terms' => $term->term_id
566 - ]
567 - ]
568 - ];
1059 + public function update_faq_order_by_category( $params ) {
1060 + $term_id = $params->get_param( 'term_id' );
1061 + $posts = $params->get_param( 'posts' );
1062 + return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
1063 + }
569 1064
570 - $posts = $this->faq_post_loop( $args );
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' );
571 1073
572 - $faq[$term->slug] = [
573 - (array) $term,
574 - 'posts' => $posts
575 - ];
576 - endforeach;
577 - endif;
578 - } else {
579 - $args = [
580 - 'post_type' => 'betterdocs_faq',
581 - 'post_status' => 'publish',
582 - 'post_per_page' => -1
583 - ];
584 - $posts = $this->faq_post_loop( $args );
585 - $faq['posts'] = $posts;
586 - }
1074 + if ( ! in_array( $order, $allowed, true ) ) {
1075 + $order = 'default';
1076 + }
587 1077
588 - return $faq;
589 - }
1078 + update_option( 'betterdocs_faq_order', $order );
590 1079
591 - public function get_uncategorised_faq() {
592 - $available_terms = array_map( function ( $term ) {
593 - return $term->term_id;
594 - }, get_terms( [
595 - 'taxonomy' => 'betterdocs_faq_category',
596 - 'hide_empty' => false
597 - ] ) );
1080 + return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1081 + }
598 1082
599 - return get_posts( [
600 - 'post_type' => 'betterdocs_faq',
601 - 'post_status' => ['publish', 'draft'],
602 - 'posts_per_page' => -1,
603 - 'tax_query' => [
604 - 'relation' => 'AND',
605 - [
606 - 'taxonomy' => 'betterdocs_faq_category',
607 - 'field' => 'term_id',
608 - 'terms' => $available_terms,
609 - 'operator' => 'NOT IN'
610 - ]
611 - ]
612 - ] );
613 - }
1083 + public function create_betterdocs_faq( $params ) {
1084 + $post_title = $params->get_param( 'post_title' );
1085 + $post_content = $params->get_param( 'post_content' );
1086 + $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 );
1089 + }
614 1090
615 - public function category_search( $request ) {
1091 + public function update_betterdocs_faq( $params ) {
1092 + $post_id = (int) $params->get_param( 'post_id' );
616 1093
617 - $title = $request['title'];
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 + }
618 1099
1100 + $post_title = $params->get_param( 'post_title' );
1101 + $post_content = $params->get_param( 'post_content' );
1102 + $status = $params->get_param( 'status' );
1103 + $term_id = (int) $params->get_param( 'term_id' );
1104 + $taxonomy = $this->resolve_taxonomy( $params );
1105 + if ( $status ) {
1106 + $data = [
1107 + 'post_type' => 'betterdocs_faq',
1108 + 'ID' => $post_id,
1109 + 'status' => $status
1110 + ];
1111 + } else {
1112 + $data = [
1113 + 'post_type' => 'betterdocs_faq',
1114 + '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 ),
1120 + ];
1121 +
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 ) ) {
1125 + $data['tax_input'] = [
1126 + $taxonomy => [ $term_id ],
1127 + ];
1128 +
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 ) ) {
1137 + array_unshift( $term_meta_arr, $post_id );
1138 + $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1139 + update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1140 + }
1141 + }
1142 + }
1143 +
1144 + return wp_update_post( $data );
1145 + }
1146 +
1147 + 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 +
1155 + return wp_delete_post( $post_id );
1156 + }
1157 +
1158 + public function faq_post_loop( $args ) {
1159 + $posts = [];
1160 + $query = new WP_Query( $args );
1161 + if ( $query->have_posts() ) :
1162 + while ( $query->have_posts() ) :
1163 + $query->the_post();
1164 + $posts[ get_the_ID() ]['title'] = get_the_title();
1165 + $posts[ get_the_ID() ]['content'] = get_the_content();
1166 + endwhile;
1167 + endif;
1168 +
1169 + return $posts;
1170 + }
1171 +
1172 + public function update_category_status( $params ) {
1173 + $term_id = $params->get_param( 'term_id' );
1174 + $status = $params->get_param( 'status' );
1175 + return update_term_meta( $term_id, 'status', $status );
1176 + }
1177 +
1178 + public function fetch_faq_posts( $params ) {
1179 + $faq = [];
1180 + $type = $params->get_param( 'type' );
1181 +
1182 + if ( $type == 'category' ) {
1183 + $taxonomy_objects = get_terms(
1184 + [
1185 + 'taxonomy' => 'betterdocs_faq_category',
1186 + 'hide_empty' => false
1187 + ]
1188 + );
1189 +
1190 + if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
1191 + 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 + $args = [
1194 + 'post_type' => 'betterdocs_faq',
1195 + 'post_status' => 'publish',
1196 + 'post_per_page' => -1,
1197 + 'tax_query' => [
1198 + [
1199 + 'taxonomy' => 'betterdocs_faq_category',
1200 + 'field' => 'term_id',
1201 + 'terms' => $term->term_id
1202 + ]
1203 + ]
1204 + ];
1205 +
1206 + $posts = $this->faq_post_loop( $args );
1207 +
1208 + $faq[ $term->slug ] = [
1209 + (array) $term,
1210 + 'posts' => $posts
1211 + ];
1212 + endforeach;
1213 + endif;
1214 + } else {
1215 + $args = [
1216 + 'post_type' => 'betterdocs_faq',
1217 + 'post_status' => 'publish',
1218 + 'post_per_page' => -1
1219 + ];
1220 + $posts = $this->faq_post_loop( $args );
1221 + $faq['posts'] = $posts;
1222 + }
1223 +
1224 + return $faq;
1225 + }
1226 +
1227 + /**
1228 + * Record an FAQ's scope whenever its group is (re)assigned, so the scope survives
1229 + * the group being deleted. Fires for every path that assigns terms.
1230 + *
1231 + * @param int $object_id Post ID.
1232 + * @param array $terms Terms assigned (unused).
1233 + * @param array $tt_ids Term-taxonomy IDs.
1234 + * @param string $taxonomy Taxonomy the terms belong to.
1235 + */
1236 + public function sync_faq_scope_meta( $object_id, $terms, $tt_ids, $taxonomy ) {
1237 + if ( get_post_type( $object_id ) !== $this->post_type ) {
1238 + return;
1239 + }
1240 +
1241 + if ( $taxonomy === $this->product_category && ! empty( $tt_ids ) ) {
1242 + update_post_meta( $object_id, self::SCOPE_META, 'product' );
1243 + return;
1244 + }
1245 +
1246 + // Assigning a General group makes it a General FAQ — but only claim it if it
1247 + // isn't already a Product FAQ that merely also carries a General term.
1248 + if ( $taxonomy === $this->category && ! empty( $tt_ids ) ) {
1249 + if ( get_post_meta( $object_id, self::SCOPE_META, true ) !== 'product' ) {
1250 + update_post_meta( $object_id, self::SCOPE_META, 'general' );
1251 + }
1252 + }
1253 + }
1254 +
1255 + /**
1256 + * One-time backfill: stamp the scope onto FAQs created before SCOPE_META existed.
1257 + * An FAQ holding a Product group is 'product'; everything else is 'general'.
1258 + * Ungrouped legacy FAQs have no way to be identified as product ones, so they stay
1259 + * in the General tab, which is exactly where they are today.
1260 + */
1261 + public function maybe_backfill_faq_scope() {
1262 + if ( get_option( self::SCOPE_BACKFILL_OPTION ) ) {
1263 + return;
1264 + }
1265 +
1266 + $product_faqs = get_posts(
1267 + [
1268 + 'post_type' => $this->post_type,
1269 + 'post_status' => 'any',
1270 + 'posts_per_page' => -1,
1271 + 'fields' => 'ids',
1272 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one-time backfill.
1273 + 'tax_query' => [
1274 + [
1275 + 'taxonomy' => $this->product_category,
1276 + 'operator' => 'EXISTS',
1277 + ],
1278 + ],
1279 + ]
1280 + );
1281 +
1282 + foreach ( $product_faqs as $faq_id ) {
1283 + update_post_meta( $faq_id, self::SCOPE_META, 'product' );
1284 + }
1285 +
1286 + update_option( self::SCOPE_BACKFILL_OPTION, 1 );
1287 + }
1288 +
1289 + /**
1290 + * FAQs with no group, for the tab that asked. Each tab owns its own bucket:
1291 + * the Product tab lists ungrouped FAQs scoped to 'product', the General tab lists
1292 + * everything else that is ungrouped (so an ungrouped Product FAQ no longer leaks
1293 + * into General).
1294 + */
1295 + public function get_uncategorised_faq( $request = null ) {
1296 + $taxonomy = $request instanceof WP_REST_Request ? $this->resolve_taxonomy( $request ) : $this->category;
1297 + $is_product = ( $taxonomy === $this->product_category );
1298 +
1299 + $term_ids = function ( $tax ) {
1300 + $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => false ] );
1301 + return is_wp_error( $terms ) ? [] : array_map(
1302 + function ( $term ) {
1303 + return $term->term_id;
1304 + },
1305 + $terms
1306 + );
1307 + };
1308 +
1309 + // "Ungrouped" always means: no group in THIS tab's taxonomy.
1310 + $tax_query = [
1311 + 'relation' => 'AND',
1312 + [
1313 + 'taxonomy' => $taxonomy,
1314 + 'field' => 'term_id',
1315 + 'terms' => $term_ids( $taxonomy ),
1316 + 'operator' => 'NOT IN'
1317 + ]
1318 + ];
1319 +
1320 + $meta_query = [];
1321 +
1322 + if ( $is_product ) {
1323 + // Product tab: only FAQs that belong to the Product side.
1324 + $meta_query[] = [
1325 + 'key' => self::SCOPE_META,
1326 + 'value' => 'product',
1327 + ];
1328 + } else {
1329 + // General tab: exclude anything scoped to Product…
1330 + $meta_query[] = [
1331 + 'relation' => 'OR',
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 + public function category_search( $request ) {
1383 +
1384 + $title = $request['title'];
1385 + $taxonomy = $this->resolve_taxonomy( $request );
1386 +
619 1387 // Perform the taxonomy search
620 - $taxonomy_args = array(
1388 + $taxonomy_args = [
621 1389 'name__like' => $title,
622 - 'taxonomy' => 'betterdocs_faq_category',
1390 + 'taxonomy' => $taxonomy,
623 1391 'hide_empty' => false
624 - );
1392 + ];
625 1393
626 - $taxonomies = get_terms($taxonomy_args);
1394 + $taxonomies = get_terms( $taxonomy_args );
627 1395
628 - if (!empty($taxonomies)) {
629 - $result = array();
630 - foreach ($taxonomies as $taxonomy) {
631 - $result[] = array(
632 - 'id' => $taxonomy->term_id,
633 - 'count' => $taxonomy->count,
1396 + if ( ! empty( $taxonomies ) ) {
1397 + $result = [];
1398 + foreach ( $taxonomies as $taxonomy ) {
1399 + $result[] = [
1400 + 'id' => $taxonomy->term_id,
1401 + 'count' => $taxonomy->count,
634 1402 'description' => $taxonomy->description,
635 - 'name' => $taxonomy->name,
636 - 'slug' => $taxonomy->slug
1403 + 'name' => $taxonomy->name,
1404 + 'slug' => $taxonomy->slug
637 1405 // Add more fields as needed
638 - );
1406 + ];
639 1407 }
640 1408 // Return the taxonomy data
641 1409 return $result;
642 1410 } else {
643 1411 // Taxonomy not found
644 - return new WP_Error('taxonomy_not_found', 'Taxonomy not found.', array('status' => 404));
1412 + return new WP_Error( 'taxonomy_not_found', 'Taxonomy not found.', [ 'status' => 404 ] );
645 1413 }
646 1414 }
647 1415
648 - public function faq_category_orderby_meta($args, $request) {
649 - if ($args['taxonomy'] === 'betterdocs_faq_category') {
650 - $args['orderby'] = 'meta_value_num';
651 - $args['meta_key'] = 'order';
1416 + public function faq_category_orderby_meta( $args, $request ) {
1417 + if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 + return $args;
652 1419 }
1420 +
1421 + // Order (and therefore paginate) the whole group list server-side to
1422 + // match the FAQ Builder header dropdown, so page boundaries are cut on
1423 + // the same sort the rows are shown in — otherwise page 1 could end on
1424 + // "W" and page 2 start on "B". The admin passes the order key explicitly
1425 + // (independent of when the preference option finishes saving); fall back
1426 + // to the saved preference, then to the manual drag-drop order.
1427 + $order_key = $request->get_param( 'betterdocs_faq_order' );
1428 + if ( empty( $order_key ) ) {
1429 + $order_key = get_option( 'betterdocs_faq_order', 'default' );
1430 + }
1431 +
1432 + switch ( $order_key ) {
1433 + case 'most_recent':
1434 + $args['orderby'] = 'term_id';
1435 + $args['order'] = 'DESC';
1436 + unset( $args['meta_key'] );
1437 + break;
1438 + case 'least_recent':
1439 + $args['orderby'] = 'term_id';
1440 + $args['order'] = 'ASC';
1441 + unset( $args['meta_key'] );
1442 + break;
1443 + case 'a_to_z':
1444 + $args['orderby'] = 'name';
1445 + $args['order'] = 'ASC';
1446 + unset( $args['meta_key'] );
1447 + break;
1448 + case 'z_to_a':
1449 + $args['orderby'] = 'name';
1450 + $args['order'] = 'DESC';
1451 + unset( $args['meta_key'] );
1452 + break;
1453 + case 'most_questions':
1454 + $args['orderby'] = 'count';
1455 + $args['order'] = 'DESC';
1456 + unset( $args['meta_key'] );
1457 + break;
1458 + case 'default':
1459 + default:
1460 + $args['orderby'] = 'meta_value_num';
1461 + $args['meta_key'] = 'order';
1462 + $args['order'] = 'ASC';
1463 + break;
1464 + }
1465 +
653 1466 return $args;
654 1467 }
655 1468 }