PluginProbe
wpForo Forum / 3.1.2
wpForo Forum v3.1.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.1.2, at classes/AIWordPressIndexer.php

1,502 lines 43.8 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_with_lock' ] );
131 }
132
133 /**
134 * Wrapper for process_batch() with transient-based lock.
135 *
136 * Prevents concurrent batch processing when inline cron nudge fires
137 * simultaneously with WP-Cron or multiple admin page refreshes.
138 * Follows same pattern as VectorStorageManager::cron_process_queue_mode().
139 *
140 * @return array|WP_Error Result from process_batch() or lock status
141 */
142 public function process_batch_with_lock() {
143 $lock_key = 'wpforo_ai_wp_indexing_lock';
144
145 // Check if already processing
146 if ( get_transient( $lock_key ) ) {
147 // Reschedule as backup (same pattern as forum indexing)
148 if ( ! wp_next_scheduled( 'wpforo_ai_process_wp_batch' ) ) {
149 wp_schedule_single_event( time() + 60, 'wpforo_ai_process_wp_batch' );
150 }
151 return [ 'status' => 'locked', 'message' => 'Another batch is being processed' ];
152 }
153
154 // Acquire lock (300s TTL - matches forum indexing)
155 set_transient( $lock_key, 'processing_' . time(), 300 );
156
157 // Process the batch
158 $result = $this->process_batch();
159
160 // Release lock
161 delete_transient( $lock_key );
162
163 return $result;
164 }
165
166 /**
167 * Check if using local storage mode for WordPress content
168 *
169 * WordPress content indexing uses a global storage mode setting.
170 * This is independent of forum boards - it's a site-wide setting
171 * stored in wpforo_ai_storage_mode_0.
172 *
173 * @return bool True if local mode, false if cloud mode
174 */
175 private function is_local_storage_mode() {
176 $storage_mode = get_option( 'wpforo_ai_storage_mode_0', 'local' );
177 return $storage_mode === 'local';
178 }
179
180 /**
181 * Get the local storage instance for WordPress content
182 *
183 * Uses the VectorStorageManager's local storage instance which has
184 * proper table references initialized. WordPress content uses the
185 * same embeddings table as forum content.
186 *
187 * @return \wpforo\classes\VectorStorageLocal|null Local storage instance or null
188 */
189 private function get_local_storage() {
190 if ( ! isset( WPF()->vector_storage ) || ! WPF()->vector_storage ) {
191 return null;
192 }
193 return WPF()->vector_storage->get_local_storage();
194 }
195
196 /**
197 * Get public post types available for indexing
198 *
199 * @return array Array of post type objects with name, label, and count
200 */
201 public function get_public_post_types() {
202 // 'publicly_queryable' => true, 'public' => true
203 $post_type_arguments = apply_filters('wpforo_ai_wp_indexing_post_types', [ 'public' => true ]);
204 $post_types = get_post_types($post_type_arguments, 'objects');
205
206 // Also include 'page' which has publicly_queryable = false by default
207 $page_type = get_post_type_object( 'page' );
208 if ( $page_type ) {
209 $post_types['page'] = $page_type;
210 }
211
212 // Also include 'post' which may have different settings
213 $post_type = get_post_type_object( 'post' );
214 if ( $post_type ) {
215 $post_types['post'] = $post_type;
216 }
217
218 // Combine skip lists
219 $all_skip_types = array_merge( $this->skip_post_types, $this->skip_post_types_woocommerce );
220
221 $result = [];
222 foreach ( $post_types as $name => $type ) {
223 // Skip internal/structural types and WooCommerce types
224 if ( in_array( $name, $all_skip_types, true ) ) {
225 continue;
226 }
227
228 // Count indexable published posts (for display accuracy)
229 $indexable_count = $this->count_indexable_posts( $name );
230
231 // Include ALL public post types, regardless of current indexable count
232 // Content quality filtering happens at indexing time, not at type listing
233 $result[] = [
234 'name' => $name,
235 'label' => $type->labels->name,
236 'count' => $indexable_count, // Shows indexable posts (may be 0)
237 ];
238 }
239
240 return $result;
241 }
242
243 /**
244 * Count indexable published posts for a post type
245 *
246 * Only counts posts with meaningful textual content.
247 * Results are cached for 5 minutes to improve performance.
248 *
249 * @param string $post_type Post type name
250 * @return int Number of indexable posts
251 */
252 private function count_indexable_posts( $post_type ) {
253 // Check transient cache first
254 $cache_key = 'wpforo_ai_indexable_count_' . sanitize_key( $post_type );
255 $cached = get_transient( $cache_key );
256 if ( false !== $cached ) {
257 return (int) $cached;
258 }
259
260 $query = new \WP_Query( [
261 'post_type' => $post_type,
262 'post_status' => 'publish',
263 'posts_per_page' => -1,
264 'no_found_rows' => true,
265 'update_post_meta_cache' => false,
266 'update_post_term_cache' => false,
267 'fields' => 'ids', // Only fetch IDs first for efficiency
268 ] );
269
270 // If no posts, return 0
271 if ( empty( $query->posts ) ) {
272 set_transient( $cache_key, 0, 5 * MINUTE_IN_SECONDS );
273 return 0;
274 }
275
276 // For small result sets, check all posts
277 // For large sets, sample to estimate (check first 500)
278 $post_ids = $query->posts;
279 $total_posts = count( $post_ids );
280
281 if( apply_filters('wpforo_ai_filter_indexable_post_types', false, $post_ids, $post_type, $total_posts) ){
282 $sample_size = min( 100, $total_posts );
283 $indexable_count = 0;
284
285 // Check sample of posts
286 for ( $i = 0; $i < $sample_size; $i++ ) {
287 $post = get_post( $post_ids[ $i ] );
288 if ( $post && $this->is_content_indexable( $post ) ) {
289 $indexable_count++;
290 }
291 }
292
293 // If we sampled, extrapolate the count
294 if ( $sample_size < $total_posts ) {
295 $ratio = $indexable_count / $sample_size;
296 $indexable_count = (int) round( $total_posts * $ratio );
297 }
298 } else {
299
300 // Keep the original number of post type items
301 $indexable_count = $total_posts;
302 }
303
304
305
306 set_transient( $cache_key, $indexable_count, 5 * MINUTE_IN_SECONDS );
307
308 return $indexable_count;
309 }
310
311 /**
312 * Get public taxonomies for a post type
313 *
314 * @param string $post_type Post type name
315 * @return array Array of taxonomy objects
316 */
317 public function get_taxonomies_for_post_type( $post_type ) {
318 $taxonomies = get_object_taxonomies( $post_type, 'objects' );
319
320 // Combine skip lists
321 $all_skip_taxonomies = array_merge( $this->skip_taxonomies, $this->skip_taxonomies_woocommerce );
322
323 $result = [];
324 foreach ( $taxonomies as $name => $taxonomy ) {
325 // Only include public taxonomies
326 if ( ! $taxonomy->public ) {
327 continue;
328 }
329
330 // Skip internal/structural taxonomies and WooCommerce taxonomies
331 if ( in_array( $name, $all_skip_taxonomies, true ) ) {
332 continue;
333 }
334
335 // Skip WooCommerce product attributes (pa_* prefix)
336 // TODO: Remove this when WooCommerce support is implemented
337 if ( strpos( $name, 'pa_' ) === 0 ) {
338 continue;
339 }
340
341 // Get term count (show all terms, not just those with posts)
342 $count = wp_count_terms( [ 'taxonomy' => $name, 'hide_empty' => false ] );
343
344 $result[] = [
345 'name' => $name,
346 'label' => $taxonomy->labels->name,
347 'term_count' => is_wp_error( $count ) ? 0 : (int) $count,
348 ];
349 }
350
351 return $result;
352 }
353
354 /**
355 * Get terms for a taxonomy
356 *
357 * @param string $taxonomy Taxonomy name
358 * @param bool $hierarchical Whether to return hierarchical structure
359 * @param array $post_types Post types to count (defaults to all public types using this taxonomy)
360 * @return array Array of terms
361 */
362 public function get_taxonomy_terms( $taxonomy, $hierarchical = true, $post_types = [] ) {
363 $args = [
364 'taxonomy' => $taxonomy,
365 'hide_empty' => false, // Show all terms, not just those with posts
366 'orderby' => 'name',
367 'order' => 'ASC',
368 ];
369
370 if ( $hierarchical && is_taxonomy_hierarchical( $taxonomy ) ) {
371 $args['parent'] = 0; // Get top-level terms first
372 }
373
374 $terms = get_terms( $args );
375
376 if ( is_wp_error( $terms ) ) {
377 return [];
378 }
379
380 // Get post types that use this taxonomy if not specified
381 if ( empty( $post_types ) ) {
382 $tax_obj = get_taxonomy( $taxonomy );
383 $post_types = $tax_obj ? $tax_obj->object_type : [ 'post' ];
384 }
385
386 // Get indexed post IDs for counting
387 $indexed_post_ids = $this->get_indexed_post_ids();
388
389 $result = [];
390 foreach ( $terms as $term ) {
391 // Count only published posts for this term
392 $published_count = $this->count_published_posts_in_term( $term->term_id, $taxonomy, $post_types );
393 // Count indexed posts in this term
394 $indexed_count = $this->count_indexed_posts_in_term( $term->term_id, $taxonomy, $post_types, $indexed_post_ids );
395
396 $term_data = [
397 'term_id' => $term->term_id,
398 'name' => $term->name,
399 'slug' => $term->slug,
400 'count' => $published_count,
401 'indexed' => $indexed_count,
402 ];
403
404 // Get children for hierarchical taxonomies
405 if ( $hierarchical && is_taxonomy_hierarchical( $taxonomy ) ) {
406 $children = get_terms( [
407 'taxonomy' => $taxonomy,
408 'hide_empty' => false,
409 'parent' => $term->term_id,
410 'orderby' => 'name',
411 'order' => 'ASC',
412 ] );
413
414 if ( ! is_wp_error( $children ) && ! empty( $children ) ) {
415 $term_data['children'] = [];
416 foreach ( $children as $child ) {
417 $child_published_count = $this->count_published_posts_in_term( $child->term_id, $taxonomy, $post_types );
418 $child_indexed_count = $this->count_indexed_posts_in_term( $child->term_id, $taxonomy, $post_types, $indexed_post_ids );
419
420 $term_data['children'][] = [
421 'term_id' => $child->term_id,
422 'name' => $child->name,
423 'slug' => $child->slug,
424 'count' => $child_published_count,
425 'indexed' => $child_indexed_count,
426 ];
427 }
428 }
429 }
430
431 $result[] = $term_data;
432 }
433
434 return $result;
435 }
436
437 /**
438 * Get all indexed WordPress post IDs
439 *
440 * @return array Array of indexed post IDs
441 */
442 private function get_indexed_post_ids() {
443 if ( $this->is_local_storage_mode() ) {
444 $local = $this->get_local_storage();
445 if ( ! $local ) {
446 return [];
447 }
448 return $local->get_wp_indexed_post_ids();
449 }
450
451 // Cloud mode: fetch indexed post IDs from backend API
452 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
453 return [];
454 }
455
456 $response = WPF()->ai_client->api_get( '/rag/wordpress/indexed-posts' );
457 if ( is_wp_error( $response ) ) {
458 return [];
459 }
460
461 return isset( $response['post_ids'] ) ? array_map( 'intval', $response['post_ids'] ) : [];
462 }
463
464 /**
465 * Count indexed posts in a specific term
466 *
467 * @param int $term_id Term ID
468 * @param string $taxonomy Taxonomy name
469 * @param array $post_types Post types to check
470 * @param array $indexed_post_ids Pre-fetched array of indexed post IDs
471 * @return int Number of indexed posts in the term
472 */
473 private function count_indexed_posts_in_term( $term_id, $taxonomy, $post_types, $indexed_post_ids ) {
474 if ( empty( $indexed_post_ids ) ) {
475 return 0;
476 }
477
478 $query = new \WP_Query( [
479 'post_type' => $post_types,
480 'post_status' => 'publish',
481 'posts_per_page' => -1,
482 'fields' => 'ids',
483 'tax_query' => [
484 [
485 'taxonomy' => $taxonomy,
486 'field' => 'term_id',
487 'terms' => $term_id,
488 ],
489 ],
490 'post__in' => $indexed_post_ids,
491 ] );
492
493 return $query->found_posts;
494 }
495
496 /**
497 * Count published and indexable posts in a specific term
498 *
499 * Only counts posts that have meaningful textual content (not just shortcodes,
500 * not binary data, and at least MIN_CONTENT_LENGTH characters).
501 * Results are cached for 5 minutes to improve performance.
502 *
503 * @param int $term_id Term ID
504 * @param string $taxonomy Taxonomy name
505 * @param array $post_types Post types to count
506 * @return int Number of indexable published posts
507 */
508 private function count_published_posts_in_term( $term_id, $taxonomy, $post_types = [ 'post' ] ) {
509 // Check transient cache first
510 $cache_key = 'wpforo_ai_term_count_' . $term_id . '_' . sanitize_key( $taxonomy );
511 $cached = get_transient( $cache_key );
512 if ( false !== $cached ) {
513 return (int) $cached;
514 }
515
516 $query = new \WP_Query( [
517 'post_type' => $post_types,
518 'post_status' => 'publish',
519 'posts_per_page' => -1,
520 'tax_query' => [
521 [
522 'taxonomy' => $taxonomy,
523 'field' => 'term_id',
524 'terms' => $term_id,
525 ],
526 ],
527 'no_found_rows' => true,
528 'update_post_meta_cache' => false,
529 'update_post_term_cache' => false,
530 'fields' => 'ids', // Only fetch IDs first
531 ] );
532
533 // If no posts, return 0
534 if ( empty( $query->posts ) ) {
535 set_transient( $cache_key, 0, 5 * MINUTE_IN_SECONDS );
536 return 0;
537 }
538
539 $post_ids = $query->posts;
540 $total_posts = count( $post_ids );
541 $sample_size = min( 200, $total_posts ); // Smaller sample for term counts
542 $indexable_count = 0;
543
544 for ( $i = 0; $i < $sample_size; $i++ ) {
545 $post = get_post( $post_ids[ $i ] );
546 if ( $post && $this->is_content_indexable( $post ) ) {
547 $indexable_count++;
548 }
549 }
550
551 // Extrapolate if sampled
552 if ( $sample_size < $total_posts ) {
553 $ratio = $indexable_count / $sample_size;
554 $indexable_count = (int) round( $total_posts * $ratio );
555 }
556
557 set_transient( $cache_key, $indexable_count, 5 * MINUTE_IN_SECONDS );
558
559 return $indexable_count;
560 }
561
562 /**
563 * Get WordPress posts for indexing
564 *
565 * @param array $args Query arguments
566 * @return array Array of formatted post data
567 */
568 public function get_posts_for_indexing( $args = [] ) {
569 $defaults = [
570 'post_type' => 'post',
571 'post_status' => 'publish',
572 'posts_per_page' => self::BATCH_SIZE,
573 'paged' => 1,
574 'orderby' => 'ID',
575 'order' => 'ASC',
576 ];
577
578 $args = wp_parse_args( $args, $defaults );
579
580 // Ensure we only get published posts
581 $args['post_status'] = 'publish';
582
583 $query = new \WP_Query( $args );
584 $posts = [];
585
586 foreach ( $query->posts as $post ) {
587 $posts[] = $this->format_post_for_indexing( $post );
588 }
589
590 return [
591 'posts' => $posts,
592 'total' => $query->found_posts,
593 'total_pages' => $query->max_num_pages,
594 'current' => $args['paged'],
595 ];
596 }
597
598 /**
599 * Format a WordPress post for indexing
600 *
601 * @param \WP_Post $post WordPress post object
602 * @return array Formatted post data for API
603 */
604 public function format_post_for_indexing( $post ) {
605 // Get taxonomy terms
606 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
607 $taxonomy_terms = [];
608
609 foreach ( $taxonomies as $taxonomy ) {
610 $terms = wp_get_post_terms( $post->ID, $taxonomy, [ 'fields' => 'names' ] );
611 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
612 $tax_obj = get_taxonomy( $taxonomy );
613 $tax_label = $tax_obj ? $tax_obj->labels->singular_name : $taxonomy;
614 $taxonomy_terms[ $tax_label ] = $terms;
615 }
616 }
617
618 // Get important post meta (configurable)
619 $post_meta = $this->get_indexable_post_meta( $post );
620
621 return [
622 'post_id' => $post->ID,
623 'post_type' => $post->post_type,
624 'title' => $post->post_title,
625 'content' => $post->post_content,
626 'excerpt' => $post->post_excerpt,
627 'author_id' => (int) $post->post_author,
628 'post_status' => $post->post_status,
629 'permalink' => get_permalink( $post->ID ),
630 'created_at' => $post->post_date_gmt,
631 'updated_at' => $post->post_modified_gmt,
632 'taxonomy_terms' => $taxonomy_terms,
633 'post_meta' => $post_meta,
634 ];
635 }
636
637 /**
638 * Get indexable post meta
639 *
640 * @param \WP_Post $post WordPress post object
641 * @return array Filtered post meta
642 */
643 private function get_indexable_post_meta( $post ) {
644 $meta = [];
645
646 // WooCommerce product meta
647 if ( $post->post_type === 'product' ) {
648 $meta['_price'] = get_post_meta( $post->ID, '_price', true );
649 $meta['_sku'] = get_post_meta( $post->ID, '_sku', true );
650 $meta['_stock_status'] = get_post_meta( $post->ID, '_stock_status', true );
651 }
652
653 // Allow plugins to add custom meta
654 $meta = apply_filters( 'wpforo_ai_indexable_post_meta', $meta, $post );
655
656 // Remove empty values
657 return array_filter( $meta, function( $v ) {
658 return $v !== '' && $v !== null;
659 } );
660 }
661
662 /**
663 * Check if post content is suitable for indexing
664 *
665 * Validates that content is:
666 * - Not just shortcodes
667 * - Has meaningful text (>= MIN_CONTENT_LENGTH chars after stripping)
668 * - Is textual (not binary/garbage data)
669 *
670 * @param \WP_Post|int $post Post object or ID
671 * @return bool True if content is indexable
672 */
673 public function is_content_indexable( $post ) {
674 if ( is_numeric( $post ) ) {
675 $post = get_post( $post );
676 }
677
678 if ( ! $post || ! isset( $post->post_content ) ) {
679 return false;
680 }
681
682 $content = $post->post_content;
683
684 // Check for binary/non-textual content
685 // Binary data often contains null bytes or high ratio of non-printable characters
686 if ( $this->is_binary_content( $content ) ) {
687 return false;
688 }
689
690 // Strip shortcodes first (e.g., [gallery], [contact-form-7 id="123"])
691 $content = strip_shortcodes( $content );
692
693 // Strip all HTML tags
694 $content = wp_strip_all_tags( $content );
695
696 // Decode HTML entities
697 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
698
699 // Normalize whitespace
700 $content = preg_replace( '/\s+/', ' ', $content );
701 $content = trim( $content );
702
703 // Check minimum length
704 $length = mb_strlen( $content, 'UTF-8' );
705
706 return $length >= self::MIN_CONTENT_LENGTH;
707 }
708
709 /**
710 * Check if content appears to be binary/non-textual data
711 *
712 * @param string $content Content to check
713 * @return bool True if content appears to be binary
714 */
715 private function is_binary_content( $content ) {
716 if ( empty( $content ) ) {
717 return false;
718 }
719
720 // Check for null bytes (common in binary data)
721 if ( strpos( $content, "\0" ) !== false ) {
722 return true;
723 }
724
725 // Sample the content (check first 1000 bytes for performance)
726 $sample = substr( $content, 0, 1000 );
727 $sample_length = strlen( $sample );
728
729 if ( $sample_length === 0 ) {
730 return false;
731 }
732
733 // Count non-printable characters (excluding common whitespace)
734 $non_printable = 0;
735 for ( $i = 0; $i < $sample_length; $i++ ) {
736 $ord = ord( $sample[ $i ] );
737 // Allow: tab (9), newline (10), carriage return (13), space and above (32-126)
738 // Allow extended ASCII/UTF-8 (128+)
739 if ( $ord < 9 || ( $ord > 13 && $ord < 32 ) || ( $ord > 126 && $ord < 128 ) ) {
740 $non_printable++;
741 }
742 }
743
744 // If more than 10% is non-printable, likely binary
745 $ratio = $non_printable / $sample_length;
746
747 return $ratio > 0.1;
748 }
749
750 /**
751 * Get the clean text content for a post (for display/counting purposes)
752 *
753 * @param \WP_Post|int $post Post object or ID
754 * @return string Clean text content
755 */
756 public function get_clean_content( $post ) {
757 if ( is_numeric( $post ) ) {
758 $post = get_post( $post );
759 }
760
761 if ( ! $post || ! isset( $post->post_content ) ) {
762 return '';
763 }
764
765 $content = $post->post_content;
766 $content = strip_shortcodes( $content );
767 $content = wp_strip_all_tags( $content );
768 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
769 $content = preg_replace( '/\s+/', ' ', $content );
770
771 return trim( $content );
772 }
773
774 /**
775 * Index posts by taxonomy term(s)
776 *
777 * @param string $taxonomy Taxonomy name
778 * @param int|array $term_ids Term ID or array of term IDs
779 * @param array $post_types Post types to index
780 * @param string $date_from Optional start date (Y-m-d format)
781 * @param string $date_to Optional end date (Y-m-d format)
782 * @return array|WP_Error Result or error
783 */
784 public function index_by_taxonomy( $taxonomy, $term_ids, $post_types = [ 'post' ], $date_from = '', $date_to = '' ) {
785 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
786 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
787 }
788
789 // Ensure term_ids is an array
790 $term_ids = (array) $term_ids;
791 $term_ids = array_map( 'intval', $term_ids );
792 $term_ids = array_filter( $term_ids ); // Remove zeros
793
794 if ( empty( $term_ids ) ) {
795 return new \WP_Error( 'no_terms', __( 'No valid terms specified', 'wpforo' ) );
796 }
797
798 // Get all posts in these terms
799 $args = [
800 'post_type' => $post_types,
801 'posts_per_page' => -1, // Get all
802 'post_status' => 'publish',
803 'tax_query' => [
804 [
805 'taxonomy' => $taxonomy,
806 'field' => 'term_id',
807 'terms' => $term_ids,
808 ],
809 ],
810 'fields' => 'ids',
811 ];
812
813 // Add date range filter if specified
814 if ( ! empty( $date_from ) ) {
815 $args['date_query'][] = [
816 'after' => $date_from,
817 'inclusive' => true,
818 ];
819 }
820
821 if ( ! empty( $date_to ) ) {
822 $args['date_query'][] = [
823 'before' => $date_to,
824 'inclusive' => true,
825 ];
826 }
827
828 $query = new \WP_Query( $args );
829 $post_ids = $query->posts;
830
831 if ( empty( $post_ids ) ) {
832 return new \WP_Error( 'no_posts', __( 'No posts found in this term', 'wpforo' ) );
833 }
834
835 // Queue posts for batch indexing
836 return $this->queue_posts_for_indexing( $post_ids );
837 }
838
839 /**
840 * Index posts with custom filters
841 *
842 * @param array $params Custom indexing parameters
843 * @return array|WP_Error Result or error
844 */
845 public function index_custom( $params ) {
846 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
847 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
848 }
849
850 $args = [
851 'post_type' => isset( $params['post_types'] ) ? $params['post_types'] : [ 'post' ],
852 'posts_per_page' => -1,
853 'post_status' => 'publish',
854 'fields' => 'ids',
855 ];
856
857 // Date range filter
858 if ( ! empty( $params['date_from'] ) ) {
859 $args['date_query'][] = [
860 'after' => $params['date_from'],
861 'inclusive' => true,
862 ];
863 }
864
865 if ( ! empty( $params['date_to'] ) ) {
866 $args['date_query'][] = [
867 'before' => $params['date_to'],
868 'inclusive' => true,
869 ];
870 }
871
872 // Specific post IDs
873 if ( ! empty( $params['post_ids'] ) ) {
874 $args['post__in'] = array_map( 'intval', (array) $params['post_ids'] );
875 }
876
877 // Author filter
878 if ( ! empty( $params['author'] ) ) {
879 $args['author'] = intval( $params['author'] );
880 }
881
882 $query = new \WP_Query( $args );
883 $post_ids = $query->posts;
884
885 if ( empty( $post_ids ) ) {
886 return new \WP_Error( 'no_posts', __( 'No posts found matching criteria', 'wpforo' ) );
887 }
888
889 return $this->queue_posts_for_indexing( $post_ids );
890 }
891
892 /**
893 * Queue posts for batch indexing
894 *
895 * @param array $post_ids Array of post IDs to index
896 * @return array Result with job info
897 */
898 public function queue_posts_for_indexing( $post_ids ) {
899 $batches = array_chunk( $post_ids, self::BATCH_SIZE );
900 $job_id = 'wp_index_' . uniqid();
901 $total_posts = count( $post_ids );
902
903 // Clear the status cache so polling gets fresh data
904 delete_transient( 'wpforo_ai_wp_indexing_status' );
905
906 // Store queue in options for processing
907 update_option( 'wpforo_ai_wp_indexing_queue', [
908 'job_id' => $job_id,
909 'batches' => $batches,
910 'current' => 0,
911 'total_posts' => $total_posts,
912 'indexed' => 0,
913 'failed' => 0,
914 'skipped' => 0,
915 'status' => 'processing',
916 'started_at' => current_time( 'mysql', true ),
917 ] );
918
919 // Schedule first batch
920 wp_schedule_single_event( time() + 1, 'wpforo_ai_process_wp_batch' );
921
922 return [
923 'job_id' => $job_id,
924 'total_posts' => $total_posts,
925 'batches' => count( $batches ),
926 'status' => 'queued',
927 ];
928 }
929
930 /**
931 * Process a batch of posts for indexing
932 *
933 * @return array|WP_Error Result or error
934 */
935 public function process_batch() {
936 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
937
938 if ( empty( $queue ) || empty( $queue['batches'] ) ) {
939 return new \WP_Error( 'no_queue', 'No indexing queue found' );
940 }
941
942 $current_batch_index = $queue['current'];
943
944 if ( ! isset( $queue['batches'][ $current_batch_index ] ) ) {
945 // All batches processed
946 delete_option( 'wpforo_ai_wp_indexing_queue' );
947 return [ 'status' => 'completed' ];
948 }
949
950 $post_ids = $queue['batches'][ $current_batch_index ];
951 $posts = [];
952 $skipped = 0;
953
954 foreach ( $post_ids as $post_id ) {
955 $post = get_post( $post_id );
956 if ( $post && $post->post_status === 'publish' ) {
957 // Skip posts with non-indexable content (shortcodes only, binary, too short)
958 if ( ! $this->is_content_indexable( $post ) ) {
959 $skipped++;
960 continue;
961 }
962 $posts[] = $this->format_post_for_indexing( $post );
963 }
964 }
965
966 // Track skipped posts
967 if ( ! isset( $queue['skipped'] ) ) {
968 $queue['skipped'] = 0;
969 }
970 $queue['skipped'] += $skipped;
971
972 if ( empty( $posts ) ) {
973 // Move to next batch
974 $queue['current']++;
975 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
976
977 // Schedule next batch
978 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
979 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
980 }
981
982 return [ 'status' => 'batch_empty', 'current' => $queue['current'] ];
983 }
984
985 // Check storage mode — local mode stores embeddings in WordPress DB
986 // WordPress content uses the global storage mode setting (board 0)
987 if ( $this->is_local_storage_mode() ) {
988 $local_result = $this->process_batch_local( $posts, $queue );
989
990 return $local_result;
991 }
992
993 // CLOUD MODE: Send to cloud API
994 $response = WPF()->ai_client->api_post( '/rag/wordpress/ingest', [
995 'posts' => $posts,
996 'chunk_size' => 512,
997 'overlap_percent' => 20,
998 ], 120 );
999
1000 if ( is_wp_error( $response ) ) {
1001 $queue['failed'] += count( $post_ids );
1002 } else {
1003 $queue['indexed'] += count( $posts );
1004 }
1005
1006 // Move to next batch
1007 $queue['current']++;
1008 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1009
1010 // Schedule next batch
1011 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
1012 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
1013 } else {
1014 // All done
1015 $queue['status'] = 'completed';
1016 $queue['completed_at'] = current_time( 'mysql', true );
1017 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1018
1019 // Clear cache
1020 delete_transient( 'wpforo_ai_wp_indexing_status' );
1021 }
1022
1023 return [
1024 'status' => 'processing',
1025 'current' => $queue['current'],
1026 'indexed' => $queue['indexed'],
1027 'failed' => $queue['failed'],
1028 'skipped' => $queue['skipped'],
1029 ];
1030 }
1031
1032 /**
1033 * Process a batch of posts for local storage mode
1034 *
1035 * Generates embeddings via cloud API but stores them in WordPress DB
1036 * instead of cloud vector storage. Uses content_hash dedup to skip
1037 * unchanged posts.
1038 *
1039 * @param array $posts Formatted post data from format_post_for_indexing()
1040 * @param array $queue Current queue state (modified by reference via option update)
1041 * @return array Result with status, indexed, failed, skipped counts
1042 */
1043 private function process_batch_local( $posts, $queue ) {
1044 $local = $this->get_local_storage();
1045 if ( ! $local ) {
1046 return [
1047 'status' => 'error',
1048 'message' => 'Local storage is not available',
1049 'indexed' => 0,
1050 'failed' => count( $posts ),
1051 'skipped' => 0,
1052 ];
1053 }
1054
1055 $indexed = 0;
1056 $failed = 0;
1057 $skipped = 0;
1058
1059 foreach ( $posts as $post_data ) {
1060 $post_id = $post_data['post_id'];
1061 $post_type = $post_data['post_type'];
1062
1063 // Build text content for embedding
1064 $content_parts = [];
1065 if ( ! empty( $post_data['title'] ) ) {
1066 $content_parts[] = $post_data['title'];
1067 }
1068 if ( ! empty( $post_data['excerpt'] ) ) {
1069 $content_parts[] = wp_strip_all_tags( $post_data['excerpt'] );
1070 }
1071 if ( ! empty( $post_data['content'] ) ) {
1072 $clean_content = strip_shortcodes( $post_data['content'] );
1073 $clean_content = wp_strip_all_tags( $clean_content );
1074 $clean_content = html_entity_decode( $clean_content, ENT_QUOTES, 'UTF-8' );
1075 $clean_content = preg_replace( '/\s+/', ' ', trim( $clean_content ) );
1076 $content_parts[] = $clean_content;
1077 }
1078
1079 // Add taxonomy context
1080 if ( ! empty( $post_data['taxonomy_terms'] ) ) {
1081 foreach ( $post_data['taxonomy_terms'] as $tax_label => $terms ) {
1082 $content_parts[] = $tax_label . ': ' . implode( ', ', $terms );
1083 }
1084 }
1085
1086 $content = implode( "\n\n", $content_parts );
1087
1088 // Truncate to fit within embedding model's token limit
1089 // Titan Embed v2: 8192 tokens ≈ 28000 chars (safe margin)
1090 $max_chars = 28000;
1091 if ( mb_strlen( $content, 'UTF-8' ) > $max_chars ) {
1092 $content = mb_substr( $content, 0, $max_chars, 'UTF-8' );
1093 }
1094
1095 $content_hash = md5( $content );
1096
1097 // Check if already indexed with same content (dedup)
1098 $existing = $local->get_embedding( $post_id );
1099 if ( $existing && $existing['content_hash'] === $content_hash ) {
1100 $skipped++;
1101 continue;
1102 }
1103
1104 // Generate embedding via cloud API
1105 $result = WPF()->ai_client->generate_embedding( $content );
1106 $embedding = is_wp_error( $result ) ? $result : ( $result['embedding'] ?? null );
1107
1108 if ( is_wp_error( $embedding ) || ! is_array( $embedding ) ) {
1109 $error_msg = is_wp_error( $embedding ) ? $embedding->get_error_message() : 'Invalid embedding response';
1110 \wpforo_ai_log( 'error', sprintf(
1111 'Failed to generate embedding for WP post %d: %s',
1112 $post_id,
1113 $error_msg
1114 ), 'WPIndexer' );
1115 $failed++;
1116 continue;
1117 }
1118
1119 // Build content preview
1120 $preview = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post_data['content'] ?? '' ) ), 80, '...' );
1121
1122 // Store locally with content_type = post_type
1123 $stored = $local->store_embedding(
1124 0, // topicid (not a forum topic)
1125 $post_id, // postid = WP post ID
1126 0, // forumid (not a forum)
1127 (int) $post_data['author_id'],
1128 $embedding,
1129 $content_hash,
1130 $preview,
1131 'amazon.titan-embed-text-v2',
1132 $post_type // content_type = post type (page, post, product, etc.)
1133 );
1134
1135 if ( $stored ) {
1136 $indexed++;
1137 } else {
1138 $failed++;
1139 }
1140 }
1141
1142 // Update queue progress
1143 $queue['indexed'] += $indexed;
1144 $queue['failed'] += $failed;
1145 $queue['skipped'] = ( $queue['skipped'] ?? 0 ) + $skipped;
1146
1147 // Move to next batch
1148 $queue['current']++;
1149 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1150
1151 // Schedule next batch
1152 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
1153 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
1154 } else {
1155 // All done
1156 $queue['status'] = 'completed';
1157 $queue['completed_at'] = current_time( 'mysql', true );
1158 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1159 delete_transient( 'wpforo_ai_wp_indexing_status' );
1160 }
1161
1162 return [
1163 'status' => 'processing',
1164 'current' => $queue['current'],
1165 'indexed' => $queue['indexed'],
1166 'failed' => $queue['failed'],
1167 'skipped' => $queue['skipped'] ?? 0,
1168 ];
1169 }
1170
1171 /**
1172 * Get WordPress content indexing status
1173 *
1174 * @return array|WP_Error Status data or error
1175 */
1176 public function get_indexing_status( $skip_cache = false ) {
1177 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1178 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1179 }
1180
1181 // Check cache (skip if explicitly requested or if indexing is in progress)
1182 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1183 $is_processing = ! empty( $queue ) && isset( $queue['status'] ) && $queue['status'] === 'processing';
1184
1185 if ( ! $skip_cache && ! $is_processing ) {
1186 $cached = get_transient( 'wpforo_ai_wp_indexing_status' );
1187 if ( $cached !== false ) {
1188 return $cached;
1189 }
1190 }
1191
1192 // Get indexed counts — source depends on storage mode
1193 if ( $this->is_local_storage_mode() ) {
1194 // LOCAL mode: count from WordPress ai_embeddings table
1195 $local = $this->get_local_storage();
1196 if ( ! $local ) {
1197 return new \WP_Error( 'storage_unavailable', __( 'Local storage is not available', 'wpforo' ) );
1198 }
1199 $indexed_counts = $local->get_wp_indexed_counts();
1200 $response = [
1201 'content_source' => 'wordpress',
1202 'indexed_counts' => $indexed_counts,
1203 'total_indexed' => array_sum( $indexed_counts ),
1204 ];
1205 } else {
1206 // CLOUD mode: query backend API (has sync_state records)
1207 $response = WPF()->ai_client->api_get( '/rag/wordpress/status' );
1208 if ( is_wp_error( $response ) ) {
1209 return $response;
1210 }
1211 }
1212
1213 // Build by_type structure that JavaScript expects
1214 $post_types = $this->get_public_post_types();
1215 $indexed_counts = isset( $response['indexed_counts'] ) ? $response['indexed_counts'] : [];
1216 $by_type = [];
1217
1218 foreach ( $post_types as $type ) {
1219 $type_key = 'wp_' . $type['name'];
1220 $indexed = isset( $indexed_counts[ $type_key ] ) ? (int) $indexed_counts[ $type_key ] : 0;
1221 $total = (int) $type['count'];
1222 $by_type[ $type_key ] = [
1223 'indexed' => $indexed,
1224 'total' => $total,
1225 'percentage' => $total > 0 ? round( ( $indexed / $total ) * 100, 1 ) : 0,
1226 ];
1227 }
1228
1229 $response['by_type'] = $by_type;
1230 $response['total_indexed'] = isset( $response['total_indexed'] ) ? (int) $response['total_indexed'] : 0;
1231
1232 // Check if there's an active queue
1233 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1234 $is_indexing = false;
1235 if ( ! empty( $queue ) ) {
1236 $queue_status = isset( $queue['status'] ) ? $queue['status'] : 'processing';
1237 $is_indexing = ( $queue_status === 'processing' );
1238 $response['queue'] = [
1239 'job_id' => $queue['job_id'],
1240 'total_posts' => $queue['total_posts'],
1241 'indexed' => $queue['indexed'],
1242 'failed' => $queue['failed'],
1243 'current' => $queue['current'],
1244 'total' => count( $queue['batches'] ),
1245 'status' => $queue_status,
1246 ];
1247 }
1248
1249 // Add is_indexing flag for UI
1250 $response['is_indexing'] = $is_indexing;
1251
1252 // Get last activity timestamp from queue or sync_state
1253 $last_indexed_at = null;
1254 if ( ! empty( $queue['completed_at'] ) ) {
1255 $last_indexed_at = $queue['completed_at'];
1256 } elseif ( ! empty( $queue['started_at'] ) ) {
1257 $last_indexed_at = $queue['started_at'];
1258 }
1259 $response['last_indexed_at'] = $last_indexed_at;
1260 // Format date using WordPress function (always available in AJAX context)
1261 $response['last_indexed_at_formatted'] = $last_indexed_at
1262 ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $last_indexed_at ) )
1263 : null;
1264
1265 // Cache for 5 minutes
1266 set_transient( 'wpforo_ai_wp_indexing_status', $response, 5 * MINUTE_IN_SECONDS );
1267
1268 return $response;
1269 }
1270
1271 /**
1272 * Delete WordPress content from index
1273 *
1274 * @param array $params Delete parameters (post_types, post_ids, all)
1275 * @return array|WP_Error Result or error
1276 */
1277 public function delete_content( $params ) {
1278 if ( $this->is_local_storage_mode() ) {
1279 // LOCAL mode: delete from WordPress ai_embeddings table
1280 $local = $this->get_local_storage();
1281 if ( ! $local ) {
1282 return new \WP_Error( 'storage_unavailable', __( 'Local storage is not available', 'wpforo' ) );
1283 }
1284
1285 $post_types = isset( $params['post_types'] ) ? $params['post_types'] : null;
1286 $post_ids = isset( $params['post_ids'] ) ? $params['post_ids'] : null;
1287
1288 // 'all' flag means delete all non-forum CPT embeddings
1289 if ( ! empty( $params['all'] ) ) {
1290 $post_types = null;
1291 $post_ids = null;
1292 }
1293
1294 $deleted = $local->delete_wp_embeddings( $post_types, $post_ids );
1295
1296 // Clear cache
1297 delete_transient( 'wpforo_ai_wp_indexing_status' );
1298
1299 return [
1300 'deleted' => $deleted,
1301 'message' => sprintf( 'Deleted %d embeddings from local storage.', $deleted ),
1302 ];
1303 }
1304
1305 // CLOUD mode: delete via backend API
1306 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1307 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1308 }
1309
1310 $response = WPF()->ai_client->api_post( '/rag/wordpress/delete', $params, 60 );
1311
1312 if ( ! is_wp_error( $response ) ) {
1313 // Clear cache
1314 delete_transient( 'wpforo_ai_wp_indexing_status' );
1315 }
1316
1317 return $response;
1318 }
1319
1320 // ===============================
1321 // AJAX Handlers
1322 // ===============================
1323
1324 /**
1325 * AJAX: Get public post types
1326 */
1327 public function ajax_get_post_types() {
1328 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1329
1330 if ( ! current_user_can( 'manage_options' ) ) {
1331 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1332 }
1333
1334 $post_types = $this->get_public_post_types();
1335 wp_send_json_success( [ 'post_types' => $post_types ] );
1336 }
1337
1338 /**
1339 * AJAX: Get taxonomies for post type
1340 */
1341 public function ajax_get_taxonomies() {
1342 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1343
1344 if ( ! current_user_can( 'manage_options' ) ) {
1345 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1346 }
1347
1348 $post_type = isset( $_POST['post_type'] ) ? sanitize_key( $_POST['post_type'] ) : 'post';
1349 $taxonomies = $this->get_taxonomies_for_post_type( $post_type );
1350
1351 wp_send_json_success( [ 'taxonomies' => $taxonomies ] );
1352 }
1353
1354 /**
1355 * AJAX: Get terms for taxonomy
1356 */
1357 public function ajax_get_taxonomy_terms() {
1358 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1359
1360 if ( ! current_user_can( 'manage_options' ) ) {
1361 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1362 }
1363
1364 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : 'category';
1365
1366 // Get post types if provided (to count only published posts for specific types)
1367 $post_types = [];
1368 if ( ! empty( $_POST['post_types'] ) ) {
1369 $post_types = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1370 }
1371
1372 $terms = $this->get_taxonomy_terms( $taxonomy, true, $post_types );
1373
1374 wp_send_json_success( [ 'terms' => $terms ] );
1375 }
1376
1377 /**
1378 * AJAX: Get indexing status
1379 */
1380 public function ajax_get_indexing_status() {
1381 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1382
1383 if ( ! current_user_can( 'manage_options' ) ) {
1384 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1385 }
1386
1387 $status = $this->get_indexing_status();
1388
1389 if ( is_wp_error( $status ) ) {
1390 wp_send_json_error( [ 'message' => $status->get_error_message() ] );
1391 }
1392
1393 wp_send_json_success( $status );
1394 }
1395
1396 /**
1397 * AJAX: Index by taxonomy
1398 */
1399 public function ajax_index_by_taxonomy() {
1400 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1401
1402 if ( ! current_user_can( 'manage_options' ) ) {
1403 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1404 }
1405
1406 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '';
1407 $post_types = isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : [ 'post' ];
1408 $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( $_POST['date_from'] ) : '';
1409 $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( $_POST['date_to'] ) : '';
1410
1411 // Support both single term_id (legacy) and multiple term_ids
1412 $term_ids = [];
1413 if ( isset( $_POST['term_ids'] ) && is_array( $_POST['term_ids'] ) ) {
1414 $term_ids = array_map( 'intval', $_POST['term_ids'] );
1415 } elseif ( isset( $_POST['term_id'] ) ) {
1416 $term_ids = [ intval( $_POST['term_id'] ) ];
1417 }
1418
1419 if ( empty( $taxonomy ) || empty( $term_ids ) ) {
1420 wp_send_json_error( [ 'message' => __( 'Invalid taxonomy or term', 'wpforo' ) ] );
1421 }
1422
1423 $result = $this->index_by_taxonomy( $taxonomy, $term_ids, $post_types, $date_from, $date_to );
1424
1425 if ( is_wp_error( $result ) ) {
1426 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1427 }
1428
1429 wp_send_json_success( $result );
1430 }
1431
1432 /**
1433 * AJAX: Custom indexing
1434 */
1435 public function ajax_index_custom() {
1436 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1437
1438 if ( ! current_user_can( 'manage_options' ) ) {
1439 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1440 }
1441
1442 $params = [];
1443
1444 if ( ! empty( $_POST['post_types'] ) ) {
1445 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1446 }
1447
1448 if ( ! empty( $_POST['date_from'] ) ) {
1449 $params['date_from'] = sanitize_text_field( $_POST['date_from'] );
1450 }
1451
1452 if ( ! empty( $_POST['date_to'] ) ) {
1453 $params['date_to'] = sanitize_text_field( $_POST['date_to'] );
1454 }
1455
1456 if ( ! empty( $_POST['post_ids'] ) ) {
1457 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1458 }
1459
1460 if ( ! empty( $_POST['author'] ) ) {
1461 $params['author'] = intval( $_POST['author'] );
1462 }
1463
1464 $result = $this->index_custom( $params );
1465
1466 if ( is_wp_error( $result ) ) {
1467 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1468 }
1469
1470 wp_send_json_success( $result );
1471 }
1472
1473 /**
1474 * AJAX: Delete content
1475 */
1476 public function ajax_delete_content() {
1477 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1478
1479 if ( ! current_user_can( 'manage_options' ) ) {
1480 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1481 }
1482
1483 $params = [];
1484
1485 if ( isset( $_POST['delete_all'] ) && $_POST['delete_all'] === 'true' ) {
1486 $params['all'] = true; // API expects 'all', not 'delete_all'
1487 } elseif ( ! empty( $_POST['post_types'] ) ) {
1488 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1489 } elseif ( ! empty( $_POST['post_ids'] ) ) {
1490 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1491 }
1492
1493 $result = $this->delete_content( $params );
1494
1495 if ( is_wp_error( $result ) ) {
1496 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1497 }
1498
1499 wp_send_json_success( $result );
1500 }
1501 }
1502