PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.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 / REST / SampleDocs.php

SampleDocs.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.6.2, at includes/REST/SampleDocs.php

1,199 lines 46.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\REST;
4
5 use WP_REST_Request;
6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 use WPDeveloper\BetterDocs\Core\SiteProfiler;
8 use WPDeveloper\BetterDocs\Core\Settings;
9 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
10
11 /**
12 * REST surface for the AI "Generate Sample Docs" feature (Phase 2).
13 *
14 * Routes (namespace betterdocs/v1):
15 * POST sample-docs/detect → SiteProfiler profile + locally suggested categories
16 * POST sample-docs/generate → signs + forwards to the hosted proxy, returns articles
17 * POST sample-docs/insert → DocBuilder creates the terms/posts (Phase 4)
18 * POST sample-docs/undo → DocBuilder removes the flagged sample content (Phase 4)
19 *
20 * @since 4.5.3
21 */
22 class SampleDocs extends BaseAPI {
23 /** Hard caps mirrored on the proxy (Phase 0 contract) — legacy single-call docs. */
24 const MAX_CATEGORIES = 3;
25 const MAX_ARTICLES_PER_CATEGORY = 3;
26
27 /**
28 * FAQ caps — mirrored on the proxy. Upper bounds only: the AI designs the groups
29 * and the questions from the site's real content and right-sizes below these. The
30 * ceiling is generous (6 groups / 18 questions) so a content-rich site can get a
31 * real FAQ, but the AI is told to produce only as many as the content warrants and
32 * never to pad to the cap — a small site still gets a small FAQ.
33 */
34 const MAX_FAQ_CATEGORIES = 6;
35 const MAX_FAQ_ARTICLES_PER_CATEGORY = 3;
36 const MAX_FAQ_ARTICLES_TOTAL = 18;
37
38 /**
39 * Product FAQ caps. Higher than the general FAQ on purpose: this tab writes ONE group
40 * per real product category (and attaches it to that category), so the group cap has
41 * to cover every category the store actually has — with a smaller group cap, a
42 * store with many product categories simply lost half of them.
43 */
44 const MAX_PRODUCT_FAQ_CATEGORIES = 12;
45 const MAX_PRODUCT_FAQ_ARTICLES_TOTAL = 36;
46
47 /**
48 * How many detected site subjects (doc categories, key pages, product categories)
49 * to offer as FAQ scope chips. They are topics to cover, NOT groups — the AI decides
50 * how many groups a set of topics warrants (folding several into one, or dropping a
51 * thin one) and produces at most MAX_FAQ_CATEGORIES groups of its own design.
52 */
53 const MAX_FAQ_TOPICS = 6;
54
55 /** Product-category chips offered on the WooCommerce tab (one group is written per kept chip). */
56 const MAX_PRODUCT_FAQ_TOPICS = 12;
57
58 /**
59 * Deep "full knowledge base" caps (docs only) — mirrored on the proxy. These are
60 * upper bounds only: the AI right-sizes the KB to what the site actually needs and
61 * may return fewer.
62 */
63 const MAX_KB_CATEGORIES = 6;
64 const MAX_KB_ARTICLES_PER_CATEGORY = 4;
65 const MAX_KB_ARTICLES_TOTAL = 18;
66
67 /** Default per-category palette/icons (matches the design mockup). */
68 const PALETTE = [ '#00B884', '#3B82F6', '#8B5CF6', '#F59E0B', '#0EA5E9', '#EF4444' ];
69
70 /**
71 * @var SiteProfiler
72 */
73 protected $profiler;
74
75 public function __construct( Settings $settings, Container $container, SiteProfiler $profiler ) {
76 parent::__construct( $settings, $container );
77 $this->profiler = $profiler;
78 }
79
80 public function permission_check(): bool {
81 return current_user_can( 'edit_docs_settings' );
82 }
83
84 public function register() {
85 $this->post( 'sample-docs/detect', [ $this, 'detect' ] );
86 $this->post( 'sample-docs/generate', [ $this, 'generate' ] );
87 // Deep KB (docs) — the multi-call outline→expand flow.
88 $this->post( 'sample-docs/outline', [ $this, 'outline' ] );
89 $this->post( 'sample-docs/article', [ $this, 'article' ] );
90 $this->post( 'sample-docs/insert', [ $this, 'insert' ] );
91 $this->post( 'sample-docs/undo', [ $this, 'undo' ] );
92 }
93
94 /**
95 * Detection step — returns the site profile and locally-derived suggested
96 * categories (no AI). Feeds the design's "detecting" + "detected profile" screens.
97 */
98 public function detect( WP_REST_Request $request ) {
99 if ( ! $this->is_enabled() ) {
100 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
101 }
102
103 $content_type = $this->content_type( $request );
104
105 // Every tab now reads its topic chips off the site's REAL content — the General FAQ
106 // and docs tabs from doc categories + key pages, the WooCommerce tab from product
107 // categories — so detect() needs the content digest for all of them. It's cached
108 // separately (and the docs outline call reuses the same cache), so this is one scan
109 // per site, not one per click.
110 $profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true );
111
112 /**
113 * Telemetry: site detection ran.
114 *
115 * @param string $type Detected site type.
116 * @param string $content_type docs|faq
117 */
118 do_action( 'betterdocs_sample_docs_detected', $profile['type'] ?? 'general', $content_type );
119
120 return $this->success(
121 [
122 'profile' => $profile,
123 'categories' => $this->suggested_categories( $profile, $content_type ),
124 ]
125 );
126 }
127
128 /**
129 * Generation step — signs the request and forwards it to the hosted proxy,
130 * which calls OpenAI and returns structured categories + articles.
131 */
132 public function generate( WP_REST_Request $request ) {
133 if ( ! $this->is_enabled() ) {
134 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
135 }
136
137 $content_type = $this->content_type( $request );
138 $profile = (array) $request->get_param( 'profile' );
139 // For a general FAQ the incoming list is the owner's kept TOPICS (scope), not the
140 // groups to produce — so it is capped at MAX_FAQ_TOPICS, which is separate from the
141 // group cap. The AI decides how many of at most MAX_FAQ_CATEGORIES groups those
142 // topics warrant, folding several into one or dropping a thin one.
143 $categories = $this->sanitize_categories(
144 (array) $request->get_param( 'categories' ),
145 false,
146 'docs' === $content_type
147 ? $this->max_categories( $content_type )
148 : ( 'product_faq' === $content_type ? self::MAX_PRODUCT_FAQ_TOPICS : self::MAX_FAQ_TOPICS ),
149 $this->max_articles( $content_type )
150 );
151
152 if ( empty( $profile ) ) {
153 $profile = $this->profiler->build();
154 }
155
156 // Product FAQs used to be generated deterministically from the store's WooCommerce
157 // SETTINGS (StoreFaqContent) — which produced generic store policy (Payments,
158 // Shipping, Returns) and said nothing about what the store actually sells. They
159 // now go through the AI like every other type, grounded in the real products and
160 // product categories from the content digest.
161
162 // FAQ / product FAQ (and the legacy docs fallback) are grounded in the site's REAL
163 // content —
164 // the same homepage/About/page/post/product excerpts the deep docs flow uses —
165 // so questions and answers are specific to what the site offers, not generic.
166 // Enrich the profile when the client didn't send the content digest.
167 if ( empty( $profile['content'] ) ) {
168 $profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true );
169 }
170 if ( empty( $categories ) ) {
171 $categories = $this->suggested_categories( $profile, $content_type );
172 }
173
174 // The store-wide "Shipping, Returns & Payments" group is offered on the WooCommerce
175 // profile screen as a chip so the owner can see (and remove) it. It is NOT a product
176 // category, so pull it out of what goes to the AI, and remember whether the owner
177 // kept it — the deterministic group is only prepended below if they did.
178 $include_store_wide = false;
179 if ( 'product_faq' === $content_type ) {
180 $kept = [];
181 foreach ( $categories as $cat ) {
182 if ( ! empty( $cat['all_products'] ) ) {
183 $include_store_wide = true;
184 } else {
185 $kept[] = $cat;
186 }
187 }
188 $categories = $kept;
189 }
190
191 // Subjects the owner removed on the profile screen must be skipped, not merely
192 // left out of the hint list — the AI sees the whole content digest and would
193 // otherwise write about them anyway.
194 $scope = $this->topic_scope( $profile, $content_type, $categories );
195
196 $payload = [
197 'profile' => $profile,
198 'categories' => $categories,
199 'options' => [
200 'content_type' => $content_type,
201 'maxCategories' => $this->max_categories( $content_type ),
202 'maxArticlesPerCategory' => $this->max_articles( $content_type ),
203 'locale' => isset( $profile['site']['locale'] ) ? $profile['site']['locale'] : get_locale(),
204 // Optional one-line steer from the owner ("what should these FAQs cover?").
205 'intent' => sanitize_text_field( (string) $request->get_param( 'intent' ) ),
206 'exclude_topics' => $scope['exclude'],
207 ],
208 ];
209
210 $response = $this->call_proxy( $payload, $content_type );
211
212 if ( is_wp_error( $response ) ) {
213 $code = $response->get_error_code();
214
215 /**
216 * Telemetry: generation failed (e.g. quota_exceeded, proxy_error).
217 *
218 * @param string $content_type docs|faq
219 * @param string $code Error code.
220 */
221 do_action( 'betterdocs_sample_docs_generation_failed', $content_type, $code );
222
223 // NOTE: no store-policy fallback for the Product tab. StoreFaqContent generates
224 // generic store policy (Payments/Shipping/Returns), which is exactly what the
225 // product FAQ was fixed to stop producing — silently serving it on a proxy
226 // outage would just reintroduce the bug under a different trigger. Surface the
227 // error instead and let the user retry.
228
229 // Typed errors the UI maps to the quota / fallback screens.
230 $data = $response->get_error_data();
231 $status = is_array( $data ) && isset( $data['status'] ) ? $data['status'] : 502;
232
233 return $this->error( $code, $response->get_error_message(), $status, [ 'fallback' => 'static' ] );
234 }
235
236 /**
237 * Telemetry: generation succeeded.
238 *
239 * @param string $content_type docs|faq
240 * @param int $count Number of categories returned.
241 */
242 $categories = $response['categories'];
243
244 // Layer 1 (WooCommerce only): prepend ONE deterministic, settings-grounded
245 // store-wide group (payments/shipping/returns/orders) flagged to show on every
246 // product page. The AI writes the per-category product groups (Layer 2); the
247 // store-wide policy answers come from the real Woo settings, not the AI, so they
248 // can never invent a return window or gateway the store doesn't have.
249 if ( 'product_faq' === $content_type && $include_store_wide ) {
250 $store_wide = $this->store_wide_group( $profile );
251 if ( ! empty( $store_wide ) ) {
252 array_unshift( $categories, $store_wide );
253 }
254 }
255
256 do_action( 'betterdocs_sample_docs_generated', $content_type, count( $categories ) );
257
258 return $this->success(
259 [
260 'content_type' => $content_type,
261 'categories' => $categories,
262 'meta' => isset( $response['meta'] ) ? $response['meta'] : [],
263 ]
264 );
265 }
266
267 /**
268 * The consolidated store-wide product FAQ group (Layer 1), tagged so the builder
269 * flags it "show on all products". Built deterministically from the store's real
270 * WooCommerce settings via StoreFaqContent — no AI, no hallucinated policy.
271 *
272 * @return array|null A sanitized category array with `all_products => true`, or null.
273 */
274 protected function store_wide_group( array $profile ) {
275 $store = $this->store_faq();
276 if ( null === $store ) {
277 return null;
278 }
279
280 $group = $store->generate_consolidated( $profile );
281 if ( empty( $group['articles'] ) ) {
282 return null;
283 }
284
285 // Reuse the standard sanitizer (one group, its own question count — not the
286 // per-category caps), then tag it for the all-products routing in the builder.
287 $clean = $this->sanitize_categories( [ $group ], true, 1, count( $group['articles'] ) );
288 if ( empty( $clean[0] ) ) {
289 return null;
290 }
291
292 $clean[0]['all_products'] = true;
293 return $clean[0];
294 }
295
296 /**
297 * Lazily resolve the deterministic store-FAQ generator (Layer 1 answers).
298 *
299 * @return \WPDeveloper\BetterDocs\Core\StoreFaqContent|null
300 */
301 protected function store_faq() {
302 $class = 'WPDeveloper\\BetterDocs\\Core\\StoreFaqContent';
303 if ( ! class_exists( $class ) ) {
304 return null;
305 }
306 return $this->container->get( $class );
307 }
308
309 /**
310 * Deep KB step 1 — design the whole knowledge base. Sends the content-enriched
311 * site profile to the proxy's /outline endpoint and returns the information
312 * architecture + a job_token the article step reuses.
313 */
314 public function outline( WP_REST_Request $request ) {
315 if ( ! $this->is_enabled() ) {
316 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
317 }
318
319 // The deep flow is documentation-only; FAQ/product_faq keep the single-call path.
320 $profile = (array) $request->get_param( 'profile' );
321 if ( empty( $profile ) || empty( $profile['content'] ) ) {
322 // Enrich with the real content digest the outline call needs.
323 $profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true );
324 }
325
326 $intent = sanitize_text_field( (string) $request->get_param( 'intent' ) );
327
328 // Honor the topics the owner kept on the profile screen. The outline call used to
329 // send NO categories at all, so removing a chip did nothing to the generated KB —
330 // the deep flow silently lost the "skip this category" behaviour the single-call
331 // flow had. Both the kept and the REMOVED subjects go to the proxy: naming what to
332 // skip is what actually keeps it out, since the AI still sees the whole content
333 // digest and would otherwise design that category right back in.
334 $scope = $this->topic_scope( $profile, 'docs', (array) $request->get_param( 'categories' ) );
335
336 $payload = [
337 'profile' => $profile,
338 'options' => [
339 'content_type' => 'docs',
340 'locale' => isset( $profile['site']['locale'] ) ? $profile['site']['locale'] : get_locale(),
341 // Upper bounds only — the proxy prompt tells the AI to right-size the KB
342 // to what the site genuinely needs and return fewer when appropriate.
343 'max_categories' => self::MAX_KB_CATEGORIES,
344 'max_articles' => self::MAX_KB_ARTICLES_TOTAL,
345 'intent' => $intent,
346 'topics' => $scope['include'],
347 'exclude_topics' => $scope['exclude'],
348 ],
349 ];
350
351 $parsed = $this->proxy_request( 'outline', $payload, 30 );
352 if ( is_wp_error( $parsed ) ) {
353 $data = $parsed->get_error_data();
354 $http = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
355 $upstream = is_array( $data ) && isset( $data['upstream'] ) ? (int) $data['upstream'] : $http;
356 do_action( 'betterdocs_sample_docs_generation_failed', 'docs', $parsed->get_error_code() );
357 return $this->error( $parsed->get_error_code(), $parsed->get_error_message(), $http, [ 'fallback' => 'static', 'proxy_status' => $upstream ] );
358 }
359
360 if ( empty( $parsed['outline']['categories'] ) || empty( $parsed['job_token'] ) ) {
361 return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 );
362 }
363
364 $outline = $this->sanitize_outline( $parsed['outline'] );
365
366 do_action( 'betterdocs_sample_docs_generated', 'docs', count( $outline['categories'] ) );
367
368 return $this->success(
369 [
370 'content_type' => 'docs',
371 'job_token' => sanitize_text_field( (string) $parsed['job_token'] ),
372 'outline' => $outline,
373 'meta' => isset( $parsed['meta'] ) ? $parsed['meta'] : [],
374 ]
375 );
376 }
377
378 /**
379 * Deep KB step 2 — expand one article of a previously issued outline. Thin pass-
380 * through to the proxy's /article endpoint; the React flow loops it per index.
381 */
382 public function article( WP_REST_Request $request ) {
383 if ( ! $this->is_enabled() ) {
384 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
385 }
386
387 $job_token = sanitize_text_field( (string) $request->get_param( 'job_token' ) );
388 $index = (int) $request->get_param( 'index' );
389
390 if ( '' === $job_token || $index < 0 ) {
391 return $this->error( 'bad_request', __( 'A job token and article index are required.', 'betterdocs' ), 400 );
392 }
393
394 $parsed = $this->proxy_request( 'article', [ 'job_token' => $job_token, 'index' => $index ], 30 );
395 if ( is_wp_error( $parsed ) ) {
396 $data = $parsed->get_error_data();
397 $http = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
398 $upstream = is_array( $data ) && isset( $data['upstream'] ) ? (int) $data['upstream'] : $http;
399 return $this->error( $parsed->get_error_code(), $parsed->get_error_message(), $http, [ 'proxy_status' => $upstream ] );
400 }
401
402 if ( empty( $parsed['article']['content_html'] ) ) {
403 return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 );
404 }
405
406 return $this->success(
407 [
408 'index' => isset( $parsed['index'] ) ? (int) $parsed['index'] : $index,
409 'article' => $this->sanitize_kb_article( $parsed['article'] ),
410 'meta' => isset( $parsed['meta'] ) ? $parsed['meta'] : [],
411 ]
412 );
413 }
414
415 /**
416 * Insert step — DocBuilder creates the terms/posts. Wired in Phase 4.
417 */
418 public function insert( WP_REST_Request $request ) {
419 if ( ! $this->is_enabled() ) {
420 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
421 }
422
423 $builder = $this->builder();
424 if ( null === $builder ) {
425 return $this->error( 'not_implemented', __( 'Inserting sample docs is not available yet.', 'betterdocs' ), 501 );
426 }
427
428 $content_type = $this->content_type( $request );
429
430 $raw = (array) $request->get_param( 'categories' );
431
432 // Docs come from the deep outline→expand flow (up to 8 categories × 6 articles);
433 // the 3×3 sanitizer would silently truncate them. FAQ/product_faq keep the caps.
434 if ( 'docs' === $content_type ) {
435 $categories = $this->sanitize_kb_categories( $raw );
436 } elseif ( 'product_faq' === $content_type ) {
437 // The store-wide group (Layer 1) is one extra group on top of the per-category
438 // cap, so it must not count against it — split it out, cap the per-category
439 // groups, then re-attach it. Otherwise the last product category is dropped.
440 $store_wide = [];
441 $per_cat = [];
442 foreach ( $raw as $cat ) {
443 if ( ! empty( $cat['all_products'] ) ) {
444 $store_wide[] = $cat;
445 } else {
446 $per_cat[] = $cat;
447 }
448 }
449 $categories = $this->sanitize_categories(
450 $per_cat,
451 true,
452 $this->max_categories( $content_type ),
453 $this->max_articles( $content_type ),
454 $this->max_articles_total( $content_type )
455 );
456 if ( ! empty( $store_wide[0] ) ) {
457 $clean = $this->sanitize_categories( [ $store_wide[0] ], true, 1, count( (array) ( $store_wide[0]['articles'] ?? [] ) ) );
458 if ( ! empty( $clean[0] ) ) {
459 $clean[0]['all_products'] = true;
460 array_unshift( $categories, $clean[0] );
461 }
462 }
463 } else {
464 $categories = $this->sanitize_categories(
465 $raw,
466 true,
467 $this->max_categories( $content_type ),
468 $this->max_articles( $content_type ),
469 $this->max_articles_total( $content_type )
470 );
471 }
472
473 $result = $builder->build( $categories, $content_type );
474
475 if ( is_wp_error( $result ) ) {
476 return $this->error( $result->get_error_code(), $result->get_error_message(), 400 );
477 }
478
479 return $this->success( $result );
480 }
481
482 /**
483 * Undo step — remove exactly the sample content we created. Wired in Phase 4.
484 */
485 public function undo( WP_REST_Request $request ) {
486 if ( ! $this->is_enabled() ) {
487 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
488 }
489
490 $builder = $this->builder();
491 if ( null === $builder ) {
492 return $this->error( 'not_implemented', __( 'Removing sample docs is not available yet.', 'betterdocs' ), 501 );
493 }
494
495 $content_type = $this->content_type( $request );
496 return $this->success( $builder->undo( $content_type ) );
497 }
498
499 /**
500 * Lazily resolve the DocBuilder (added in Phase 4) so this class loads even
501 * before the builder exists.
502 *
503 * @return \WPDeveloper\BetterDocs\Core\SampleDocBuilder|null
504 */
505 protected function builder() {
506 $class = 'WPDeveloper\\BetterDocs\\Core\\SampleDocBuilder';
507 if ( ! class_exists( $class ) ) {
508 return null;
509 }
510 return $this->container->get( $class );
511 }
512
513 /* --------------------------------------------------------------------- */
514 /* Proxy plumbing */
515 /* --------------------------------------------------------------------- */
516
517 /**
518 * Legacy single-call generation — sign + POST to the proxy and validate the
519 * { categories, meta } shape (used by the FAQ path).
520 *
521 * @return array|\WP_Error Parsed { categories, meta } on success.
522 */
523 protected function call_proxy( array $payload, $content_type = 'docs' ) {
524 $parsed = $this->proxy_request( '', $payload );
525 if ( is_wp_error( $parsed ) ) {
526 return $parsed;
527 }
528
529 if ( empty( $parsed['categories'] ) || ! is_array( $parsed['categories'] ) ) {
530 return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 );
531 }
532
533 // Enforce caps + sanitize defensively on our side too — with THIS content type's
534 // caps. Sanitizing an FAQ with the docs 3x3 defaults would silently throw away
535 // every group and question the proxy right-sized beyond the third.
536 $parsed['categories'] = $this->sanitize_categories(
537 $parsed['categories'],
538 true,
539 $this->max_categories( $content_type ),
540 $this->max_articles( $content_type ),
541 $this->max_articles_total( $content_type )
542 );
543
544 return $parsed;
545 }
546
547 /**
548 * Sign + POST a payload to a hosted-proxy action ('' = legacy generate, 'outline',
549 * 'article'), with one retry on transport failure. Returns the parsed JSON array on
550 * a 2xx response, or a typed WP_Error otherwise. Shape validation is the caller's job.
551 *
552 * @param string $action Sub-path under v1/sample-docs ('' | 'outline' | 'article').
553 * @param array $payload Request body.
554 * @param int $timeout_base Base HTTP timeout in seconds (per single OpenAI call).
555 * @return array|\WP_Error
556 */
557 protected function proxy_request( $action, array $payload, $timeout_base = 20 ) {
558 $url = $this->proxy_endpoint( $action );
559 $secret = $this->proxy_secret();
560 $body = wp_json_encode( $payload );
561 $timeout = $this->request_timeout( $timeout_base );
562
563 $args = [
564 'timeout' => $timeout,
565 'headers' => [
566 'Content-Type' => 'application/json',
567 'Accept' => 'application/json',
568 'X-BetterDocs-Site' => esc_url_raw( home_url() ),
569 'X-BetterDocs-License' => $this->license_key(),
570 'X-BetterDocs-Signature' => hash_hmac( 'sha256', $body, $secret ),
571 ],
572 'body' => $body,
573 ];
574
575 $attempts = 0;
576 $response = null;
577 while ( $attempts < 2 ) {
578 $attempts++;
579
580 // Give this attempt a fresh execution budget: without it a hung upstream
581 // trips PHP's max_execution_time mid-cURL and the route dies with a raw
582 // 500 critical error instead of the typed JSON the modal understands.
583 $reset = function_exists( 'set_time_limit' ) && @set_time_limit( $timeout + 15 );
584
585 $response = wp_remote_post( $url, $args );
586
587 // Retry ONLY on a genuine transport failure (no HTTP response). A 5xx may
588 // mean the proxy already called OpenAI and spent tokens, so re-POSTing
589 // would risk double-billing — treat any received status as final.
590 if ( ! is_wp_error( $response ) ) {
591 break;
592 }
593
594 // If the time limit could not be reset (disabled by the host), a second
595 // full-length attempt could still fatal mid-cURL — surface the transport
596 // error instead of risking the retry.
597 if ( ! $reset ) {
598 break;
599 }
600 }
601
602 if ( is_wp_error( $response ) ) {
603 return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502, [ 'upstream' => 0 ] );
604 }
605
606 $status = wp_remote_retrieve_response_code( $response );
607 $parsed = json_decode( wp_remote_retrieve_body( $response ), true );
608
609 if ( 429 === $status || ( isset( $parsed['status'] ) && 'quota_exceeded' === $parsed['status'] ) ) {
610 return $this->error( 'quota_exceeded', __( 'You have used your free AI generation for this site.', 'betterdocs' ), 429, [ 'upstream' => 429 ] );
611 }
612
613 if ( $status >= 400 || ! is_array( $parsed ) ) {
614 $message = isset( $parsed['message'] ) ? $parsed['message'] : __( 'The AI service returned an unexpected response.', 'betterdocs' );
615 // Preserve the real upstream status under a distinct key so callers (and the
616 // UI) can special-case e.g. 404 (old proxy → classic fallback) or 410 (job
617 // expired → regenerate); error() itself overwrites data['status'] with $status.
618 return $this->error( 'proxy_error', $message, 502, [ 'upstream' => $status ? $status : 502 ] );
619 }
620
621 return $parsed;
622 }
623
624 /**
625 * HTTP timeout (seconds) for a proxy call, kept safely below PHP's
626 * max_execution_time so a hung upstream returns a clean WP_Error (typed
627 * JSON + static fallback in the wizard) instead of fataling mid-cURL.
628 *
629 * @param int $base Base timeout in seconds for a single OpenAI call.
630 * @return int
631 */
632 protected function request_timeout( $base = 20 ) {
633 $timeout = max( 5, (int) $base );
634 $max_exec = (int) ini_get( 'max_execution_time' );
635
636 if ( $max_exec > 0 ) {
637 $timeout = min( $timeout, max( 5, $max_exec - 10 ) );
638 }
639
640 /** Filter the HTTP timeout (seconds) for hosted AI proxy requests. */
641 return (int) apply_filters( 'betterdocs_ai_proxy_timeout', $timeout );
642 }
643
644 /**
645 * Full proxy endpoint URL for an action ('' | 'outline' | 'article').
646 *
647 * @return string
648 */
649 protected function proxy_endpoint( $action = '' ) {
650 $base = get_option( 'betterdocs_ai_proxy_url', 'https://api.betterdocs.co/ai' );
651 /** Filter the hosted proxy base URL. */
652 $base = apply_filters( 'betterdocs_ai_proxy_url', $base );
653 $url = trailingslashit( $base ) . 'v1/sample-docs';
654 return '' !== $action ? $url . '/' . ltrim( (string) $action, '/' ) : $url;
655 }
656
657 protected function proxy_secret() {
658 $secret = get_option( 'betterdocs_ai_proxy_secret', 'betterdocs-local-dev-secret' );
659 /** Filter the HMAC shared secret used to sign proxy requests. */
660 return apply_filters( 'betterdocs_ai_proxy_secret', $secret );
661 }
662
663 protected function license_key() {
664 /** Filter the license key sent to the proxy for per-site identity. */
665 return apply_filters( 'betterdocs_ai_proxy_license', (string) get_option( 'betterdocs_pro_licenses', '' ) );
666 }
667
668 /* --------------------------------------------------------------------- */
669 /* Helpers */
670 /* --------------------------------------------------------------------- */
671
672 protected function is_enabled() {
673 return (bool) $this->settings->get( 'enable_ai_sample_docs', true );
674 }
675
676 protected function content_type( WP_REST_Request $request ) {
677 $type = $request->get_param( 'content_type' );
678 if ( 'faq' === $type || 'product_faq' === $type ) {
679 return $type;
680 }
681 return 'docs';
682 }
683
684 /**
685 * Per-content-type category cap. Product FAQs cover the four store-ops groups
686 * (Payments & Billing, Shipping & Delivery, Returns & Refunds, Orders & Account);
687 * a general FAQ is right-sized by the AI up to the FAQ cap; docs keep the legacy
688 * single-call cap (the real KB goes through outline→expand).
689 *
690 * @return int
691 */
692 protected function max_categories( $content_type ) {
693 if ( 'product_faq' === $content_type ) {
694 return self::MAX_PRODUCT_FAQ_CATEGORIES;
695 }
696 return 'docs' === $content_type ? self::MAX_CATEGORIES : self::MAX_FAQ_CATEGORIES;
697 }
698
699 /**
700 * Per-content-type cap on entries within one category/group.
701 *
702 * @return int
703 */
704 protected function max_articles( $content_type ) {
705 return 'docs' === $content_type ? self::MAX_ARTICLES_PER_CATEGORY : self::MAX_FAQ_ARTICLES_PER_CATEGORY;
706 }
707
708 /**
709 * Per-content-type cap on total entries (0 = no separate total cap).
710 *
711 * @return int
712 */
713 protected function max_articles_total( $content_type ) {
714 if ( 'product_faq' === $content_type ) {
715 return self::MAX_PRODUCT_FAQ_ARTICLES_TOTAL;
716 }
717 return 'docs' === $content_type ? 0 : self::MAX_FAQ_ARTICLES_TOTAL;
718 }
719
720 /**
721 * Deterministic, type-aware suggested categories (no AI) for the profile screen.
722 *
723 * @return array
724 */
725 protected function suggested_categories( array $profile, $content_type ) {
726 // WooCommerce Product FAQ: the chips are the store's REAL product categories —
727 // this tab writes FAQs about what the store SELLS. (It used to show the canned
728 // store-policy groups from StoreFaqContent: Payments, Shipping, Returns…)
729 if ( 'product_faq' === $content_type ) {
730 return $this->product_topics( $profile );
731 }
732
733 // General FAQ AND docs both show the SUBJECTS actually detected on the site (doc
734 // categories, key pages) as scope chips — no canned lists. Docs used to show a
735 // hardcoded preset structure ("Getting Started / Shipping & Delivery / Product
736 // Guides") with placeholder article titles, which the profile screen counted as a
737 // fixed "3 categories, 9 docs". That count was always misleading: the deep
738 // outline flow designs and right-sizes the REAL knowledge base from the site
739 // content, so the preset numbers never matched what got generated. The owner
740 // prunes the detected subjects to scope generation; the proxy designs the rest.
741 return $this->detected_topics( $profile );
742 }
743
744 /**
745 * Subjects actually detected ON the site, for the GENERAL FAQ profile screen — doc
746 * categories first (what the site already documents is what people ask about), then
747 * real key pages (Pricing, Security, Integrations…). These are SUBJECTS, not FAQ
748 * group names: the AI designs the groups, the questions and the answers itself.
749 * Pruning a chip scopes the FAQ.
750 *
751 * Product categories are NOT included: they are the WooCommerce tab's material
752 * (see product_topics()), and a general site FAQ has no business offering to write
753 * about "Hoodies".
754 *
755 * Returns [] on a site with no usable content — the AI then works from the site
756 * profile alone rather than from a canned list.
757 *
758 * @return array
759 */
760 /**
761 * Subjects for the WooCommerce (Product FAQ) tab: what the store actually SELLS.
762 *
763 * Real product categories first (they map 1:1 onto the FAQ group the AI designs, so
764 * the group can then be assigned to that product category and show on those product
765 * pages), falling back to product names on a store with no categories. Never store
766 * policy — a shopper on a product page asks about the product.
767 *
768 * @return array
769 */
770 protected function product_topics( array $profile ) {
771 $woo = isset( $profile['woocommerce'] ) && is_array( $profile['woocommerce'] ) ? $profile['woocommerce'] : [];
772 $content = isset( $profile['content'] ) && is_array( $profile['content'] ) ? $profile['content'] : [];
773
774 $names = [];
775
776 if ( ! empty( $woo['product_categories'] ) ) {
777 $names = array_map( 'strval', (array) $woo['product_categories'] );
778 }
779
780 // No product categories (a small store selling a handful of items): fall back to
781 // the products themselves.
782 if ( empty( $names ) ) {
783 if ( ! empty( $content['products'] ) && is_array( $content['products'] ) ) {
784 foreach ( $content['products'] as $product ) {
785 if ( ! empty( $product['title'] ) ) {
786 $names[] = (string) $product['title'];
787 }
788 }
789 } elseif ( ! empty( $woo['sample_products'] ) ) {
790 $names = array_map( 'strval', (array) $woo['sample_products'] );
791 }
792 }
793
794 $chips = $this->topic_chips( $names, self::MAX_PRODUCT_FAQ_TOPICS );
795
796 // Offer the always-available store-wide group as the FIRST chip, so the owner can
797 // see — and, by removing it, opt out of — the deterministic "Shipping, Returns &
798 // Payments" group that shows on every product page. It is NOT a product category;
799 // the `all_products` flag tells generate() to route it to the store-wide layer
800 // rather than the AI.
801 $store = $this->store_faq();
802 if ( $store ) {
803 $def = $store->consolidated_definition();
804 array_unshift(
805 $chips,
806 [
807 'id' => 'sd_storewide',
808 'name' => $def['name'],
809 'icon' => isset( $def['icon'] ) ? sanitize_key( $def['icon'] ) : 'truck',
810 'color' => self::PALETTE[0],
811 'all_products' => true,
812 // Real question titles so the profile screen shows a count.
813 'articles' => array_values( (array) $def['questions'] ),
814 ]
815 );
816 }
817
818 return $chips;
819 }
820
821 protected function detected_topics( array $profile ) {
822 $topics = isset( $profile['topics'] ) && is_array( $profile['topics'] ) ? $profile['topics'] : [];
823 $content = isset( $profile['content'] ) && is_array( $profile['content'] ) ? $profile['content'] : [];
824
825 $names = [];
826
827 // 1. What the site already documents.
828 if ( ! empty( $topics['doc_categories'] ) ) {
829 $names = array_merge( $names, array_map( 'strval', (array) $topics['doc_categories'] ) );
830 }
831
832 // 2. Real key pages — minus the boilerplate every WP site has, which nobody
833 // writes an FAQ group about.
834 if ( ! empty( $content['pages'] ) && is_array( $content['pages'] ) ) {
835 foreach ( $content['pages'] as $page ) {
836 $title = isset( $page['title'] ) ? (string) $page['title'] : '';
837 if ( '' !== $title && ! $this->is_boilerplate_page( $title ) ) {
838 $names[] = $title;
839 }
840 }
841 }
842
843 // NOTE: product categories are deliberately NOT topics here. They belong to the
844 // WooCommerce (Product FAQ) tab, which is generated per product category — pulling
845 // them into the General FAQ made a general site FAQ offer to write about
846 // "Clothing", "Hoodies", "Music".
847
848 return $this->topic_chips( $names );
849 }
850
851 /**
852 * Turn a raw list of subject names into profile-screen chips: de-duped
853 * case-insensitively (first spelling wins) and bounded to MAX_FAQ_TOPICS.
854 *
855 * @return array
856 */
857 protected function topic_chips( array $names, $max = self::MAX_FAQ_TOPICS ) {
858 $max = max( 1, (int) $max );
859 $seen = [];
860 $clean = [];
861 foreach ( $names as $name ) {
862 $name = trim( wp_strip_all_tags( (string) $name ) );
863 $key = strtolower( $name );
864 if ( '' === $name || isset( $seen[ $key ] ) ) {
865 continue;
866 }
867 $seen[ $key ] = true;
868 $clean[] = $name;
869 if ( count( $clean ) >= $max ) {
870 break;
871 }
872 }
873
874 $cats = [];
875 foreach ( $clean as $i => $name ) {
876 $cats[] = [
877 'id' => sanitize_key( 'sd_' . $i ),
878 'name' => sanitize_text_field( $name ),
879 'icon' => 'help',
880 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
881 // No seeded questions: the AI writes them. The FAQ profile screen counts
882 // topics, not questions, so nothing needs a placeholder here.
883 'articles' => [],
884 ];
885 }
886
887 return $cats;
888 }
889
890 /**
891 * Pages that are site *plumbing* — a cart, a login form, an index — and so carry no
892 * subject anyone asks a question about.
893 *
894 * Policy pages (privacy, terms, refunds) are deliberately NOT skipped: they are
895 * plumbing for documentation but they are prime FAQ material ("Do you sell my data?",
896 * "Can I get a refund?"). Skipping them meant a site with no docs yet — the normal
897 * case when generating samples — detected zero topics, while the AI, which reads the
898 * whole content digest rather than these chips, went on to build "Privacy & Data" and
899 * "Terms & Licensing" groups from them anyway.
900 *
901 * @return bool
902 */
903 protected function is_boilerplate_page( $title ) {
904 $skip = [ 'home', 'homepage', 'front page', 'sample page', 'blog', 'news', 'shop', 'store', 'cart', 'checkout', 'my account', 'account', 'login', 'log in', 'register', 'sign up', 'search results', '404', 'page not found' ];
905 return in_array( strtolower( trim( wp_strip_all_tags( (string) $title ) ) ), $skip, true );
906 }
907
908 /**
909 * What the owner kept, and what they REMOVED, on the profile screen.
910 *
911 * Removing a chip must actually skip that subject — for docs, FAQ and product FAQ
912 * alike. Sending only the kept list isn't enough: the AI also sees the site's full
913 * content digest, so a removed subject happily reappears unless it is named as
914 * off-limits. The removed set is derived server-side by diffing the deterministic
915 * suggestion list against what the client sent back, so no client change (and no
916 * trust in the client) is needed.
917 *
918 * @param array $kept Categories the client sent back (each with a 'name').
919 * @param string $content_type docs|faq|product_faq
920 * @return array { include: string[], exclude: string[] }
921 */
922 protected function topic_scope( array $profile, $content_type, array $kept ) {
923 $suggested = $this->suggested_categories( $profile, $content_type );
924
925 $kept_names = [];
926 foreach ( $kept as $cat ) {
927 $name = is_array( $cat ) ? ( isset( $cat['name'] ) ? $cat['name'] : '' ) : $cat;
928 $name = trim( wp_strip_all_tags( (string) $name ) );
929 if ( '' !== $name ) {
930 $kept_names[ $this->normalize_category_name( $name ) ] = $name;
931 }
932 }
933
934 $exclude = [];
935 foreach ( $suggested as $cat ) {
936 // The store-wide chip is not a real topic the AI writes about (it's the
937 // deterministic Layer-1 group), so never push it into the AI's exclude list.
938 if ( is_array( $cat ) && ! empty( $cat['all_products'] ) ) {
939 continue;
940 }
941 $name = isset( $cat['name'] ) ? (string) $cat['name'] : '';
942 if ( '' !== $name && ! isset( $kept_names[ $this->normalize_category_name( $name ) ] ) ) {
943 $exclude[] = $name;
944 }
945 }
946
947 return [
948 'include' => array_values( $kept_names ),
949 'exclude' => $exclude,
950 ];
951 }
952
953 /**
954 * Keep only the generated categories the user left selected on the profile
955 * screen, matched by normalized name. Falls back to the full generated set when
956 * the selection is empty or nothing matches, so we never return zero categories.
957 *
958 * @param array $generated Categories produced by the deterministic generator.
959 * @param array $selected The user's kept categories (each with a 'name').
960 * @return array
961 */
962 protected function filter_selected_categories( array $generated, array $selected ) {
963 if ( empty( $selected ) ) {
964 return $generated;
965 }
966
967 $wanted = [];
968 foreach ( $selected as $cat ) {
969 if ( is_array( $cat ) && ! empty( $cat['name'] ) ) {
970 $wanted[ $this->normalize_category_name( $cat['name'] ) ] = true;
971 }
972 }
973 if ( empty( $wanted ) ) {
974 return $generated;
975 }
976
977 $filtered = array_values(
978 array_filter(
979 $generated,
980 function ( $group ) use ( $wanted ) {
981 return ! empty( $group['name'] ) && isset( $wanted[ $this->normalize_category_name( $group['name'] ) ] );
982 }
983 )
984 );
985
986 return ! empty( $filtered ) ? $filtered : $generated;
987 }
988
989 /**
990 * Normalize a category name for loose matching between the user's selection and
991 * the generated set (case/whitespace/tag-insensitive).
992 *
993 * @return string
994 */
995 protected function normalize_category_name( $name ) {
996 return strtolower( trim( wp_strip_all_tags( (string) $name ) ) );
997 }
998
999 /**
1000 * Sanitize + cap an incoming categories array (used both for the user's edited
1001 * list and the proxy response).
1002 *
1003 * @param bool $with_content Keep article body HTML (proxy response) vs titles only.
1004 * @param int $max Category cap (defaults to MAX_CATEGORIES; FAQ uses more).
1005 * @param int $max_articles Per-category entry cap.
1006 * @param int $max_total Total entry cap across all categories (0 = none).
1007 * @return array
1008 */
1009 protected function sanitize_categories( array $categories, $with_content = false, $max = self::MAX_CATEGORIES, $max_articles = self::MAX_ARTICLES_PER_CATEGORY, $max_total = 0 ) {
1010 $max = max( 1, (int) $max );
1011 $max_articles = max( 1, (int) $max_articles );
1012 $max_total = max( 0, (int) $max_total );
1013 $total = 0;
1014 $clean = [];
1015 foreach ( array_slice( $categories, 0, $max ) as $i => $cat ) {
1016 if ( empty( $cat['name'] ) || ( $max_total > 0 && $total >= $max_total ) ) {
1017 continue;
1018 }
1019
1020 $articles = [];
1021 $raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
1022 foreach ( array_slice( $raw, 0, $max_articles ) as $article ) {
1023 if ( $max_total > 0 && $total >= $max_total ) {
1024 break;
1025 }
1026 if ( is_array( $article ) ) {
1027 $entry = [
1028 'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ),
1029 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
1030 ];
1031 if ( $with_content ) {
1032 $entry['content_html'] = wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' );
1033 }
1034 if ( '' !== $entry['title'] ) {
1035 $articles[] = $entry;
1036 $total++;
1037 }
1038 } else {
1039 $title = sanitize_text_field( $article );
1040 if ( '' !== $title ) {
1041 $articles[] = $with_content ? [ 'title' => $title, 'content_html' => '', 'excerpt' => '' ] : $title;
1042 $total++;
1043 }
1044 }
1045 }
1046
1047 $clean[] = [
1048 'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ),
1049 'name' => sanitize_text_field( $cat['name'] ),
1050 'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '',
1051 'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book',
1052 'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ],
1053 // Product FAQ: the WooCommerce product category this group is about, so the
1054 // builder can attach the group to it (the FAQ then shows on those product
1055 // pages instead of nowhere).
1056 'product_category' => isset( $cat['product_category'] ) ? sanitize_text_field( $cat['product_category'] ) : '',
1057 // Product FAQ Layer 1: the consolidated store-wide group, flagged so the
1058 // builder assigns it to "all products" rather than one category.
1059 'all_products' => ! empty( $cat['all_products'] ),
1060 'articles' => $articles,
1061 ];
1062 }
1063
1064 return $clean;
1065 }
1066
1067 /* --------------------------------------------------------------------- */
1068 /* Deep KB sanitizers */
1069 /* --------------------------------------------------------------------- */
1070
1071 /**
1072 * Sanitize the proxy's designed outline for the client: clamp counts, keep the
1073 * per-article specs (index/type/slug/summary), and assign a display color per
1074 * category. Does NOT run the 3×3 sanitizer.
1075 *
1076 * @return array { total_articles, categories:[ { id, name, slug, icon, color, description, articles:[…] } ] }
1077 */
1078 protected function sanitize_outline( array $outline ) {
1079 $cats_in = isset( $outline['categories'] ) && is_array( $outline['categories'] ) ? $outline['categories'] : [];
1080
1081 $categories = [];
1082 $total = 0;
1083 foreach ( array_slice( $cats_in, 0, self::MAX_KB_CATEGORIES ) as $i => $cat ) {
1084 if ( empty( $cat['name'] ) || $total >= self::MAX_KB_ARTICLES_TOTAL ) {
1085 continue;
1086 }
1087
1088 $articles = [];
1089 $arts_in = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
1090 foreach ( array_slice( $arts_in, 0, self::MAX_KB_ARTICLES_PER_CATEGORY ) as $art ) {
1091 if ( $total >= self::MAX_KB_ARTICLES_TOTAL || empty( $art['title'] ) ) {
1092 continue;
1093 }
1094 $links = [];
1095 if ( isset( $art['links'] ) && is_array( $art['links'] ) ) {
1096 foreach ( $art['links'] as $l ) {
1097 $links[] = sanitize_title( (string) $l );
1098 }
1099 }
1100 $articles[] = [
1101 'index' => isset( $art['index'] ) ? (int) $art['index'] : $total,
1102 'type' => sanitize_key( isset( $art['type'] ) ? $art['type'] : 'guide' ),
1103 'title' => sanitize_text_field( $art['title'] ),
1104 'slug' => sanitize_title( isset( $art['slug'] ) ? $art['slug'] : $art['title'] ),
1105 'summary' => sanitize_text_field( isset( $art['summary'] ) ? $art['summary'] : '' ),
1106 'links' => array_values( array_filter( $links ) ),
1107 ];
1108 $total++;
1109 }
1110
1111 if ( empty( $articles ) ) {
1112 continue;
1113 }
1114
1115 $categories[] = [
1116 'id' => sanitize_key( isset( $cat['id'] ) ? 'sd_' . $cat['id'] : 'sd_' . $i ),
1117 'name' => sanitize_text_field( $cat['name'] ),
1118 'slug' => sanitize_title( isset( $cat['slug'] ) ? $cat['slug'] : $cat['name'] ),
1119 'icon' => sanitize_key( isset( $cat['icon'] ) ? $cat['icon'] : 'book' ),
1120 'color' => self::PALETTE[ count( $categories ) % count( self::PALETTE ) ],
1121 'description' => sanitize_text_field( isset( $cat['description'] ) ? $cat['description'] : '' ),
1122 'articles' => $articles,
1123 ];
1124 }
1125
1126 return [
1127 'total_articles' => $total,
1128 'categories' => $categories,
1129 ];
1130 }
1131
1132 /**
1133 * Sanitize a single expanded article. Keeps the `#bd-link--slug` cross-link
1134 * sentinels (fragment hrefs survive wp_kses_post) for the builder to resolve.
1135 *
1136 * @return array { title, slug, type, category_slug, content_html, excerpt }
1137 */
1138 protected function sanitize_kb_article( array $article ) {
1139 return [
1140 'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ),
1141 'slug' => sanitize_title( isset( $article['slug'] ) ? $article['slug'] : '' ),
1142 'type' => sanitize_key( isset( $article['type'] ) ? $article['type'] : 'guide' ),
1143 'category_slug' => sanitize_title( isset( $article['category_slug'] ) ? $article['category_slug'] : '' ),
1144 'content_html' => wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' ),
1145 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
1146 ];
1147 }
1148
1149 /**
1150 * Sanitize + cap an assembled deep KB (categories with expanded articles) for
1151 * insertion. Preserves article slug/type (needed for cross-link resolution and
1152 * intro/quickstart ordering) and keeps content HTML.
1153 *
1154 * @return array
1155 */
1156 protected function sanitize_kb_categories( array $categories ) {
1157 $clean = [];
1158 $total = 0;
1159
1160 foreach ( array_slice( $categories, 0, self::MAX_KB_CATEGORIES ) as $i => $cat ) {
1161 if ( empty( $cat['name'] ) || $total >= self::MAX_KB_ARTICLES_TOTAL ) {
1162 continue;
1163 }
1164
1165 $articles = [];
1166 $raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
1167 foreach ( array_slice( $raw, 0, self::MAX_KB_ARTICLES_PER_CATEGORY ) as $article ) {
1168 if ( $total >= self::MAX_KB_ARTICLES_TOTAL || ! is_array( $article ) || empty( $article['title'] ) ) {
1169 continue;
1170 }
1171 $articles[] = [
1172 'title' => sanitize_text_field( $article['title'] ),
1173 'slug' => sanitize_title( isset( $article['slug'] ) ? $article['slug'] : $article['title'] ),
1174 'type' => sanitize_key( isset( $article['type'] ) ? $article['type'] : 'guide' ),
1175 'content_html' => wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' ),
1176 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
1177 ];
1178 $total++;
1179 }
1180
1181 if ( empty( $articles ) ) {
1182 continue;
1183 }
1184
1185 $clean[] = [
1186 'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ),
1187 'name' => sanitize_text_field( $cat['name'] ),
1188 'slug' => sanitize_title( isset( $cat['slug'] ) ? $cat['slug'] : $cat['name'] ),
1189 'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '',
1190 'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book',
1191 'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ],
1192 'articles' => $articles,
1193 ];
1194 }
1195
1196 return $clean;
1197 }
1198 }
1199