PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.5.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.5.3
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
← All changes | includes/Core/FAQBuilder.php +551 -1333 4.8.23.5.3 View file →
@@ -1,1437 +1,655 @@
1 1 <?php
2 +
2 3 namespace WPDeveloper\BetterDocs\Core;
3 4
4 -if ( ! defined( 'ABSPATH' ) ) {
5 - exit;
6 -}
7 -
8 -// FAQ-by-category lookups require tax_query / meta_key filters by design.
9 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
10 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
11 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
12 -
5 +use WP_Query;
13 6 use WP_Error;
14 -use WP_Query;
15 -use WP_REST_Request;
16 7 use WPDeveloper\BetterDocs\Utils\Base;
17 -use WPDeveloper\BetterDocs\Utils\Helper;
18 8
19 9 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';
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';
27 17
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';
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 + }
36 35
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';
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' );
49 40
50 - /** Option flag for the one-time backfill of SCOPE_META on pre-existing FAQs. */
51 - const SCOPE_BACKFILL_OPTION = 'betterdocs_faq_scope_backfilled';
41 + // removing emoji support
42 + remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
43 + remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
52 44
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';
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 + }
58 53
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';
54 + public function output() {
55 + betterdocs()->views->get( 'admin/faq-builder' );
56 + }
64 57
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';
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 + ];
72 81
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 = [];
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 + ];
81 99
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' ] );
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] );
97 104
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 );
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 + ];
107 124
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 - }
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 + ];
115 143
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 - }
144 + register_post_type( $this->post_type, $args );
145 + }
142 146
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 - }
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 + }
157 157
158 - global $wpdb;
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 );
159 166
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
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 + }
174 174
175 - $counts = [];
176 - foreach ( (array) $rows as $row ) {
177 - $counts[ (int) $row->term_id ] = (int) $row->draft_count;
178 - }
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;
179 180
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 ) )
181 + $max_term_order = $wpdb->get_col(
182 + $wpdb->prepare(
183 + "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
376 184 FROM $wpdb->terms t
377 - JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
185 + JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = '%s'
378 186 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
379 - $tax_slug
380 - )
381 - );
187 + $tax_slug
188 + )
189 + );
382 190
383 - $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
191 + $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
384 192
385 - return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
386 - }
193 + return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
194 + }
387 195
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 - }
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;
402 206
403 - foreach ( $taxonomies as $taxonomy ) {
404 - global $wpdb;
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'";
405 209
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'";
210 + if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
211 + $pieces['join'] .= $join_statement;
212 + }
408 213
409 - if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
410 - $pieces['join'] .= $join_statement;
411 - }
214 + $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
215 + }
216 + }
412 217
413 - $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
414 - }
415 - }
218 + return $pieces;
219 + }
416 220
417 - return $pieces;
418 - }
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 + }
419 229
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 - }
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 + }
428 241
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 - }
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 + ] );
440 250
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 - }
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 + ] );
457 256
458 - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
459 - if ( ! $screen || $screen->taxonomy !== $this->category ) {
460 - return;
461 - }
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 + ] );
462 264
463 - betterdocs()->assets->enqueue(
464 - 'betterdocs-category-edit',
465 - 'admin/js/category-edit.js',
466 - [ 'jquery', 'jquery-ui-sortable' ]
467 - );
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 + ] );
468 272
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 - }
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 + ] );
485 280
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 - }
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 + ] );
495 288
496 - if ( ! current_user_can( 'edit_others_posts' ) ) {
497 - wp_send_json_error( __( 'You don\'t have permission to manage FAQ groups.', 'betterdocs' ) );
498 - }
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 + ] );
499 296
500 - $base_index = isset( $_POST['base_index'] ) ? intval( $_POST['base_index'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
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 + ] );
501 304
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 - }
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 + ] );
520 312
521 - foreach ( $ordering as $order_data ) {
522 - update_term_meta( $order_data['term_id'], 'order', $order_data['order'] + $base_index );
523 - }
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 + ] );
524 320
525 - wp_send_json_success( __( 'Successfully updated.', 'betterdocs' ) );
526 - }
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 + ] );
527 328
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;
329 + register_rest_route( $this->namespace, '/faq/uncategorised', [
330 + 'methods' => ['GET'],
331 + 'callback' => [$this, 'get_uncategorised_faq'],
332 + 'permission_callback' => '__return_true'
333 + ] );
536 334
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 - }
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 + }
548 347
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 - }
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 + }
558 363
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'";
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 + }
561 371
562 - if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
563 - $pieces['join'] .= $join_statement;
564 - }
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 + ] );
565 383
566 - $pieces['orderby'] = 'ORDER BY CAST( bd_faq_order.meta_value AS UNSIGNED )';
384 + if ( is_wp_error( $update ) ) {
385 + return $update;
386 + } else {
387 + return true;
388 + }
389 + }
567 390
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 391 public function delete_faq_category( $params ) {
851 392 $term_id = $params->get_param( 'term_id' );
852 - $delete_all_docs = $params->get_param('with_all_post');
853 - $taxonomy = $this->resolve_taxonomy( $params );
393 + $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
854 394
855 - if ( $delete_all_docs ) {
856 - Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
395 + if ( is_wp_error( $delete ) ) {
396 + return $delete;
397 + } else {
398 + return true;
857 399 }
400 + }
858 401
859 - $delete = wp_delete_term( $term_id, $taxonomy );
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 + );
860 411
861 - if ( is_wp_error( $delete ) ) {
862 - return $delete;
863 - } else {
864 - return true;
865 - }
866 - }
412 + if ( is_wp_error( $insert_term ) ) {
413 + return $insert_term;
414 + } else {
415 + return true;
416 + }
417 + }
867 418
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 - );
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 );
877 422
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 - }
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 + }
891 430
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 - }
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 + );
911 440
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 - }
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 + }
921 452
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 ) );
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 + }
930 466
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 ) );
467 + /**
468 + * Update _betterdocs_faq_order meta when new post created
469 + */
939 470
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 - }
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 + }
945 476
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 - }
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 + }
951 483
952 - // Explicitly not "all products" → drop the match-all flag.
953 - delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
954 - }
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 + ];
955 503
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 - }
504 + if ( $term_id ) {
505 + $data['tax_input'] = [
506 + "betterdocs_faq_category" => $term_id
507 + ];
964 508
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 );
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 + }
968 518
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 - }
519 + return wp_update_post( $data );
520 + }
976 521
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 - );
522 + public function delete_betterdocs_faq( $params ) {
523 + $post_id = $params->get_param( 'post_id' );
524 + return wp_delete_post( $post_id );
525 + }
986 526
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 - }
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;
998 536
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 - }
537 + return $posts;
538 + }
1006 539
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 - }
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 + }
1011 545
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 - }
546 + public function fetch_faq_posts( $params ) {
547 + $faq = [];
548 + $type = $params->get_param( 'type' );
1023 549
1024 - /**
1025 - * Update _betterdocs_faq_order meta when new post created
1026 - */
550 + if ( $type == 'category' ) {
551 + $taxonomy_objects = get_terms( 'betterdocs_faq_category', [
552 + 'hide_empty' => false
553 + ] );
1027 554
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 - }
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 + ];
1033 569
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' );
570 + $posts = $this->faq_post_loop( $args );
1042 571
1043 - if ( ! in_array( $order, $allowed, true ) ) {
1044 - $order = 'default';
1045 - }
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 + }
1046 587
1047 - update_option( 'betterdocs_faq_order', $order );
588 + return $faq;
589 + }
1048 590
1049 - return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1050 - }
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 + ] ) );
1051 598
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 - }
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 + }
1059 614
1060 - public function update_betterdocs_faq( $params ) {
1061 - $post_id = (int) $params->get_param( 'post_id' );
615 + public function category_search( $request ) {
1062 616
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 - }
617 + $title = $request['title'];
1068 618
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 619 // Perform the taxonomy search
1357 - $taxonomy_args = [
620 + $taxonomy_args = array(
1358 621 'name__like' => $title,
1359 - 'taxonomy' => $taxonomy,
622 + 'taxonomy' => 'betterdocs_faq_category',
1360 623 'hide_empty' => false
1361 - ];
624 + );
1362 625
1363 - $taxonomies = get_terms( $taxonomy_args );
626 + $taxonomies = get_terms($taxonomy_args);
1364 627
1365 - if ( ! empty( $taxonomies ) ) {
1366 - $result = [];
1367 - foreach ( $taxonomies as $taxonomy ) {
1368 - $result[] = [
1369 - 'id' => $taxonomy->term_id,
1370 - 'count' => $taxonomy->count,
628 + if (!empty($taxonomies)) {
629 + $result = array();
630 + foreach ($taxonomies as $taxonomy) {
631 + $result[] = array(
632 + 'id' => $taxonomy->term_id,
633 + 'count' => $taxonomy->count,
1371 634 'description' => $taxonomy->description,
1372 - 'name' => $taxonomy->name,
1373 - 'slug' => $taxonomy->slug
635 + 'name' => $taxonomy->name,
636 + 'slug' => $taxonomy->slug
1374 637 // Add more fields as needed
1375 - ];
638 + );
1376 639 }
1377 640 // Return the taxonomy data
1378 641 return $result;
1379 642 } else {
1380 643 // Taxonomy not found
1381 - return new WP_Error( 'taxonomy_not_found', 'Taxonomy not found.', [ 'status' => 404 ] );
644 + return new WP_Error('taxonomy_not_found', 'Taxonomy not found.', array('status' => 404));
1382 645 }
1383 646 }
1384 647
1385 - public function faq_category_orderby_meta( $args, $request ) {
1386 - if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1387 - return $args;
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';
1388 652 }
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 653 return $args;
1436 654 }
1437 655 }