PluginProbe
wpForo Forum / 3.0.2
wpForo Forum v3.0.2
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / AIWordPressIndexer.php

AIWordPressIndexer.php in wpForo Forum 3.0.2, at classes/AIWordPressIndexer.php

1,328 lines 38.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 /**
6 * WordPress Content Type Indexer
7 *
8 * Handles AI content indexing for WordPress posts, pages, and custom post types.
9 * Uses the existing RAG infrastructure via /rag/wordpress/* API endpoints.
10 *
11 * Content Types:
12 * - Posts, Pages, and any public custom post types
13 * - Excludes internal types (revisions, nav_menu_item, etc.)
14 *
15 * @since 3.0.0
16 */
17 class AIWordPressIndexer {
18
19 /**
20 * Internal/structural post types to skip
21 * These should never be indexed as they're not user-facing content.
22 *
23 * @var array
24 */
25 private $skip_post_types = [
26 // WordPress core internal
27 'attachment',
28 'revision',
29 'nav_menu_item',
30 'wp_block',
31 'wp_template',
32 'wp_template_part',
33 'wp_navigation',
34 'wp_font_family',
35 'wp_font_face',
36 'oembed_cache',
37 'user_request',
38 'wp_global_styles',
39 'custom_css',
40 'customize_changeset',
41
42 // ACF internal
43 'acf-field-group',
44 'acf-field',
45 'acf-post-type',
46 'acf-taxonomy',
47
48 // wpForo posts (indexed separately via forum indexing)
49 'wpforo_post',
50 ];
51
52 /**
53 * WooCommerce post types to skip (temporary exclusion)
54 * TODO: Remove this exclusion when WooCommerce support is implemented
55 *
56 * @var array
57 */
58 private $skip_post_types_woocommerce = [
59 'product', // Main products
60 'product_variation', // Product variations
61 'shop_order', // Orders (legacy, pre-HPOS)
62 'shop_order_refund', // Order refunds
63 'shop_coupon', // Coupons
64 'shop_order_placehold', // Order placeholders
65 'shop_webhook', // Webhooks
66 ];
67
68 /**
69 * Internal/structural taxonomies to skip
70 *
71 * @var array
72 */
73 private $skip_taxonomies = [
74 // WordPress core internal
75 'nav_menu',
76 'link_category',
77 'post_format',
78 'wp_theme',
79 'wp_template_part_area',
80 'wp_pattern_category',
81 ];
82
83 /**
84 * WooCommerce taxonomies to skip (temporary exclusion)
85 * TODO: Remove this exclusion when WooCommerce support is implemented
86 *
87 * @var array
88 */
89 private $skip_taxonomies_woocommerce = [
90 'product_cat', // Product categories
91 'product_tag', // Product tags
92 'product_type', // Product types (simple, variable, etc.)
93 'product_visibility', // Product visibility
94 'product_shipping_class', // Shipping classes
95 // Note: pa_* (product attributes) are handled dynamically in get_taxonomies_for_post_type()
96 ];
97
98 /**
99 * Batch size for indexing
100 *
101 * @var int
102 */
103 const BATCH_SIZE = 50;
104
105 /**
106 * Minimum content length after stripping HTML and shortcodes
107 *
108 * @var int
109 */
110 const MIN_CONTENT_LENGTH = 10;
111
112 /**
113 * Constructor
114 */
115 public function __construct() {
116 // Register admin-only AJAX handlers
117 if ( is_admin() ) {
118 add_action( 'wp_ajax_wpforo_ai_wp_get_post_types', [ $this, 'ajax_get_post_types' ] );
119 add_action( 'wp_ajax_wpforo_ai_wp_get_taxonomies', [ $this, 'ajax_get_taxonomies' ] );
120 add_action( 'wp_ajax_wpforo_ai_wp_get_taxonomy_terms', [ $this, 'ajax_get_taxonomy_terms' ] );
121 add_action( 'wp_ajax_wpforo_ai_wp_get_indexing_status', [ $this, 'ajax_get_indexing_status' ] );
122 add_action( 'wp_ajax_wpforo_ai_wp_index_by_taxonomy', [ $this, 'ajax_index_by_taxonomy' ] );
123 add_action( 'wp_ajax_wpforo_ai_wp_index_custom', [ $this, 'ajax_index_custom' ] );
124 add_action( 'wp_ajax_wpforo_ai_wp_delete_content', [ $this, 'ajax_delete_content' ] );
125 }
126
127 // Register WP Cron handler for batch processing
128 // IMPORTANT: Must be registered unconditionally (not only in admin context)
129 // because WP Cron runs in a separate request where is_admin() returns FALSE
130 add_action( 'wpforo_ai_process_wp_batch', [ $this, 'process_batch' ] );
131 }
132
133 /**
134 * Get public post types available for indexing
135 *
136 * @return array Array of post type objects with name, label, and count
137 */
138 public function get_public_post_types() {
139 $post_types = get_post_types(
140 [
141 'public' => true,
142 'publicly_queryable' => true,
143 ],
144 'objects'
145 );
146
147 // Also include 'page' which has publicly_queryable = false by default
148 $page_type = get_post_type_object( 'page' );
149 if ( $page_type ) {
150 $post_types['page'] = $page_type;
151 }
152
153 // Also include 'post' which may have different settings
154 $post_type = get_post_type_object( 'post' );
155 if ( $post_type ) {
156 $post_types['post'] = $post_type;
157 }
158
159 // Combine skip lists
160 $all_skip_types = array_merge( $this->skip_post_types, $this->skip_post_types_woocommerce );
161
162 $result = [];
163 foreach ( $post_types as $name => $type ) {
164 // Skip internal/structural types and WooCommerce types
165 if ( in_array( $name, $all_skip_types, true ) ) {
166 continue;
167 }
168
169 // Count indexable published posts (for display accuracy)
170 $indexable_count = $this->count_indexable_posts( $name );
171
172 // Include ALL public post types, regardless of current indexable count
173 // Content quality filtering happens at indexing time, not at type listing
174 $result[] = [
175 'name' => $name,
176 'label' => $type->labels->name,
177 'count' => $indexable_count, // Shows indexable posts (may be 0)
178 ];
179 }
180
181 return $result;
182 }
183
184 /**
185 * Count indexable published posts for a post type
186 *
187 * Only counts posts with meaningful textual content.
188 * Results are cached for 5 minutes to improve performance.
189 *
190 * @param string $post_type Post type name
191 * @return int Number of indexable posts
192 */
193 private function count_indexable_posts( $post_type ) {
194 // Check transient cache first
195 $cache_key = 'wpforo_ai_indexable_count_' . sanitize_key( $post_type );
196 $cached = get_transient( $cache_key );
197 if ( false !== $cached ) {
198 return (int) $cached;
199 }
200
201 $query = new \WP_Query( [
202 'post_type' => $post_type,
203 'post_status' => 'publish',
204 'posts_per_page' => -1,
205 'no_found_rows' => true,
206 'update_post_meta_cache' => false,
207 'update_post_term_cache' => false,
208 'fields' => 'ids', // Only fetch IDs first for efficiency
209 ] );
210
211 // If no posts, return 0
212 if ( empty( $query->posts ) ) {
213 set_transient( $cache_key, 0, 5 * MINUTE_IN_SECONDS );
214 return 0;
215 }
216
217 // For small result sets, check all posts
218 // For large sets, sample to estimate (check first 500)
219 $post_ids = $query->posts;
220 $total_posts = count( $post_ids );
221 $sample_size = min( 500, $total_posts );
222 $indexable_count = 0;
223
224 // Check sample of posts
225 for ( $i = 0; $i < $sample_size; $i++ ) {
226 $post = get_post( $post_ids[ $i ] );
227 if ( $post && $this->is_content_indexable( $post ) ) {
228 $indexable_count++;
229 }
230 }
231
232 // If we sampled, extrapolate the count
233 if ( $sample_size < $total_posts ) {
234 $ratio = $indexable_count / $sample_size;
235 $indexable_count = (int) round( $total_posts * $ratio );
236 }
237
238 set_transient( $cache_key, $indexable_count, 5 * MINUTE_IN_SECONDS );
239
240 return $indexable_count;
241 }
242
243 /**
244 * Get public taxonomies for a post type
245 *
246 * @param string $post_type Post type name
247 * @return array Array of taxonomy objects
248 */
249 public function get_taxonomies_for_post_type( $post_type ) {
250 $taxonomies = get_object_taxonomies( $post_type, 'objects' );
251
252 // Combine skip lists
253 $all_skip_taxonomies = array_merge( $this->skip_taxonomies, $this->skip_taxonomies_woocommerce );
254
255 $result = [];
256 foreach ( $taxonomies as $name => $taxonomy ) {
257 // Only include public taxonomies
258 if ( ! $taxonomy->public ) {
259 continue;
260 }
261
262 // Skip internal/structural taxonomies and WooCommerce taxonomies
263 if ( in_array( $name, $all_skip_taxonomies, true ) ) {
264 continue;
265 }
266
267 // Skip WooCommerce product attributes (pa_* prefix)
268 // TODO: Remove this when WooCommerce support is implemented
269 if ( strpos( $name, 'pa_' ) === 0 ) {
270 continue;
271 }
272
273 // Get term count (show all terms, not just those with posts)
274 $count = wp_count_terms( [ 'taxonomy' => $name, 'hide_empty' => false ] );
275
276 $result[] = [
277 'name' => $name,
278 'label' => $taxonomy->labels->name,
279 'term_count' => is_wp_error( $count ) ? 0 : (int) $count,
280 ];
281 }
282
283 return $result;
284 }
285
286 /**
287 * Get terms for a taxonomy
288 *
289 * @param string $taxonomy Taxonomy name
290 * @param bool $hierarchical Whether to return hierarchical structure
291 * @param array $post_types Post types to count (defaults to all public types using this taxonomy)
292 * @return array Array of terms
293 */
294 public function get_taxonomy_terms( $taxonomy, $hierarchical = true, $post_types = [] ) {
295 $args = [
296 'taxonomy' => $taxonomy,
297 'hide_empty' => false, // Show all terms, not just those with posts
298 'orderby' => 'name',
299 'order' => 'ASC',
300 ];
301
302 if ( $hierarchical && is_taxonomy_hierarchical( $taxonomy ) ) {
303 $args['parent'] = 0; // Get top-level terms first
304 }
305
306 $terms = get_terms( $args );
307
308 if ( is_wp_error( $terms ) ) {
309 return [];
310 }
311
312 // Get post types that use this taxonomy if not specified
313 if ( empty( $post_types ) ) {
314 $tax_obj = get_taxonomy( $taxonomy );
315 $post_types = $tax_obj ? $tax_obj->object_type : [ 'post' ];
316 }
317
318 $result = [];
319 foreach ( $terms as $term ) {
320 // Count only published posts for this term
321 $published_count = $this->count_published_posts_in_term( $term->term_id, $taxonomy, $post_types );
322
323 $term_data = [
324 'term_id' => $term->term_id,
325 'name' => $term->name,
326 'slug' => $term->slug,
327 'count' => $published_count, // Only published posts
328 'indexed' => 0, // Placeholder - actual indexed count from AI backend
329 ];
330
331 // Get children for hierarchical taxonomies
332 if ( $hierarchical && is_taxonomy_hierarchical( $taxonomy ) ) {
333 $children = get_terms( [
334 'taxonomy' => $taxonomy,
335 'hide_empty' => false, // Show all terms, not just those with posts
336 'parent' => $term->term_id,
337 'orderby' => 'name',
338 'order' => 'ASC',
339 ] );
340
341 if ( ! is_wp_error( $children ) && ! empty( $children ) ) {
342 $term_data['children'] = [];
343 foreach ( $children as $child ) {
344 // Count only published posts for child term
345 $child_published_count = $this->count_published_posts_in_term( $child->term_id, $taxonomy, $post_types );
346
347 $term_data['children'][] = [
348 'term_id' => $child->term_id,
349 'name' => $child->name,
350 'slug' => $child->slug,
351 'count' => $child_published_count, // Only published posts
352 'indexed' => 0, // Placeholder - actual indexed count from AI backend
353 ];
354 }
355 }
356 }
357
358 $result[] = $term_data;
359 }
360
361 return $result;
362 }
363
364 /**
365 * Count published and indexable posts in a specific term
366 *
367 * Only counts posts that have meaningful textual content (not just shortcodes,
368 * not binary data, and at least MIN_CONTENT_LENGTH characters).
369 * Results are cached for 5 minutes to improve performance.
370 *
371 * @param int $term_id Term ID
372 * @param string $taxonomy Taxonomy name
373 * @param array $post_types Post types to count
374 * @return int Number of indexable published posts
375 */
376 private function count_published_posts_in_term( $term_id, $taxonomy, $post_types = [ 'post' ] ) {
377 // Check transient cache first
378 $cache_key = 'wpforo_ai_term_count_' . $term_id . '_' . sanitize_key( $taxonomy );
379 $cached = get_transient( $cache_key );
380 if ( false !== $cached ) {
381 return (int) $cached;
382 }
383
384 $query = new \WP_Query( [
385 'post_type' => $post_types,
386 'post_status' => 'publish',
387 'posts_per_page' => -1,
388 'tax_query' => [
389 [
390 'taxonomy' => $taxonomy,
391 'field' => 'term_id',
392 'terms' => $term_id,
393 ],
394 ],
395 'no_found_rows' => true,
396 'update_post_meta_cache' => false,
397 'update_post_term_cache' => false,
398 'fields' => 'ids', // Only fetch IDs first
399 ] );
400
401 // If no posts, return 0
402 if ( empty( $query->posts ) ) {
403 set_transient( $cache_key, 0, 5 * MINUTE_IN_SECONDS );
404 return 0;
405 }
406
407 $post_ids = $query->posts;
408 $total_posts = count( $post_ids );
409 $sample_size = min( 200, $total_posts ); // Smaller sample for term counts
410 $indexable_count = 0;
411
412 for ( $i = 0; $i < $sample_size; $i++ ) {
413 $post = get_post( $post_ids[ $i ] );
414 if ( $post && $this->is_content_indexable( $post ) ) {
415 $indexable_count++;
416 }
417 }
418
419 // Extrapolate if sampled
420 if ( $sample_size < $total_posts ) {
421 $ratio = $indexable_count / $sample_size;
422 $indexable_count = (int) round( $total_posts * $ratio );
423 }
424
425 set_transient( $cache_key, $indexable_count, 5 * MINUTE_IN_SECONDS );
426
427 return $indexable_count;
428 }
429
430 /**
431 * Get WordPress posts for indexing
432 *
433 * @param array $args Query arguments
434 * @return array Array of formatted post data
435 */
436 public function get_posts_for_indexing( $args = [] ) {
437 $defaults = [
438 'post_type' => 'post',
439 'post_status' => 'publish',
440 'posts_per_page' => self::BATCH_SIZE,
441 'paged' => 1,
442 'orderby' => 'ID',
443 'order' => 'ASC',
444 ];
445
446 $args = wp_parse_args( $args, $defaults );
447
448 // Ensure we only get published posts
449 $args['post_status'] = 'publish';
450
451 $query = new \WP_Query( $args );
452 $posts = [];
453
454 foreach ( $query->posts as $post ) {
455 $posts[] = $this->format_post_for_indexing( $post );
456 }
457
458 return [
459 'posts' => $posts,
460 'total' => $query->found_posts,
461 'total_pages' => $query->max_num_pages,
462 'current' => $args['paged'],
463 ];
464 }
465
466 /**
467 * Format a WordPress post for indexing
468 *
469 * @param \WP_Post $post WordPress post object
470 * @return array Formatted post data for API
471 */
472 public function format_post_for_indexing( $post ) {
473 // Get taxonomy terms
474 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
475 $taxonomy_terms = [];
476
477 foreach ( $taxonomies as $taxonomy ) {
478 $terms = wp_get_post_terms( $post->ID, $taxonomy, [ 'fields' => 'names' ] );
479 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
480 $tax_obj = get_taxonomy( $taxonomy );
481 $tax_label = $tax_obj ? $tax_obj->labels->singular_name : $taxonomy;
482 $taxonomy_terms[ $tax_label ] = $terms;
483 }
484 }
485
486 // Get important post meta (configurable)
487 $post_meta = $this->get_indexable_post_meta( $post );
488
489 return [
490 'post_id' => $post->ID,
491 'post_type' => $post->post_type,
492 'title' => $post->post_title,
493 'content' => $post->post_content,
494 'excerpt' => $post->post_excerpt,
495 'author_id' => (int) $post->post_author,
496 'post_status' => $post->post_status,
497 'permalink' => get_permalink( $post->ID ),
498 'created_at' => $post->post_date_gmt,
499 'updated_at' => $post->post_modified_gmt,
500 'taxonomy_terms' => $taxonomy_terms,
501 'post_meta' => $post_meta,
502 ];
503 }
504
505 /**
506 * Get indexable post meta
507 *
508 * @param \WP_Post $post WordPress post object
509 * @return array Filtered post meta
510 */
511 private function get_indexable_post_meta( $post ) {
512 $meta = [];
513
514 // WooCommerce product meta
515 if ( $post->post_type === 'product' ) {
516 $meta['_price'] = get_post_meta( $post->ID, '_price', true );
517 $meta['_sku'] = get_post_meta( $post->ID, '_sku', true );
518 $meta['_stock_status'] = get_post_meta( $post->ID, '_stock_status', true );
519 }
520
521 // Allow plugins to add custom meta
522 $meta = apply_filters( 'wpforo_ai_indexable_post_meta', $meta, $post );
523
524 // Remove empty values
525 return array_filter( $meta, function( $v ) {
526 return $v !== '' && $v !== null;
527 } );
528 }
529
530 /**
531 * Check if post content is suitable for indexing
532 *
533 * Validates that content is:
534 * - Not just shortcodes
535 * - Has meaningful text (>= MIN_CONTENT_LENGTH chars after stripping)
536 * - Is textual (not binary/garbage data)
537 *
538 * @param \WP_Post|int $post Post object or ID
539 * @return bool True if content is indexable
540 */
541 public function is_content_indexable( $post ) {
542 if ( is_numeric( $post ) ) {
543 $post = get_post( $post );
544 }
545
546 if ( ! $post || ! isset( $post->post_content ) ) {
547 return false;
548 }
549
550 $content = $post->post_content;
551
552 // Check for binary/non-textual content
553 // Binary data often contains null bytes or high ratio of non-printable characters
554 if ( $this->is_binary_content( $content ) ) {
555 return false;
556 }
557
558 // Strip shortcodes first (e.g., [gallery], [contact-form-7 id="123"])
559 $content = strip_shortcodes( $content );
560
561 // Strip all HTML tags
562 $content = wp_strip_all_tags( $content );
563
564 // Decode HTML entities
565 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
566
567 // Normalize whitespace
568 $content = preg_replace( '/\s+/', ' ', $content );
569 $content = trim( $content );
570
571 // Check minimum length
572 $length = mb_strlen( $content, 'UTF-8' );
573
574 return $length >= self::MIN_CONTENT_LENGTH;
575 }
576
577 /**
578 * Check if content appears to be binary/non-textual data
579 *
580 * @param string $content Content to check
581 * @return bool True if content appears to be binary
582 */
583 private function is_binary_content( $content ) {
584 if ( empty( $content ) ) {
585 return false;
586 }
587
588 // Check for null bytes (common in binary data)
589 if ( strpos( $content, "\0" ) !== false ) {
590 return true;
591 }
592
593 // Sample the content (check first 1000 bytes for performance)
594 $sample = substr( $content, 0, 1000 );
595 $sample_length = strlen( $sample );
596
597 if ( $sample_length === 0 ) {
598 return false;
599 }
600
601 // Count non-printable characters (excluding common whitespace)
602 $non_printable = 0;
603 for ( $i = 0; $i < $sample_length; $i++ ) {
604 $ord = ord( $sample[ $i ] );
605 // Allow: tab (9), newline (10), carriage return (13), space and above (32-126)
606 // Allow extended ASCII/UTF-8 (128+)
607 if ( $ord < 9 || ( $ord > 13 && $ord < 32 ) || ( $ord > 126 && $ord < 128 ) ) {
608 $non_printable++;
609 }
610 }
611
612 // If more than 10% is non-printable, likely binary
613 $ratio = $non_printable / $sample_length;
614
615 return $ratio > 0.1;
616 }
617
618 /**
619 * Get the clean text content for a post (for display/counting purposes)
620 *
621 * @param \WP_Post|int $post Post object or ID
622 * @return string Clean text content
623 */
624 public function get_clean_content( $post ) {
625 if ( is_numeric( $post ) ) {
626 $post = get_post( $post );
627 }
628
629 if ( ! $post || ! isset( $post->post_content ) ) {
630 return '';
631 }
632
633 $content = $post->post_content;
634 $content = strip_shortcodes( $content );
635 $content = wp_strip_all_tags( $content );
636 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
637 $content = preg_replace( '/\s+/', ' ', $content );
638
639 return trim( $content );
640 }
641
642 /**
643 * Index posts by taxonomy term(s)
644 *
645 * @param string $taxonomy Taxonomy name
646 * @param int|array $term_ids Term ID or array of term IDs
647 * @param array $post_types Post types to index
648 * @param string $date_from Optional start date (Y-m-d format)
649 * @param string $date_to Optional end date (Y-m-d format)
650 * @return array|WP_Error Result or error
651 */
652 public function index_by_taxonomy( $taxonomy, $term_ids, $post_types = [ 'post' ], $date_from = '', $date_to = '' ) {
653 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
654 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
655 }
656
657 // Ensure term_ids is an array
658 $term_ids = (array) $term_ids;
659 $term_ids = array_map( 'intval', $term_ids );
660 $term_ids = array_filter( $term_ids ); // Remove zeros
661
662 if ( empty( $term_ids ) ) {
663 return new \WP_Error( 'no_terms', __( 'No valid terms specified', 'wpforo' ) );
664 }
665
666 // Get all posts in these terms
667 $args = [
668 'post_type' => $post_types,
669 'posts_per_page' => -1, // Get all
670 'post_status' => 'publish',
671 'tax_query' => [
672 [
673 'taxonomy' => $taxonomy,
674 'field' => 'term_id',
675 'terms' => $term_ids,
676 ],
677 ],
678 'fields' => 'ids',
679 ];
680
681 // Add date range filter if specified
682 if ( ! empty( $date_from ) ) {
683 $args['date_query'][] = [
684 'after' => $date_from,
685 'inclusive' => true,
686 ];
687 }
688
689 if ( ! empty( $date_to ) ) {
690 $args['date_query'][] = [
691 'before' => $date_to,
692 'inclusive' => true,
693 ];
694 }
695
696 $query = new \WP_Query( $args );
697 $post_ids = $query->posts;
698
699 if ( empty( $post_ids ) ) {
700 return new \WP_Error( 'no_posts', __( 'No posts found in this term', 'wpforo' ) );
701 }
702
703 // Queue posts for batch indexing
704 return $this->queue_posts_for_indexing( $post_ids );
705 }
706
707 /**
708 * Index posts with custom filters
709 *
710 * @param array $params Custom indexing parameters
711 * @return array|WP_Error Result or error
712 */
713 public function index_custom( $params ) {
714 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
715 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
716 }
717
718 $args = [
719 'post_type' => isset( $params['post_types'] ) ? $params['post_types'] : [ 'post' ],
720 'posts_per_page' => -1,
721 'post_status' => 'publish',
722 'fields' => 'ids',
723 ];
724
725 // Date range filter
726 if ( ! empty( $params['date_from'] ) ) {
727 $args['date_query'][] = [
728 'after' => $params['date_from'],
729 'inclusive' => true,
730 ];
731 }
732
733 if ( ! empty( $params['date_to'] ) ) {
734 $args['date_query'][] = [
735 'before' => $params['date_to'],
736 'inclusive' => true,
737 ];
738 }
739
740 // Specific post IDs
741 if ( ! empty( $params['post_ids'] ) ) {
742 $args['post__in'] = array_map( 'intval', (array) $params['post_ids'] );
743 }
744
745 // Author filter
746 if ( ! empty( $params['author'] ) ) {
747 $args['author'] = intval( $params['author'] );
748 }
749
750 $query = new \WP_Query( $args );
751 $post_ids = $query->posts;
752
753 if ( empty( $post_ids ) ) {
754 return new \WP_Error( 'no_posts', __( 'No posts found matching criteria', 'wpforo' ) );
755 }
756
757 return $this->queue_posts_for_indexing( $post_ids );
758 }
759
760 /**
761 * Queue posts for batch indexing
762 *
763 * @param array $post_ids Array of post IDs to index
764 * @return array Result with job info
765 */
766 public function queue_posts_for_indexing( $post_ids ) {
767 $batches = array_chunk( $post_ids, self::BATCH_SIZE );
768 $job_id = 'wp_index_' . uniqid();
769 $total_posts = count( $post_ids );
770
771 // Clear the status cache so polling gets fresh data
772 delete_transient( 'wpforo_ai_wp_indexing_status' );
773
774 // Store queue in options for processing
775 update_option( 'wpforo_ai_wp_indexing_queue', [
776 'job_id' => $job_id,
777 'batches' => $batches,
778 'current' => 0,
779 'total_posts' => $total_posts,
780 'indexed' => 0,
781 'failed' => 0,
782 'skipped' => 0,
783 'status' => 'processing',
784 'started_at' => current_time( 'mysql', true ),
785 ] );
786
787 // Schedule first batch
788 wp_schedule_single_event( time() + 1, 'wpforo_ai_process_wp_batch' );
789
790 return [
791 'job_id' => $job_id,
792 'total_posts' => $total_posts,
793 'batches' => count( $batches ),
794 'status' => 'queued',
795 ];
796 }
797
798 /**
799 * Process a batch of posts for indexing
800 *
801 * @return array|WP_Error Result or error
802 */
803 public function process_batch() {
804 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
805
806 if ( empty( $queue ) || empty( $queue['batches'] ) ) {
807 return new \WP_Error( 'no_queue', 'No indexing queue found' );
808 }
809
810 $current_batch_index = $queue['current'];
811
812 if ( ! isset( $queue['batches'][ $current_batch_index ] ) ) {
813 // All batches processed
814 delete_option( 'wpforo_ai_wp_indexing_queue' );
815 return [ 'status' => 'completed' ];
816 }
817
818 $post_ids = $queue['batches'][ $current_batch_index ];
819 $posts = [];
820 $skipped = 0;
821
822 foreach ( $post_ids as $post_id ) {
823 $post = get_post( $post_id );
824 if ( $post && $post->post_status === 'publish' ) {
825 // Skip posts with non-indexable content (shortcodes only, binary, too short)
826 if ( ! $this->is_content_indexable( $post ) ) {
827 $skipped++;
828 continue;
829 }
830 $posts[] = $this->format_post_for_indexing( $post );
831 }
832 }
833
834 // Track skipped posts
835 if ( ! isset( $queue['skipped'] ) ) {
836 $queue['skipped'] = 0;
837 }
838 $queue['skipped'] += $skipped;
839
840 if ( empty( $posts ) ) {
841 // Move to next batch
842 $queue['current']++;
843 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
844
845 // Schedule next batch
846 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
847 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
848 }
849
850 return [ 'status' => 'batch_empty', 'current' => $queue['current'] ];
851 }
852
853 // Check storage mode — local mode stores embeddings in WordPress DB
854 $storage_manager = WPF()->vector_storage;
855 if ( $storage_manager && $storage_manager->is_local_mode() ) {
856 $local_result = $this->process_batch_local( $posts, $queue );
857
858 return $local_result;
859 }
860
861 // CLOUD MODE: Send to cloud API
862 $response = WPF()->ai_client->api_post( '/rag/wordpress/ingest', [
863 'posts' => $posts,
864 'chunk_size' => 512,
865 'overlap_percent' => 20,
866 ], 120 );
867
868 if ( is_wp_error( $response ) ) {
869 $queue['failed'] += count( $post_ids );
870 } else {
871 $queue['indexed'] += count( $posts );
872 }
873
874 // Move to next batch
875 $queue['current']++;
876 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
877
878 // Schedule next batch
879 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
880 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
881 } else {
882 // All done
883 $queue['status'] = 'completed';
884 $queue['completed_at'] = current_time( 'mysql', true );
885 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
886
887 // Clear cache
888 delete_transient( 'wpforo_ai_wp_indexing_status' );
889 }
890
891 return [
892 'status' => 'processing',
893 'current' => $queue['current'],
894 'indexed' => $queue['indexed'],
895 'failed' => $queue['failed'],
896 'skipped' => $queue['skipped'],
897 ];
898 }
899
900 /**
901 * Process a batch of posts for local storage mode
902 *
903 * Generates embeddings via cloud API but stores them in WordPress DB
904 * instead of cloud vector storage. Uses content_hash dedup to skip
905 * unchanged posts.
906 *
907 * @param array $posts Formatted post data from format_post_for_indexing()
908 * @param array $queue Current queue state (modified by reference via option update)
909 * @return array Result with status, indexed, failed, skipped counts
910 */
911 private function process_batch_local( $posts, $queue ) {
912 $storage_manager = WPF()->vector_storage;
913 $local = $storage_manager->get_local_storage();
914 $indexed = 0;
915 $failed = 0;
916 $skipped = 0;
917
918 foreach ( $posts as $post_data ) {
919 $post_id = $post_data['post_id'];
920 $post_type = $post_data['post_type'];
921
922 // Build text content for embedding
923 $content_parts = [];
924 if ( ! empty( $post_data['title'] ) ) {
925 $content_parts[] = $post_data['title'];
926 }
927 if ( ! empty( $post_data['excerpt'] ) ) {
928 $content_parts[] = wp_strip_all_tags( $post_data['excerpt'] );
929 }
930 if ( ! empty( $post_data['content'] ) ) {
931 $clean_content = strip_shortcodes( $post_data['content'] );
932 $clean_content = wp_strip_all_tags( $clean_content );
933 $clean_content = html_entity_decode( $clean_content, ENT_QUOTES, 'UTF-8' );
934 $clean_content = preg_replace( '/\s+/', ' ', trim( $clean_content ) );
935 $content_parts[] = $clean_content;
936 }
937
938 // Add taxonomy context
939 if ( ! empty( $post_data['taxonomy_terms'] ) ) {
940 foreach ( $post_data['taxonomy_terms'] as $tax_label => $terms ) {
941 $content_parts[] = $tax_label . ': ' . implode( ', ', $terms );
942 }
943 }
944
945 $content = implode( "\n\n", $content_parts );
946 $content_hash = md5( $content );
947
948 // Check if already indexed with same content (dedup)
949 $existing = $local->get_embedding( $post_id );
950 if ( $existing && $existing['content_hash'] === $content_hash ) {
951 $skipped++;
952 continue;
953 }
954
955 // Generate embedding via cloud API
956 $embedding = $storage_manager->generate_embedding( $content );
957
958 if ( is_wp_error( $embedding ) ) {
959 \wpforo_ai_log( 'error', sprintf(
960 'Failed to generate embedding for WP post %d: %s',
961 $post_id,
962 $embedding->get_error_message()
963 ), 'WPIndexer' );
964 $failed++;
965 continue;
966 }
967
968 // Build content preview
969 $preview = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post_data['content'] ?? '' ) ), 80, '...' );
970
971 // Store locally with content_type = post_type
972 $stored = $local->store_embedding(
973 0, // topicid (not a forum topic)
974 $post_id, // postid = WP post ID
975 0, // forumid (not a forum)
976 (int) $post_data['author_id'],
977 $embedding,
978 $content_hash,
979 $preview,
980 'amazon.titan-embed-text-v2',
981 $post_type // content_type = post type (page, post, product, etc.)
982 );
983
984 if ( $stored ) {
985 $indexed++;
986 } else {
987 $failed++;
988 }
989 }
990
991 // Update queue progress
992 $queue['indexed'] += $indexed;
993 $queue['failed'] += $failed;
994 $queue['skipped'] = ( $queue['skipped'] ?? 0 ) + $skipped;
995
996 // Move to next batch
997 $queue['current']++;
998 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
999
1000 // Schedule next batch
1001 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
1002 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
1003 } else {
1004 // All done
1005 $queue['status'] = 'completed';
1006 $queue['completed_at'] = current_time( 'mysql', true );
1007 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1008 delete_transient( 'wpforo_ai_wp_indexing_status' );
1009 }
1010
1011 return [
1012 'status' => 'processing',
1013 'current' => $queue['current'],
1014 'indexed' => $queue['indexed'],
1015 'failed' => $queue['failed'],
1016 'skipped' => $queue['skipped'] ?? 0,
1017 ];
1018 }
1019
1020 /**
1021 * Get WordPress content indexing status
1022 *
1023 * @return array|WP_Error Status data or error
1024 */
1025 public function get_indexing_status( $skip_cache = false ) {
1026 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1027 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1028 }
1029
1030 // Check cache (skip if explicitly requested or if indexing is in progress)
1031 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1032 $is_processing = ! empty( $queue ) && isset( $queue['status'] ) && $queue['status'] === 'processing';
1033
1034 if ( ! $skip_cache && ! $is_processing ) {
1035 $cached = get_transient( 'wpforo_ai_wp_indexing_status' );
1036 if ( $cached !== false ) {
1037 return $cached;
1038 }
1039 }
1040
1041 // Get indexed counts — source depends on storage mode
1042 $storage_manager = WPF()->vector_storage;
1043 if ( $storage_manager && $storage_manager->is_local_mode() ) {
1044 // LOCAL mode: count from WordPress ai_embeddings table
1045 $local = $storage_manager->get_local_storage();
1046 $indexed_counts = $local->get_wp_indexed_counts();
1047 $response = [
1048 'content_source' => 'wordpress',
1049 'indexed_counts' => $indexed_counts,
1050 'total_indexed' => array_sum( $indexed_counts ),
1051 ];
1052 } else {
1053 // CLOUD mode: query backend API (has sync_state records)
1054 $response = WPF()->ai_client->api_get( '/rag/wordpress/status' );
1055 if ( is_wp_error( $response ) ) {
1056 return $response;
1057 }
1058 }
1059
1060 // Build by_type structure that JavaScript expects
1061 $post_types = $this->get_public_post_types();
1062 $indexed_counts = isset( $response['indexed_counts'] ) ? $response['indexed_counts'] : [];
1063 $by_type = [];
1064
1065 foreach ( $post_types as $type ) {
1066 $type_key = 'wp_' . $type['name'];
1067 $indexed = isset( $indexed_counts[ $type_key ] ) ? (int) $indexed_counts[ $type_key ] : 0;
1068 $total = (int) $type['count'];
1069 $by_type[ $type_key ] = [
1070 'indexed' => $indexed,
1071 'total' => $total,
1072 'percentage' => $total > 0 ? round( ( $indexed / $total ) * 100, 1 ) : 0,
1073 ];
1074 }
1075
1076 $response['by_type'] = $by_type;
1077 $response['total_indexed'] = isset( $response['total_indexed'] ) ? (int) $response['total_indexed'] : 0;
1078
1079 // Check if there's an active queue
1080 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1081 if ( ! empty( $queue ) ) {
1082 $response['queue'] = [
1083 'job_id' => $queue['job_id'],
1084 'total_posts' => $queue['total_posts'],
1085 'indexed' => $queue['indexed'],
1086 'failed' => $queue['failed'],
1087 'current' => $queue['current'],
1088 'total' => count( $queue['batches'] ),
1089 'status' => isset( $queue['status'] ) ? $queue['status'] : 'processing',
1090 ];
1091 }
1092
1093 // Cache for 5 minutes
1094 set_transient( 'wpforo_ai_wp_indexing_status', $response, 5 * MINUTE_IN_SECONDS );
1095
1096 return $response;
1097 }
1098
1099 /**
1100 * Delete WordPress content from index
1101 *
1102 * @param array $params Delete parameters (post_types, post_ids, all)
1103 * @return array|WP_Error Result or error
1104 */
1105 public function delete_content( $params ) {
1106 $storage_manager = WPF()->vector_storage;
1107
1108 if ( $storage_manager && $storage_manager->is_local_mode() ) {
1109 // LOCAL mode: delete from WordPress ai_embeddings table
1110 $local = $storage_manager->get_local_storage();
1111 $post_types = isset( $params['post_types'] ) ? $params['post_types'] : null;
1112 $post_ids = isset( $params['post_ids'] ) ? $params['post_ids'] : null;
1113
1114 // 'all' flag means delete all non-forum CPT embeddings
1115 if ( ! empty( $params['all'] ) ) {
1116 $post_types = null;
1117 $post_ids = null;
1118 }
1119
1120 $deleted = $local->delete_wp_embeddings( $post_types, $post_ids );
1121
1122 // Clear cache
1123 delete_transient( 'wpforo_ai_wp_indexing_status' );
1124
1125 return [
1126 'deleted' => $deleted,
1127 'message' => sprintf( 'Deleted %d embeddings from local storage.', $deleted ),
1128 ];
1129 }
1130
1131 // CLOUD mode: delete via backend API
1132 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1133 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1134 }
1135
1136 $response = WPF()->ai_client->api_post( '/rag/wordpress/delete', $params, 60 );
1137
1138 if ( ! is_wp_error( $response ) ) {
1139 // Clear cache
1140 delete_transient( 'wpforo_ai_wp_indexing_status' );
1141 }
1142
1143 return $response;
1144 }
1145
1146 // ===============================
1147 // AJAX Handlers
1148 // ===============================
1149
1150 /**
1151 * AJAX: Get public post types
1152 */
1153 public function ajax_get_post_types() {
1154 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1155
1156 if ( ! current_user_can( 'manage_options' ) ) {
1157 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1158 }
1159
1160 $post_types = $this->get_public_post_types();
1161 wp_send_json_success( [ 'post_types' => $post_types ] );
1162 }
1163
1164 /**
1165 * AJAX: Get taxonomies for post type
1166 */
1167 public function ajax_get_taxonomies() {
1168 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1169
1170 if ( ! current_user_can( 'manage_options' ) ) {
1171 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1172 }
1173
1174 $post_type = isset( $_POST['post_type'] ) ? sanitize_key( $_POST['post_type'] ) : 'post';
1175 $taxonomies = $this->get_taxonomies_for_post_type( $post_type );
1176
1177 wp_send_json_success( [ 'taxonomies' => $taxonomies ] );
1178 }
1179
1180 /**
1181 * AJAX: Get terms for taxonomy
1182 */
1183 public function ajax_get_taxonomy_terms() {
1184 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1185
1186 if ( ! current_user_can( 'manage_options' ) ) {
1187 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1188 }
1189
1190 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : 'category';
1191
1192 // Get post types if provided (to count only published posts for specific types)
1193 $post_types = [];
1194 if ( ! empty( $_POST['post_types'] ) ) {
1195 $post_types = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1196 }
1197
1198 $terms = $this->get_taxonomy_terms( $taxonomy, true, $post_types );
1199
1200 wp_send_json_success( [ 'terms' => $terms ] );
1201 }
1202
1203 /**
1204 * AJAX: Get indexing status
1205 */
1206 public function ajax_get_indexing_status() {
1207 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1208
1209 if ( ! current_user_can( 'manage_options' ) ) {
1210 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1211 }
1212
1213 $status = $this->get_indexing_status();
1214
1215 if ( is_wp_error( $status ) ) {
1216 wp_send_json_error( [ 'message' => $status->get_error_message() ] );
1217 }
1218
1219 wp_send_json_success( $status );
1220 }
1221
1222 /**
1223 * AJAX: Index by taxonomy
1224 */
1225 public function ajax_index_by_taxonomy() {
1226 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1227
1228 if ( ! current_user_can( 'manage_options' ) ) {
1229 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1230 }
1231
1232 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '';
1233 $post_types = isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : [ 'post' ];
1234 $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( $_POST['date_from'] ) : '';
1235 $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( $_POST['date_to'] ) : '';
1236
1237 // Support both single term_id (legacy) and multiple term_ids
1238 $term_ids = [];
1239 if ( isset( $_POST['term_ids'] ) && is_array( $_POST['term_ids'] ) ) {
1240 $term_ids = array_map( 'intval', $_POST['term_ids'] );
1241 } elseif ( isset( $_POST['term_id'] ) ) {
1242 $term_ids = [ intval( $_POST['term_id'] ) ];
1243 }
1244
1245 if ( empty( $taxonomy ) || empty( $term_ids ) ) {
1246 wp_send_json_error( [ 'message' => __( 'Invalid taxonomy or term', 'wpforo' ) ] );
1247 }
1248
1249 $result = $this->index_by_taxonomy( $taxonomy, $term_ids, $post_types, $date_from, $date_to );
1250
1251 if ( is_wp_error( $result ) ) {
1252 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1253 }
1254
1255 wp_send_json_success( $result );
1256 }
1257
1258 /**
1259 * AJAX: Custom indexing
1260 */
1261 public function ajax_index_custom() {
1262 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1263
1264 if ( ! current_user_can( 'manage_options' ) ) {
1265 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1266 }
1267
1268 $params = [];
1269
1270 if ( ! empty( $_POST['post_types'] ) ) {
1271 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1272 }
1273
1274 if ( ! empty( $_POST['date_from'] ) ) {
1275 $params['date_from'] = sanitize_text_field( $_POST['date_from'] );
1276 }
1277
1278 if ( ! empty( $_POST['date_to'] ) ) {
1279 $params['date_to'] = sanitize_text_field( $_POST['date_to'] );
1280 }
1281
1282 if ( ! empty( $_POST['post_ids'] ) ) {
1283 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1284 }
1285
1286 if ( ! empty( $_POST['author'] ) ) {
1287 $params['author'] = intval( $_POST['author'] );
1288 }
1289
1290 $result = $this->index_custom( $params );
1291
1292 if ( is_wp_error( $result ) ) {
1293 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1294 }
1295
1296 wp_send_json_success( $result );
1297 }
1298
1299 /**
1300 * AJAX: Delete content
1301 */
1302 public function ajax_delete_content() {
1303 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1304
1305 if ( ! current_user_can( 'manage_options' ) ) {
1306 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1307 }
1308
1309 $params = [];
1310
1311 if ( isset( $_POST['delete_all'] ) && $_POST['delete_all'] === 'true' ) {
1312 $params['all'] = true; // API expects 'all', not 'delete_all'
1313 } elseif ( ! empty( $_POST['post_types'] ) ) {
1314 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1315 } elseif ( ! empty( $_POST['post_ids'] ) ) {
1316 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1317 }
1318
1319 $result = $this->delete_content( $params );
1320
1321 if ( is_wp_error( $result ) ) {
1322 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1323 }
1324
1325 wp_send_json_success( $result );
1326 }
1327 }
1328