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.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 +551 -1364 4.9.23.5.3 View file →
@@ -1,1468 +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 ( ! $this->can_manage_faqs() ) {
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 - /**
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 -
881 391 public function delete_faq_category( $params ) {
882 392 $term_id = $params->get_param( 'term_id' );
883 - $delete_all_docs = $params->get_param('with_all_post');
884 - $taxonomy = $this->resolve_taxonomy( $params );
393 + $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
885 394
886 - if ( $delete_all_docs ) {
887 - 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;
888 399 }
400 + }
889 401
890 - $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 + );
891 411
892 - if ( is_wp_error( $delete ) ) {
893 - return $delete;
894 - } else {
895 - return true;
896 - }
897 - }
412 + if ( is_wp_error( $insert_term ) ) {
413 + return $insert_term;
414 + } else {
415 + return true;
416 + }
417 + }
898 418
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 - );
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 );
908 422
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 - }
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 + }
922 430
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 - }
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 + );
942 440
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 - }
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 + }
952 452
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 ) );
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 + }
961 466
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 ) );
467 + /**
468 + * Update _betterdocs_faq_order meta when new post created
469 + */
970 470
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 - }
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 + }
976 476
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 - }
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 + }
982 483
983 - // Explicitly not "all products" → drop the match-all flag.
984 - delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
985 - }
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 + ];
986 503
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 - }
504 + if ( $term_id ) {
505 + $data['tax_input'] = [
506 + "betterdocs_faq_category" => $term_id
507 + ];
995 508
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 );
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 + }
999 518
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 - }
519 + return wp_update_post( $data );
520 + }
1007 521
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 - );
522 + public function delete_betterdocs_faq( $params ) {
523 + $post_id = $params->get_param( 'post_id' );
524 + return wp_delete_post( $post_id );
525 + }
1017 526
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 - }
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;
1029 536
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 - }
537 + return $posts;
538 + }
1037 539
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 - }
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 + }
1042 545
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 - }
546 + public function fetch_faq_posts( $params ) {
547 + $faq = [];
548 + $type = $params->get_param( 'type' );
1054 549
1055 - /**
1056 - * Update _betterdocs_faq_order meta when new post created
1057 - */
550 + if ( $type == 'category' ) {
551 + $taxonomy_objects = get_terms( 'betterdocs_faq_category', [
552 + 'hide_empty' => false
553 + ] );
1058 554
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 - }
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 + ];
1064 569
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' );
570 + $posts = $this->faq_post_loop( $args );
1073 571
1074 - if ( ! in_array( $order, $allowed, true ) ) {
1075 - $order = 'default';
1076 - }
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 + }
1077 587
1078 - update_option( 'betterdocs_faq_order', $order );
588 + return $faq;
589 + }
1079 590
1080 - return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1081 - }
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 + ] ) );
1082 598
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 - }
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 + }
1090 614
1091 - public function update_betterdocs_faq( $params ) {
1092 - $post_id = (int) $params->get_param( 'post_id' );
615 + public function category_search( $request ) {
1093 616
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 - }
617 + $title = $request['title'];
1099 618
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 -
1387 619 // Perform the taxonomy search
1388 - $taxonomy_args = [
620 + $taxonomy_args = array(
1389 621 'name__like' => $title,
1390 - 'taxonomy' => $taxonomy,
622 + 'taxonomy' => 'betterdocs_faq_category',
1391 623 'hide_empty' => false
1392 - ];
624 + );
1393 625
1394 - $taxonomies = get_terms( $taxonomy_args );
626 + $taxonomies = get_terms($taxonomy_args);
1395 627
1396 - if ( ! empty( $taxonomies ) ) {
1397 - $result = [];
1398 - foreach ( $taxonomies as $taxonomy ) {
1399 - $result[] = [
1400 - 'id' => $taxonomy->term_id,
1401 - '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,
1402 634 'description' => $taxonomy->description,
1403 - 'name' => $taxonomy->name,
1404 - 'slug' => $taxonomy->slug
635 + 'name' => $taxonomy->name,
636 + 'slug' => $taxonomy->slug
1405 637 // Add more fields as needed
1406 - ];
638 + );
1407 639 }
1408 640 // Return the taxonomy data
1409 641 return $result;
1410 642 } else {
1411 643 // Taxonomy not found
1412 - 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));
1413 645 }
1414 646 }
1415 647
1416 - public function faq_category_orderby_meta( $args, $request ) {
1417 - if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 - 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';
1419 652 }
1420 -
1421 - // Order (and therefore paginate) the whole group list server-side to
1422 - // match the FAQ Builder header dropdown, so page boundaries are cut on
1423 - // the same sort the rows are shown in — otherwise page 1 could end on
1424 - // "W" and page 2 start on "B". The admin passes the order key explicitly
1425 - // (independent of when the preference option finishes saving); fall back
1426 - // to the saved preference, then to the manual drag-drop order.
1427 - $order_key = $request->get_param( 'betterdocs_faq_order' );
1428 - if ( empty( $order_key ) ) {
1429 - $order_key = get_option( 'betterdocs_faq_order', 'default' );
1430 - }
1431 -
1432 - switch ( $order_key ) {
1433 - case 'most_recent':
1434 - $args['orderby'] = 'term_id';
1435 - $args['order'] = 'DESC';
1436 - unset( $args['meta_key'] );
1437 - break;
1438 - case 'least_recent':
1439 - $args['orderby'] = 'term_id';
1440 - $args['order'] = 'ASC';
1441 - unset( $args['meta_key'] );
1442 - break;
1443 - case 'a_to_z':
1444 - $args['orderby'] = 'name';
1445 - $args['order'] = 'ASC';
1446 - unset( $args['meta_key'] );
1447 - break;
1448 - case 'z_to_a':
1449 - $args['orderby'] = 'name';
1450 - $args['order'] = 'DESC';
1451 - unset( $args['meta_key'] );
1452 - break;
1453 - case 'most_questions':
1454 - $args['orderby'] = 'count';
1455 - $args['order'] = 'DESC';
1456 - unset( $args['meta_key'] );
1457 - break;
1458 - case 'default':
1459 - default:
1460 - $args['orderby'] = 'meta_value_num';
1461 - $args['meta_key'] = 'order';
1462 - $args['order'] = 'ASC';
1463 - break;
1464 - }
1465 -
1466 653 return $args;
1467 654 }
1468 655 }