PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.2
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / SampleDocBuilder.php

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

723 lines 25.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 use WP_Error;
6
7 /**
8 * SampleDocBuilder — Phase 4 of the AI "Generate Sample Docs" feature.
9 *
10 * Turns the proxy's structured response into real BetterDocs content:
11 * doc/FAQ categories (terms) + articles (posts), preserving order. Everything
12 * created is flagged with the `_betterdocs_sample` meta so it can be removed
13 * cleanly via undo().
14 *
15 * @since 4.5.3
16 */
17 class SampleDocBuilder {
18 /** Meta key flagging sample posts and terms we created. */
19 const SAMPLE_META = '_betterdocs_sample';
20
21 /**
22 * Post type + taxonomy per content type.
23 *
24 * @var array
25 */
26 const MAP = [
27 'docs' => [ 'post_type' => 'docs', 'taxonomy' => 'doc_category' ],
28 'faq' => [ 'post_type' => 'betterdocs_faq', 'taxonomy' => 'betterdocs_faq_category' ],
29 'product_faq' => [ 'post_type' => 'betterdocs_faq', 'taxonomy' => 'betterdocs_product_faq_category' ],
30 ];
31
32 /** Ordering rank so an intro then a quickstart always lead their category. */
33 const TYPE_RANK = [ 'intro' => 0, 'quickstart' => 1 ];
34
35 /**
36 * Create terms + posts from the (already sanitized) categories array.
37 *
38 * Two passes so the AI's intra-KB cross-links resolve to real permalinks: pass 1
39 * creates every term + post (capturing each article's declared slug → post id);
40 * pass 2 rewrites the `#bd-link--slug` sentinels in each body to the sibling's real
41 * permalink (neutralizing any that never got created) and writes the final blocks.
42 *
43 * @param array $categories [{ name, slug?, description, articles:[{title,slug?,type?,content_html,excerpt}] }]
44 * @param string $content_type 'docs' | 'faq'
45 * @return array|WP_Error Summary on success.
46 */
47 public function build( array $categories, $content_type = 'docs' ) {
48 $map = $this->map( $content_type );
49 if ( null === $map ) {
50 return new WP_Error( 'invalid_content_type', __( 'Unknown content type.', 'betterdocs' ) );
51 }
52
53 if ( empty( $categories ) ) {
54 return new WP_Error( 'no_categories', __( 'No categories to create.', 'betterdocs' ) );
55 }
56
57 // Idempotency guard: if sample content for this type already exists (e.g. a
58 // duplicate insert call, which the UI prevents but a direct REST call does
59 // not), return the existing summary instead of creating duplicate posts.
60 // Regeneration is expected to undo() first, which clears these.
61 $existing = $this->existing_sample( $map );
62 if ( $existing['categories'] > 0 || $existing['articles'] > 0 ) {
63 return array_merge( [ 'content_type' => $content_type, 'already_exists' => true ], $existing );
64 }
65
66 $created_terms = [];
67 $created_posts = [];
68 $slug_to_id = []; // declared article slug → created post id (cross-link map)
69 $pending = []; // [ post_id => raw content_html ] to finalize in pass 2
70 $cat_posts = []; // term_id → [ post_id, … ] in intended reading order
71 $order = 0; // global menu_order so intro/quickstart lead the whole KB
72 $order_meta_key = $this->category_order_meta_key( $map['taxonomy'] );
73
74 /**
75 * Status sample content is created with. Draft by default so AI-generated docs
76 * are never auto-published on a live site — the owner reviews each and publishes
77 * it manually. Drafts are non-public/non-indexable but still show in the admin
78 * dashboard (categories via hide_empty=false; docs via the edit_docs status set),
79 * so they can be reviewed. Each post is flagged `_betterdocs_sample` (used by
80 * undo). Filterable for sites that prefer to publish immediately.
81 *
82 * @param string $status Post status ('draft').
83 * @param string $content_type docs|faq|product_faq
84 */
85 $post_status = apply_filters( 'betterdocs_sample_docs_post_status', 'draft', $content_type );
86
87 // -- Pass 1: create terms + posts (bodies still carry link sentinels) --------
88 foreach ( $categories as $cat_index => $category ) {
89 if ( empty( $category['name'] ) ) {
90 continue;
91 }
92
93 $term_id = $this->ensure_term( $category, $map['taxonomy'] );
94 if ( is_wp_error( $term_id ) || ! $term_id ) {
95 continue;
96 }
97 $created_terms[] = $term_id;
98
99 $articles = isset( $category['articles'] ) && is_array( $category['articles'] ) ? $category['articles'] : [];
100 foreach ( $this->order_articles( $articles ) as $article ) {
101 $title = is_array( $article ) ? ( $article['title'] ?? '' ) : (string) $article;
102 if ( '' === trim( $title ) ) {
103 continue;
104 }
105
106 $content = is_array( $article ) ? ( $article['content_html'] ?? '' ) : '';
107 $excerpt = is_array( $article ) ? ( $article['excerpt'] ?? '' ) : '';
108 $slug = is_array( $article ) && ! empty( $article['slug'] ) ? sanitize_title( $article['slug'] ) : '';
109
110 $postarr = [
111 'post_type' => $map['post_type'],
112 'post_title' => wp_strip_all_tags( $title ),
113 // Placeholder now; the real blocks are written in pass 2 once the
114 // full slug → id map exists.
115 'post_content' => '',
116 'post_excerpt' => sanitize_text_field( $excerpt ),
117 'post_status' => $post_status,
118 'menu_order' => $order++,
119 ];
120 // Give drafts a real slug up front — WP only derives post_name on publish,
121 // so without this a draft's get_permalink() (used to resolve cross-links)
122 // would be an ugly ?p=ID URL.
123 if ( '' !== $slug ) {
124 $postarr['post_name'] = $slug;
125 }
126
127 $post_id = wp_insert_post( $postarr, true );
128
129 if ( is_wp_error( $post_id ) ) {
130 continue;
131 }
132
133 wp_set_object_terms( $post_id, [ (int) $term_id ], $map['taxonomy'] );
134 update_post_meta( $post_id, self::SAMPLE_META, 1 );
135
136 // Record which FAQ tab this belongs to, so it stays in that tab's
137 // "Uncategorized" bucket if its group is ever deleted. FAQBuilder syncs this
138 // on set_object_terms too, but stamping it here does not depend on that hook
139 // being loaded — and a generated FAQ that ends up scope-less silently
140 // defaults into the GENERAL tab, which is how store FAQs from the old
141 // product generator ended up mixed in with general ones.
142 if ( 'docs' !== $content_type ) {
143 update_post_meta(
144 $post_id,
145 '_betterdocs_faq_scope',
146 'product_faq' === $content_type ? 'product' : 'general'
147 );
148 }
149
150 if ( '' !== $slug && ! isset( $slug_to_id[ $slug ] ) ) {
151 $slug_to_id[ $slug ] = $post_id;
152 }
153
154 $pending[ $post_id ] = (string) $content;
155 $created_posts[] = $post_id;
156 $cat_posts[ $term_id ][] = $post_id; // preserve intro/quickstart-first order
157 }
158 }
159
160 // -- Pass 2: resolve cross-links, convert to blocks, write final content ------
161 foreach ( $pending as $post_id => $raw ) {
162 $resolved = $this->resolve_cross_links( $raw, $slug_to_id );
163 wp_update_post(
164 [
165 'ID' => $post_id,
166 'post_content' => $this->html_to_blocks( $resolved ),
167 ]
168 );
169 }
170
171 // -- Pass 3: pin category display order to the KB's designed reading order -----
172 // Done LAST, after every term + post exists, because BetterDocs re-seeds
173 // `doc_category_order` to max+1 via the created_doc_category hook as each later
174 // category is created — which would otherwise bump the first category (Getting
175 // Started) to the end.
176 //
177 // Order is 1-based on purpose: BetterDocs' default_term_order() (which runs on
178 // the admin dashboard load) treats a term whose order fails `! get_term_meta()`
179 // as "unordered" and reassigns it to max+1. A 0 value is falsy in PHP, so a
180 // first category at 0 would get bumped to the end on the very next page load.
181 // Starting at 1 keeps every value truthy and the intended order stable.
182 foreach ( array_values( $created_terms ) as $position => $term_id ) {
183 update_term_meta( $term_id, $order_meta_key, $position + 1 );
184 }
185
186 // Pin the WITHIN-category item order for EVERY content type. BetterDocs orders
187 // items in a category by a term-meta id list (docs: `_docs_order`; FAQ &
188 // product FAQ: `_betterdocs_faq_order`), and its insert hooks PREPEND each new
189 // item — so left alone the order comes out reversed, AND for FAQs it is never
190 // seeded at all, which is why publishing a drafted sample FAQ reshuffled the
191 // list to the top. Overwrite it here with the intended generation order so the
192 // list stays put through activation, exactly as docs do.
193 $order_key = $this->within_category_order_meta_key( $map['taxonomy'] );
194 if ( $order_key ) {
195 foreach ( $cat_posts as $term_id => $post_ids ) {
196 update_term_meta(
197 $term_id,
198 'doc_category' === $map['taxonomy'] ? $this->docs_order_meta_key( $term_id ) : $order_key,
199 implode( ',', array_map( 'intval', $post_ids ) )
200 );
201 }
202 }
203
204 /**
205 * Fires after sample content is created (telemetry/integration hook).
206 *
207 * @param array $created_posts
208 * @param array $created_terms
209 * @param string $content_type
210 */
211 do_action( 'betterdocs_sample_docs_created', $created_posts, $created_terms, $content_type );
212
213 return [
214 'content_type' => $content_type,
215 'categories' => count( $created_terms ),
216 'articles' => count( $created_posts ),
217 'term_ids' => $created_terms,
218 'post_ids' => $created_posts,
219 ];
220 }
221
222 /**
223 * Stable-sort a category's articles so an "intro" then a "quickstart" always lead,
224 * with everything else keeping its given order.
225 *
226 * @return array
227 */
228 protected function order_articles( array $articles ) {
229 $articles = array_values( $articles );
230 $indexed = [];
231 foreach ( $articles as $i => $article ) {
232 $type = is_array( $article ) && ! empty( $article['type'] ) ? (string) $article['type'] : '';
233 $rank = isset( self::TYPE_RANK[ $type ] ) ? self::TYPE_RANK[ $type ] : 2;
234 $indexed[] = [ 'rank' => $rank, 'i' => $i, 'article' => $article ];
235 }
236 usort(
237 $indexed,
238 function ( $a, $b ) {
239 return $a['rank'] === $b['rank'] ? ( $a['i'] <=> $b['i'] ) : ( $a['rank'] <=> $b['rank'] );
240 }
241 );
242 return array_column( $indexed, 'article' );
243 }
244
245 /**
246 * Rewrite `#bd-link--slug` cross-link sentinels to real permalinks. Slugs that were
247 * never created (e.g. a skipped article) have their surrounding <a> stripped so no
248 * dangling sentinel is ever published.
249 *
250 * @param string $html
251 * @param array $slug_to_id declared slug → post id
252 * @return string
253 */
254 protected function resolve_cross_links( $html, array $slug_to_id ) {
255 $html = (string) $html;
256 if ( false === strpos( $html, '#bd-link--' ) ) {
257 return $html;
258 }
259
260 // Resolve known slugs to permalinks. The slug class stops at the closing quote,
261 // so this matches the whole slug (no partial-prefix collisions).
262 $html = preg_replace_callback(
263 '/#bd-link--([a-z0-9\-]+)/i',
264 function ( $m ) use ( $slug_to_id ) {
265 $slug = strtolower( $m[1] );
266 if ( isset( $slug_to_id[ $slug ] ) ) {
267 $url = $this->permalink_for( $slug_to_id[ $slug ] );
268 if ( $url ) {
269 return esc_url( $url );
270 }
271 }
272 // Leave the sentinel in place so the dangling-anchor sweep below removes it.
273 return '#bd-link--' . $slug;
274 },
275 $html
276 );
277
278 // Strip any anchor whose href is still an unresolved sentinel, keeping its text.
279 $html = preg_replace(
280 '/<a\b[^>]*href=("|\')#bd-link--[a-z0-9\-]+\1[^>]*>(.*?)<\/a>/is',
281 '$2',
282 $html
283 );
284
285 return $html;
286 }
287
288 /**
289 * Pretty permalink for a post that may still be a draft. get_permalink() returns
290 * an ugly `?p=ID` URL for unpublished posts, so for cross-links we build the
291 * would-be published permalink (stable through review → publish).
292 *
293 * @param int $post_id
294 * @return string
295 */
296 protected function permalink_for( $post_id ) {
297 $status = get_post_status( $post_id );
298 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
299 return (string) get_permalink( $post_id );
300 }
301
302 if ( ! function_exists( 'get_sample_permalink' ) ) {
303 require_once ABSPATH . 'wp-admin/includes/post.php';
304 }
305 $sample = get_sample_permalink( $post_id );
306 $post = get_post( $post_id );
307 if ( is_array( $sample ) && ! empty( $sample[0] ) && $post instanceof \WP_Post ) {
308 $name = '' !== $post->post_name ? $post->post_name : sanitize_title( $post->post_title );
309 return str_replace( [ '%pagename%', '%postname%' ], $name, $sample[0] );
310 }
311
312 return (string) get_permalink( $post_id );
313 }
314
315 /**
316 * Remove exactly the sample content we created for a content type.
317 *
318 * @param string $content_type 'docs' | 'faq'
319 * @return array Counts removed.
320 */
321 public function undo( $content_type = 'docs' ) {
322 $map = $this->map( $content_type );
323 if ( null === $map ) {
324 return [ 'categories' => 0, 'articles' => 0 ];
325 }
326
327 // 1. Delete flagged posts. Scope by taxonomy as well as post type: General
328 // FAQs and Product FAQs share the `betterdocs_faq` post type, so the
329 // taxonomy is what keeps "undo" from removing the other scope's samples.
330 $posts = get_posts(
331 [
332 'post_type' => $map['post_type'],
333 'post_status' => 'any',
334 'posts_per_page' => -1,
335 'fields' => 'ids',
336 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
337 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
338 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
339 [
340 'taxonomy' => $map['taxonomy'],
341 'operator' => 'EXISTS',
342 ],
343 ],
344 ]
345 );
346 foreach ( $posts as $post_id ) {
347 wp_delete_post( $post_id, true );
348 }
349
350 // 2. Delete flagged terms.
351 $terms = get_terms(
352 [
353 'taxonomy' => $map['taxonomy'],
354 'hide_empty' => false,
355 'fields' => 'ids',
356 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
357 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
358 ]
359 );
360 $term_count = 0;
361 if ( ! is_wp_error( $terms ) ) {
362 foreach ( $terms as $term_id ) {
363 wp_delete_term( $term_id, $map['taxonomy'] );
364 $term_count++;
365 }
366 }
367
368 do_action( 'betterdocs_sample_docs_removed', $content_type );
369
370 return [
371 'content_type' => $content_type,
372 'categories' => $term_count,
373 'articles' => count( $posts ),
374 ];
375 }
376
377 /* --------------------------------------------------------------------- */
378
379 /**
380 * Collect the sample content already created for a content type, so build()
381 * stays idempotent against duplicate insert calls.
382 *
383 * @return array { categories, articles, term_ids, post_ids }
384 */
385 protected function existing_sample( array $map ) {
386 $post_ids = get_posts(
387 [
388 'post_type' => $map['post_type'],
389 'post_status' => 'any',
390 'posts_per_page' => -1,
391 'fields' => 'ids',
392 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
393 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
394 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
395 [
396 'taxonomy' => $map['taxonomy'],
397 'operator' => 'EXISTS',
398 ],
399 ],
400 ]
401 );
402
403 $term_ids = get_terms(
404 [
405 'taxonomy' => $map['taxonomy'],
406 'hide_empty' => false,
407 'fields' => 'ids',
408 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
409 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
410 ]
411 );
412 $term_ids = is_wp_error( $term_ids ) ? [] : $term_ids;
413
414 return [
415 'categories' => count( $term_ids ),
416 'articles' => count( $post_ids ),
417 'term_ids' => array_map( 'intval', $term_ids ),
418 'post_ids' => array_map( 'intval', $post_ids ),
419 ];
420 }
421
422 /**
423 * Term-meta key BetterDocs orders categories by, per taxonomy. Docs use
424 * `doc_category_order` (with the multilingual fallback BetterDocs itself uses);
425 * FAQ and product-FAQ categories use `order`.
426 *
427 * @param string $taxonomy
428 * @return string
429 */
430 protected function category_order_meta_key( $taxonomy ) {
431 if ( 'doc_category' === $taxonomy ) {
432 $helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
433 if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
434 return $helper::get_meta_key_with_fallback( 'doc_category_order' );
435 }
436 return 'doc_category_order';
437 }
438 return 'order';
439 }
440
441 /**
442 * Term-meta key BetterDocs orders items *within* a category by, per taxonomy:
443 * docs → `_docs_order` (multilingual-aware, see docs_order_meta_key()); FAQ and
444 * product FAQ → `_betterdocs_faq_order`. Empty string if the taxonomy has no such
445 * ordering meta.
446 *
447 * @param string $taxonomy
448 * @return string
449 */
450 protected function within_category_order_meta_key( $taxonomy ) {
451 if ( 'doc_category' === $taxonomy ) {
452 return '_docs_order';
453 }
454 if ( in_array( $taxonomy, [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ], true ) ) {
455 return '_betterdocs_faq_order';
456 }
457 return '';
458 }
459
460 /**
461 * Term-meta key BetterDocs orders docs *within* a category by (`_docs_order`),
462 * using the same multilingual fallback the plugin itself applies.
463 *
464 * @param int $term_id
465 * @return string
466 */
467 protected function docs_order_meta_key( $term_id ) {
468 $helper = 'WPDeveloper\\BetterDocs\\Utils\\Helper';
469 if ( class_exists( $helper ) && method_exists( $helper, 'get_meta_key_with_fallback' ) ) {
470 return $helper::get_meta_key_with_fallback( '_docs_order', $term_id );
471 }
472 return '_docs_order';
473 }
474
475 /**
476 * Create the category term (or reuse an existing one) and flag it if new.
477 *
478 * @return int|WP_Error
479 */
480 protected function ensure_term( array $category, $taxonomy ) {
481 $name = sanitize_text_field( $category['name'] );
482 $existing = term_exists( $name, $taxonomy );
483
484 if ( $existing && ! empty( $existing['term_id'] ) ) {
485 // Still (re)assign: a re-run reuses the existing group term, and returning early
486 // left it assigned to nothing (so its FAQs showed on no product page at all).
487 $this->route_product_assignment( (int) $existing['term_id'], $category, $taxonomy );
488 return (int) $existing['term_id'];
489 }
490
491 $inserted = wp_insert_term(
492 $name,
493 $taxonomy,
494 [ 'description' => sanitize_text_field( $category['description'] ?? '' ) ]
495 );
496
497 if ( is_wp_error( $inserted ) ) {
498 return $inserted;
499 }
500
501 $term_id = (int) $inserted['term_id'];
502 update_term_meta( $term_id, self::SAMPLE_META, 1 );
503
504 $this->route_product_assignment( $term_id, $category, $taxonomy );
505
506 return $term_id;
507 }
508
509 /**
510 * Decide where a generated Product FAQ group is shown on the storefront.
511 *
512 * Two layers:
513 * - Store-wide (Layer 1, `all_products`) → flag the group to show on EVERY product
514 * page, once. This is the consolidated Payments/Shipping/Returns/Orders group.
515 * - Per-category (Layer 2) → attach to the one product category it is about.
516 *
517 * A generated group with neither would be invisible on the storefront, so every
518 * product FAQ group we create is routed to exactly one of the two.
519 *
520 * @param int $term_id
521 * @param array $category The generated payload (carries `all_products` / `product_category`).
522 * @param string $taxonomy
523 */
524 protected function route_product_assignment( $term_id, array $category, $taxonomy ) {
525 if ( 'betterdocs_product_faq_category' !== $taxonomy ) {
526 return;
527 }
528
529 if ( ! empty( $category['all_products'] ) ) {
530 // Show on every product page — the dormant front-end mechanism in
531 // WooProductFAQ::get_group_ids_for_product(). Reuse the constant, not the raw key.
532 update_term_meta( $term_id, FAQBuilder::GROUP_ALL_PRODUCTS_META, true );
533 // Make sure a re-run that switched a group to store-wide drops any stale
534 // per-category assignment.
535 delete_term_meta( $term_id, FAQBuilder::GROUP_PRODUCT_CATS_META );
536 return;
537 }
538
539 $this->assign_product_category( $term_id, $category, $taxonomy );
540 }
541
542 /**
543 * Attach a generated Product FAQ group to the WooCommerce product category it was
544 * written about (matched by name, which is what the AI was given).
545 *
546 * @param int $term_id The FAQ group term.
547 * @param array $category The generated category payload (carries `product_category`).
548 * @param string $taxonomy The FAQ taxonomy being built.
549 */
550 protected function assign_product_category( $term_id, array $category, $taxonomy ) {
551 if ( 'betterdocs_product_faq_category' !== $taxonomy || ! taxonomy_exists( 'product_cat' ) ) {
552 return;
553 }
554
555 // The AI echoes back the exact product category name it was given; the group's own
556 // name is the fallback (it's derived from that category, but the model may have
557 // prettified it — "Tshirts" → "T-Shirts with Logo"), so try both.
558 $candidates = [];
559 foreach ( [ $category['product_category'] ?? '', $category['name'] ?? '' ] as $candidate ) {
560 $candidate = trim( html_entity_decode( wp_strip_all_tags( (string) $candidate ), ENT_QUOTES, 'UTF-8' ) );
561 if ( '' !== $candidate ) {
562 $candidates[] = $candidate;
563 }
564 }
565 if ( empty( $candidates ) ) {
566 return;
567 }
568
569 $product_cat = null;
570 foreach ( $candidates as $candidate ) {
571 $product_cat = get_term_by( 'name', $candidate, 'product_cat' );
572 if ( ! $product_cat ) {
573 $product_cat = get_term_by( 'slug', sanitize_title( $candidate ), 'product_cat' );
574 }
575 if ( ! $product_cat ) {
576 $product_cat = $this->match_product_cat_loosely( $candidate );
577 }
578 if ( $product_cat && ! is_wp_error( $product_cat ) ) {
579 break;
580 }
581 }
582
583 if ( ! $product_cat || is_wp_error( $product_cat ) ) {
584 return;
585 }
586
587 update_term_meta( $term_id, '_betterdocs_faq_group_product_cats', [ (int) $product_cat->term_id ] );
588 }
589
590 /**
591 * Last-resort product-category match for a group name the model reworded ("T-Shirts
592 * with Logo" → "Tshirts", "Music & Audio" → "Music"). Compares on a letters-and-digits
593 * only, lower-cased key, then falls back to containment. Returns the term or null.
594 *
595 * A wrong assignment would publish an FAQ on the wrong product pages, so only an
596 * unambiguous single match is accepted.
597 *
598 * @return \WP_Term|null
599 */
600 protected function match_product_cat_loosely( $name ) {
601 $terms = get_terms( [ 'taxonomy' => 'product_cat', 'hide_empty' => false ] );
602 if ( is_wp_error( $terms ) || empty( $terms ) ) {
603 return null;
604 }
605
606 $key = function ( $value ) {
607 return preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $value ) );
608 };
609
610 $needle = $key( $name );
611 if ( '' === $needle ) {
612 return null;
613 }
614
615 $matches = [];
616 foreach ( $terms as $term ) {
617 $hay = $key( $term->name );
618 if ( '' === $hay ) {
619 continue;
620 }
621 if ( $hay === $needle || false !== strpos( $needle, $hay ) || false !== strpos( $hay, $needle ) ) {
622 $matches[] = $term;
623 }
624 }
625
626 // Ambiguous (e.g. "Clothing" also matching "Clothing Accessories") — assign nothing
627 // rather than the wrong category.
628 return 1 === count( $matches ) ? $matches[0] : null;
629 }
630
631 /**
632 * Convert the proxy's content HTML into clean Gutenberg block markup.
633 * Each top-level element becomes its matching core block; unknown nodes
634 * fall back to a paragraph. Empty input yields an empty paragraph block.
635 *
636 * @return string
637 */
638 protected function html_to_blocks( $html ) {
639 // Sanitize our OWN input rather than trusting the caller. node_to_block() emits
640 // $dom->saveHTML() verbatim for every known tag (p, h1-h6, ul, ol, blockquote,
641 // pre), so any attribute on those elements — including an onclick — is copied
642 // straight into post_content. Today the REST layer kses's the AI response before
643 // it ever gets here, so nothing leaks; this makes that a property of the method
644 // instead of a property of its one current caller. wp_kses_post() is idempotent,
645 // so the existing double-sanitization costs only a pass over the string, and it
646 // preserves the `#bd-link--slug` cross-link fragments the builder resolves.
647 $html = wp_kses_post( trim( (string) $html ) );
648 if ( '' === trim( $html ) ) {
649 return "<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->";
650 }
651
652 if ( ! class_exists( '\DOMDocument' ) ) {
653 return wp_kses_post( $html );
654 }
655
656 $dom = new \DOMDocument();
657 libxml_use_internal_errors( true );
658 $dom->loadHTML( '<?xml encoding="utf-8"?><div id="bd-root">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
659 libxml_clear_errors();
660
661 $root = $dom->getElementById( 'bd-root' );
662 if ( ! $root ) {
663 return wp_kses_post( $html );
664 }
665
666 $blocks = '';
667 foreach ( $root->childNodes as $node ) {
668 $blocks .= $this->node_to_block( $node, $dom );
669 }
670
671 return '' !== trim( $blocks ) ? $blocks : wp_kses_post( $html );
672 }
673
674 /**
675 * Map a single DOM node to a core block string.
676 *
677 * @return string
678 */
679 protected function node_to_block( \DOMNode $node, \DOMDocument $dom ) {
680 if ( XML_TEXT_NODE === $node->nodeType ) {
681 $text = trim( $node->textContent );
682 return '' === $text ? '' : "<!-- wp:paragraph --><p>" . esc_html( $text ) . "</p><!-- /wp:paragraph -->";
683 }
684
685 if ( XML_ELEMENT_NODE !== $node->nodeType ) {
686 return '';
687 }
688
689 $tag = strtolower( $node->nodeName );
690 $html = $dom->saveHTML( $node );
691
692 switch ( $tag ) {
693 case 'h1':
694 case 'h2':
695 case 'h3':
696 case 'h4':
697 case 'h5':
698 case 'h6':
699 $level = (int) substr( $tag, 1 );
700 return "<!-- wp:heading {\"level\":{$level}} -->{$html}<!-- /wp:heading -->";
701 case 'ul':
702 return "<!-- wp:list -->{$html}<!-- /wp:list -->";
703 case 'ol':
704 return "<!-- wp:list {\"ordered\":true} -->{$html}<!-- /wp:list -->";
705 case 'blockquote':
706 return "<!-- wp:quote -->{$html}<!-- /wp:quote -->";
707 case 'pre':
708 return "<!-- wp:preformatted -->{$html}<!-- /wp:preformatted -->";
709 case 'p':
710 return "<!-- wp:paragraph -->{$html}<!-- /wp:paragraph -->";
711 default:
712 return "<!-- wp:paragraph --><p>" . wp_kses_post( $node->textContent ) . "</p><!-- /wp:paragraph -->";
713 }
714 }
715
716 /**
717 * @return array|null
718 */
719 protected function map( $content_type ) {
720 return isset( self::MAP[ $content_type ] ) ? self::MAP[ $content_type ] : null;
721 }
722 }
723