PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.1
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.6.1, at includes/Core/SampleDocBuilder.php

347 lines 10.5 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 /**
33 * Create terms + posts from the (already sanitized) categories array.
34 *
35 * @param array $categories [{ name, description, articles:[{title,content_html,excerpt}] }]
36 * @param string $content_type 'docs' | 'faq'
37 * @return array|WP_Error Summary on success.
38 */
39 public function build( array $categories, $content_type = 'docs' ) {
40 $map = $this->map( $content_type );
41 if ( null === $map ) {
42 return new WP_Error( 'invalid_content_type', __( 'Unknown content type.', 'betterdocs' ) );
43 }
44
45 if ( empty( $categories ) ) {
46 return new WP_Error( 'no_categories', __( 'No categories to create.', 'betterdocs' ) );
47 }
48
49 // Idempotency guard: if sample content for this type already exists (e.g. a
50 // duplicate insert call, which the UI prevents but a direct REST call does
51 // not), return the existing summary instead of creating duplicate posts.
52 // Regeneration is expected to undo() first, which clears these.
53 $existing = $this->existing_sample( $map );
54 if ( $existing['categories'] > 0 || $existing['articles'] > 0 ) {
55 return array_merge( [ 'content_type' => $content_type, 'already_exists' => true ], $existing );
56 }
57
58 $created_terms = [];
59 $created_posts = [];
60
61 foreach ( $categories as $cat_index => $category ) {
62 if ( empty( $category['name'] ) ) {
63 continue;
64 }
65
66 $term_id = $this->ensure_term( $category, $map['taxonomy'] );
67 if ( is_wp_error( $term_id ) || ! $term_id ) {
68 continue;
69 }
70 $created_terms[] = $term_id;
71
72 $articles = isset( $category['articles'] ) && is_array( $category['articles'] ) ? $category['articles'] : [];
73 foreach ( array_values( $articles ) as $order => $article ) {
74 $title = is_array( $article ) ? ( $article['title'] ?? '' ) : (string) $article;
75 if ( '' === trim( $title ) ) {
76 continue;
77 }
78
79 $content = is_array( $article ) ? ( $article['content_html'] ?? '' ) : '';
80 $excerpt = is_array( $article ) ? ( $article['excerpt'] ?? '' ) : '';
81
82 $post_id = wp_insert_post(
83 [
84 'post_type' => $map['post_type'],
85 'post_title' => wp_strip_all_tags( $title ),
86 'post_content' => $this->html_to_blocks( $content ),
87 'post_excerpt' => sanitize_text_field( $excerpt ),
88 'post_status' => 'publish',
89 'menu_order' => $order,
90 ],
91 true
92 );
93
94 if ( is_wp_error( $post_id ) ) {
95 continue;
96 }
97
98 wp_set_object_terms( $post_id, [ (int) $term_id ], $map['taxonomy'] );
99 update_post_meta( $post_id, self::SAMPLE_META, 1 );
100 $created_posts[] = $post_id;
101 }
102 }
103
104 /**
105 * Fires after sample content is created (telemetry/integration hook).
106 *
107 * @param array $created_posts
108 * @param array $created_terms
109 * @param string $content_type
110 */
111 do_action( 'betterdocs_sample_docs_created', $created_posts, $created_terms, $content_type );
112
113 return [
114 'content_type' => $content_type,
115 'categories' => count( $created_terms ),
116 'articles' => count( $created_posts ),
117 'term_ids' => $created_terms,
118 'post_ids' => $created_posts,
119 ];
120 }
121
122 /**
123 * Remove exactly the sample content we created for a content type.
124 *
125 * @param string $content_type 'docs' | 'faq'
126 * @return array Counts removed.
127 */
128 public function undo( $content_type = 'docs' ) {
129 $map = $this->map( $content_type );
130 if ( null === $map ) {
131 return [ 'categories' => 0, 'articles' => 0 ];
132 }
133
134 // 1. Delete flagged posts. Scope by taxonomy as well as post type: General
135 // FAQs and Product FAQs share the `betterdocs_faq` post type, so the
136 // taxonomy is what keeps "undo" from removing the other scope's samples.
137 $posts = get_posts(
138 [
139 'post_type' => $map['post_type'],
140 'post_status' => 'any',
141 'posts_per_page' => -1,
142 'fields' => 'ids',
143 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
144 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
145 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
146 [
147 'taxonomy' => $map['taxonomy'],
148 'operator' => 'EXISTS',
149 ],
150 ],
151 ]
152 );
153 foreach ( $posts as $post_id ) {
154 wp_delete_post( $post_id, true );
155 }
156
157 // 2. Delete flagged terms.
158 $terms = get_terms(
159 [
160 'taxonomy' => $map['taxonomy'],
161 'hide_empty' => false,
162 'fields' => 'ids',
163 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
164 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
165 ]
166 );
167 $term_count = 0;
168 if ( ! is_wp_error( $terms ) ) {
169 foreach ( $terms as $term_id ) {
170 wp_delete_term( $term_id, $map['taxonomy'] );
171 $term_count++;
172 }
173 }
174
175 do_action( 'betterdocs_sample_docs_removed', $content_type );
176
177 return [
178 'content_type' => $content_type,
179 'categories' => $term_count,
180 'articles' => count( $posts ),
181 ];
182 }
183
184 /* --------------------------------------------------------------------- */
185
186 /**
187 * Collect the sample content already created for a content type, so build()
188 * stays idempotent against duplicate insert calls.
189 *
190 * @return array { categories, articles, term_ids, post_ids }
191 */
192 protected function existing_sample( array $map ) {
193 $post_ids = get_posts(
194 [
195 'post_type' => $map['post_type'],
196 'post_status' => 'any',
197 'posts_per_page' => -1,
198 'fields' => 'ids',
199 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
200 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
201 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
202 [
203 'taxonomy' => $map['taxonomy'],
204 'operator' => 'EXISTS',
205 ],
206 ],
207 ]
208 );
209
210 $term_ids = get_terms(
211 [
212 'taxonomy' => $map['taxonomy'],
213 'hide_empty' => false,
214 'fields' => 'ids',
215 'meta_key' => self::SAMPLE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
216 'meta_value' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
217 ]
218 );
219 $term_ids = is_wp_error( $term_ids ) ? [] : $term_ids;
220
221 return [
222 'categories' => count( $term_ids ),
223 'articles' => count( $post_ids ),
224 'term_ids' => array_map( 'intval', $term_ids ),
225 'post_ids' => array_map( 'intval', $post_ids ),
226 ];
227 }
228
229 /**
230 * Create the category term (or reuse an existing one) and flag it if new.
231 *
232 * @return int|WP_Error
233 */
234 protected function ensure_term( array $category, $taxonomy ) {
235 $name = sanitize_text_field( $category['name'] );
236 $existing = term_exists( $name, $taxonomy );
237
238 if ( $existing && ! empty( $existing['term_id'] ) ) {
239 return (int) $existing['term_id'];
240 }
241
242 $inserted = wp_insert_term(
243 $name,
244 $taxonomy,
245 [ 'description' => sanitize_text_field( $category['description'] ?? '' ) ]
246 );
247
248 if ( is_wp_error( $inserted ) ) {
249 return $inserted;
250 }
251
252 $term_id = (int) $inserted['term_id'];
253 update_term_meta( $term_id, self::SAMPLE_META, 1 );
254
255 // Do NOT flag sample product-FAQ groups as "show on all products". A
256 // product FAQ should only appear where the owner explicitly assigns it
257 // (by product or category); auto-assigning leaked generated FAQs onto
258 // every product page even when nothing was assigned.
259
260 return $term_id;
261 }
262
263 /**
264 * Convert the proxy's content HTML into clean Gutenberg block markup.
265 * Each top-level element becomes its matching core block; unknown nodes
266 * fall back to a paragraph. Empty input yields an empty paragraph block.
267 *
268 * @return string
269 */
270 protected function html_to_blocks( $html ) {
271 $html = trim( (string) $html );
272 if ( '' === $html ) {
273 return "<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->";
274 }
275
276 if ( ! class_exists( '\DOMDocument' ) ) {
277 return wp_kses_post( $html );
278 }
279
280 $dom = new \DOMDocument();
281 libxml_use_internal_errors( true );
282 $dom->loadHTML( '<?xml encoding="utf-8"?><div id="bd-root">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
283 libxml_clear_errors();
284
285 $root = $dom->getElementById( 'bd-root' );
286 if ( ! $root ) {
287 return wp_kses_post( $html );
288 }
289
290 $blocks = '';
291 foreach ( $root->childNodes as $node ) {
292 $blocks .= $this->node_to_block( $node, $dom );
293 }
294
295 return '' !== trim( $blocks ) ? $blocks : wp_kses_post( $html );
296 }
297
298 /**
299 * Map a single DOM node to a core block string.
300 *
301 * @return string
302 */
303 protected function node_to_block( \DOMNode $node, \DOMDocument $dom ) {
304 if ( XML_TEXT_NODE === $node->nodeType ) {
305 $text = trim( $node->textContent );
306 return '' === $text ? '' : "<!-- wp:paragraph --><p>" . esc_html( $text ) . "</p><!-- /wp:paragraph -->";
307 }
308
309 if ( XML_ELEMENT_NODE !== $node->nodeType ) {
310 return '';
311 }
312
313 $tag = strtolower( $node->nodeName );
314 $html = $dom->saveHTML( $node );
315
316 switch ( $tag ) {
317 case 'h1':
318 case 'h2':
319 case 'h3':
320 case 'h4':
321 case 'h5':
322 case 'h6':
323 $level = (int) substr( $tag, 1 );
324 return "<!-- wp:heading {\"level\":{$level}} -->{$html}<!-- /wp:heading -->";
325 case 'ul':
326 return "<!-- wp:list -->{$html}<!-- /wp:list -->";
327 case 'ol':
328 return "<!-- wp:list {\"ordered\":true} -->{$html}<!-- /wp:list -->";
329 case 'blockquote':
330 return "<!-- wp:quote -->{$html}<!-- /wp:quote -->";
331 case 'pre':
332 return "<!-- wp:preformatted -->{$html}<!-- /wp:preformatted -->";
333 case 'p':
334 return "<!-- wp:paragraph -->{$html}<!-- /wp:paragraph -->";
335 default:
336 return "<!-- wp:paragraph --><p>" . wp_kses_post( $node->textContent ) . "</p><!-- /wp:paragraph -->";
337 }
338 }
339
340 /**
341 * @return array|null
342 */
343 protected function map( $content_type ) {
344 return isset( self::MAP[ $content_type ] ) ? self::MAP[ $content_type ] : null;
345 }
346 }
347