PluginProbe
wpForo Forum / 3.1.4
wpForo Forum v3.1.4
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.4, at classes/AIWordPressIndexer.php

1,511 lines 44.1 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 // Skip password-protected posts - their content should not be searchable
588 if ( ! empty( $post->post_password ) ) {
589 continue;
590 }
591 $posts[] = $this->format_post_for_indexing( $post );
592 }
593
594 return [
595 'posts' => $posts,
596 'total' => $query->found_posts,
597 'total_pages' => $query->max_num_pages,
598 'current' => $args['paged'],
599 ];
600 }
601
602 /**
603 * Format a WordPress post for indexing
604 *
605 * @param \WP_Post $post WordPress post object
606 * @return array Formatted post data for API
607 */
608 public function format_post_for_indexing( $post ) {
609 // Get taxonomy terms
610 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
611 $taxonomy_terms = [];
612
613 foreach ( $taxonomies as $taxonomy ) {
614 $terms = wp_get_post_terms( $post->ID, $taxonomy, [ 'fields' => 'names' ] );
615 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
616 $tax_obj = get_taxonomy( $taxonomy );
617 $tax_label = $tax_obj ? $tax_obj->labels->singular_name : $taxonomy;
618 $taxonomy_terms[ $tax_label ] = $terms;
619 }
620 }
621
622 // Get important post meta (configurable)
623 $post_meta = $this->get_indexable_post_meta( $post );
624
625 return [
626 'post_id' => $post->ID,
627 'post_type' => $post->post_type,
628 'title' => $post->post_title,
629 'content' => $post->post_content,
630 'excerpt' => $post->post_excerpt,
631 'author_id' => (int) $post->post_author,
632 'post_status' => $post->post_status,
633 'permalink' => get_permalink( $post->ID ),
634 'created_at' => $post->post_date_gmt,
635 'updated_at' => $post->post_modified_gmt,
636 'taxonomy_terms' => $taxonomy_terms,
637 'post_meta' => $post_meta,
638 ];
639 }
640
641 /**
642 * Get indexable post meta
643 *
644 * @param \WP_Post $post WordPress post object
645 * @return array Filtered post meta
646 */
647 private function get_indexable_post_meta( $post ) {
648 $meta = [];
649
650 // WooCommerce product meta
651 if ( $post->post_type === 'product' ) {
652 $meta['_price'] = get_post_meta( $post->ID, '_price', true );
653 $meta['_sku'] = get_post_meta( $post->ID, '_sku', true );
654 $meta['_stock_status'] = get_post_meta( $post->ID, '_stock_status', true );
655 }
656
657 // Allow plugins to add custom meta
658 $meta = apply_filters( 'wpforo_ai_indexable_post_meta', $meta, $post );
659
660 // Remove empty values
661 return array_filter( $meta, function( $v ) {
662 return $v !== '' && $v !== null;
663 } );
664 }
665
666 /**
667 * Check if post content is suitable for indexing
668 *
669 * Validates that content is:
670 * - Not just shortcodes
671 * - Has meaningful text (>= MIN_CONTENT_LENGTH chars after stripping)
672 * - Is textual (not binary/garbage data)
673 *
674 * @param \WP_Post|int $post Post object or ID
675 * @return bool True if content is indexable
676 */
677 public function is_content_indexable( $post ) {
678 if ( is_numeric( $post ) ) {
679 $post = get_post( $post );
680 }
681
682 if ( ! $post || ! isset( $post->post_content ) ) {
683 return false;
684 }
685
686 $content = $post->post_content;
687
688 // Check for binary/non-textual content
689 // Binary data often contains null bytes or high ratio of non-printable characters
690 if ( $this->is_binary_content( $content ) ) {
691 return false;
692 }
693
694 // Strip shortcodes first (e.g., [gallery], [contact-form-7 id="123"])
695 $content = strip_shortcodes( $content );
696
697 // Strip all HTML tags
698 $content = wp_strip_all_tags( $content );
699
700 // Decode HTML entities
701 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
702
703 // Normalize whitespace
704 $content = preg_replace( '/\s+/', ' ', $content );
705 $content = trim( $content );
706
707 // Check minimum length
708 $length = mb_strlen( $content, 'UTF-8' );
709
710 return $length >= self::MIN_CONTENT_LENGTH;
711 }
712
713 /**
714 * Check if content appears to be binary/non-textual data
715 *
716 * @param string $content Content to check
717 * @return bool True if content appears to be binary
718 */
719 private function is_binary_content( $content ) {
720 if ( empty( $content ) ) {
721 return false;
722 }
723
724 // Check for null bytes (common in binary data)
725 if ( strpos( $content, "\0" ) !== false ) {
726 return true;
727 }
728
729 // Sample the content (check first 1000 bytes for performance)
730 $sample = substr( $content, 0, 1000 );
731 $sample_length = strlen( $sample );
732
733 if ( $sample_length === 0 ) {
734 return false;
735 }
736
737 // Count non-printable characters (excluding common whitespace)
738 $non_printable = 0;
739 for ( $i = 0; $i < $sample_length; $i++ ) {
740 $ord = ord( $sample[ $i ] );
741 // Allow: tab (9), newline (10), carriage return (13), space and above (32-126)
742 // Allow extended ASCII/UTF-8 (128+)
743 if ( $ord < 9 || ( $ord > 13 && $ord < 32 ) || ( $ord > 126 && $ord < 128 ) ) {
744 $non_printable++;
745 }
746 }
747
748 // If more than 10% is non-printable, likely binary
749 $ratio = $non_printable / $sample_length;
750
751 return $ratio > 0.1;
752 }
753
754 /**
755 * Get the clean text content for a post (for display/counting purposes)
756 *
757 * @param \WP_Post|int $post Post object or ID
758 * @return string Clean text content
759 */
760 public function get_clean_content( $post ) {
761 if ( is_numeric( $post ) ) {
762 $post = get_post( $post );
763 }
764
765 if ( ! $post || ! isset( $post->post_content ) ) {
766 return '';
767 }
768
769 $content = $post->post_content;
770 $content = strip_shortcodes( $content );
771 $content = wp_strip_all_tags( $content );
772 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
773 $content = preg_replace( '/\s+/', ' ', $content );
774
775 return trim( $content );
776 }
777
778 /**
779 * Index posts by taxonomy term(s)
780 *
781 * @param string $taxonomy Taxonomy name
782 * @param int|array $term_ids Term ID or array of term IDs
783 * @param array $post_types Post types to index
784 * @param string $date_from Optional start date (Y-m-d format)
785 * @param string $date_to Optional end date (Y-m-d format)
786 * @return array|WP_Error Result or error
787 */
788 public function index_by_taxonomy( $taxonomy, $term_ids, $post_types = [ 'post' ], $date_from = '', $date_to = '' ) {
789 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
790 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
791 }
792
793 // Ensure term_ids is an array
794 $term_ids = (array) $term_ids;
795 $term_ids = array_map( 'intval', $term_ids );
796 $term_ids = array_filter( $term_ids ); // Remove zeros
797
798 if ( empty( $term_ids ) ) {
799 return new \WP_Error( 'no_terms', __( 'No valid terms specified', 'wpforo' ) );
800 }
801
802 // Get all posts in these terms
803 $args = [
804 'post_type' => $post_types,
805 'posts_per_page' => -1, // Get all
806 'post_status' => 'publish',
807 'tax_query' => [
808 [
809 'taxonomy' => $taxonomy,
810 'field' => 'term_id',
811 'terms' => $term_ids,
812 ],
813 ],
814 'fields' => 'ids',
815 ];
816
817 // Add date range filter if specified
818 if ( ! empty( $date_from ) ) {
819 $args['date_query'][] = [
820 'after' => $date_from,
821 'inclusive' => true,
822 ];
823 }
824
825 if ( ! empty( $date_to ) ) {
826 $args['date_query'][] = [
827 'before' => $date_to,
828 'inclusive' => true,
829 ];
830 }
831
832 $query = new \WP_Query( $args );
833 $post_ids = $query->posts;
834
835 if ( empty( $post_ids ) ) {
836 return new \WP_Error( 'no_posts', __( 'No posts found in this term', 'wpforo' ) );
837 }
838
839 // Queue posts for batch indexing
840 return $this->queue_posts_for_indexing( $post_ids );
841 }
842
843 /**
844 * Index posts with custom filters
845 *
846 * @param array $params Custom indexing parameters
847 * @return array|WP_Error Result or error
848 */
849 public function index_custom( $params ) {
850 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
851 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
852 }
853
854 $args = [
855 'post_type' => isset( $params['post_types'] ) ? $params['post_types'] : [ 'post' ],
856 'posts_per_page' => -1,
857 'post_status' => 'publish',
858 'fields' => 'ids',
859 ];
860
861 // Date range filter
862 if ( ! empty( $params['date_from'] ) ) {
863 $args['date_query'][] = [
864 'after' => $params['date_from'],
865 'inclusive' => true,
866 ];
867 }
868
869 if ( ! empty( $params['date_to'] ) ) {
870 $args['date_query'][] = [
871 'before' => $params['date_to'],
872 'inclusive' => true,
873 ];
874 }
875
876 // Specific post IDs
877 if ( ! empty( $params['post_ids'] ) ) {
878 $args['post__in'] = array_map( 'intval', (array) $params['post_ids'] );
879 }
880
881 // Author filter
882 if ( ! empty( $params['author'] ) ) {
883 $args['author'] = intval( $params['author'] );
884 }
885
886 $query = new \WP_Query( $args );
887 $post_ids = $query->posts;
888
889 if ( empty( $post_ids ) ) {
890 return new \WP_Error( 'no_posts', __( 'No posts found matching criteria', 'wpforo' ) );
891 }
892
893 return $this->queue_posts_for_indexing( $post_ids );
894 }
895
896 /**
897 * Queue posts for batch indexing
898 *
899 * @param array $post_ids Array of post IDs to index
900 * @return array Result with job info
901 */
902 public function queue_posts_for_indexing( $post_ids ) {
903 $batches = array_chunk( $post_ids, self::BATCH_SIZE );
904 $job_id = 'wp_index_' . uniqid();
905 $total_posts = count( $post_ids );
906
907 // Clear the status cache so polling gets fresh data
908 delete_transient( 'wpforo_ai_wp_indexing_status' );
909
910 // Store queue in options for processing
911 update_option( 'wpforo_ai_wp_indexing_queue', [
912 'job_id' => $job_id,
913 'batches' => $batches,
914 'current' => 0,
915 'total_posts' => $total_posts,
916 'indexed' => 0,
917 'failed' => 0,
918 'skipped' => 0,
919 'status' => 'processing',
920 'started_at' => current_time( 'mysql', true ),
921 ] );
922
923 // Schedule first batch
924 wp_schedule_single_event( time() + 1, 'wpforo_ai_process_wp_batch' );
925
926 return [
927 'job_id' => $job_id,
928 'total_posts' => $total_posts,
929 'batches' => count( $batches ),
930 'status' => 'queued',
931 ];
932 }
933
934 /**
935 * Process a batch of posts for indexing
936 *
937 * @return array|WP_Error Result or error
938 */
939 public function process_batch() {
940 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
941
942 if ( empty( $queue ) || empty( $queue['batches'] ) ) {
943 return new \WP_Error( 'no_queue', 'No indexing queue found' );
944 }
945
946 $current_batch_index = $queue['current'];
947
948 if ( ! isset( $queue['batches'][ $current_batch_index ] ) ) {
949 // All batches processed
950 delete_option( 'wpforo_ai_wp_indexing_queue' );
951 return [ 'status' => 'completed' ];
952 }
953
954 $post_ids = $queue['batches'][ $current_batch_index ];
955 $posts = [];
956 $skipped = 0;
957
958 foreach ( $post_ids as $post_id ) {
959 $post = get_post( $post_id );
960 if ( $post && $post->post_status === 'publish' ) {
961 // Skip password-protected posts - their content should not be searchable
962 if ( ! empty( $post->post_password ) ) {
963 $skipped++;
964 continue;
965 }
966 // Skip posts with non-indexable content (shortcodes only, binary, too short)
967 if ( ! $this->is_content_indexable( $post ) ) {
968 $skipped++;
969 continue;
970 }
971 $posts[] = $this->format_post_for_indexing( $post );
972 }
973 }
974
975 // Track skipped posts
976 if ( ! isset( $queue['skipped'] ) ) {
977 $queue['skipped'] = 0;
978 }
979 $queue['skipped'] += $skipped;
980
981 if ( empty( $posts ) ) {
982 // Move to next batch
983 $queue['current']++;
984 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
985
986 // Schedule next batch
987 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
988 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
989 }
990
991 return [ 'status' => 'batch_empty', 'current' => $queue['current'] ];
992 }
993
994 // Check storage mode — local mode stores embeddings in WordPress DB
995 // WordPress content uses the global storage mode setting (board 0)
996 if ( $this->is_local_storage_mode() ) {
997 $local_result = $this->process_batch_local( $posts, $queue );
998
999 return $local_result;
1000 }
1001
1002 // CLOUD MODE: Send to cloud API
1003 $response = WPF()->ai_client->api_post( '/rag/wordpress/ingest', [
1004 'posts' => $posts,
1005 'chunk_size' => 512,
1006 'overlap_percent' => 20,
1007 ], 120 );
1008
1009 if ( is_wp_error( $response ) ) {
1010 $queue['failed'] += count( $post_ids );
1011 } else {
1012 $queue['indexed'] += count( $posts );
1013 }
1014
1015 // Move to next batch
1016 $queue['current']++;
1017 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1018
1019 // Schedule next batch
1020 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
1021 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
1022 } else {
1023 // All done
1024 $queue['status'] = 'completed';
1025 $queue['completed_at'] = current_time( 'mysql', true );
1026 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1027
1028 // Clear cache
1029 delete_transient( 'wpforo_ai_wp_indexing_status' );
1030 }
1031
1032 return [
1033 'status' => 'processing',
1034 'current' => $queue['current'],
1035 'indexed' => $queue['indexed'],
1036 'failed' => $queue['failed'],
1037 'skipped' => $queue['skipped'],
1038 ];
1039 }
1040
1041 /**
1042 * Process a batch of posts for local storage mode
1043 *
1044 * Generates embeddings via cloud API but stores them in WordPress DB
1045 * instead of cloud vector storage. Uses content_hash dedup to skip
1046 * unchanged posts.
1047 *
1048 * @param array $posts Formatted post data from format_post_for_indexing()
1049 * @param array $queue Current queue state (modified by reference via option update)
1050 * @return array Result with status, indexed, failed, skipped counts
1051 */
1052 private function process_batch_local( $posts, $queue ) {
1053 $local = $this->get_local_storage();
1054 if ( ! $local ) {
1055 return [
1056 'status' => 'error',
1057 'message' => 'Local storage is not available',
1058 'indexed' => 0,
1059 'failed' => count( $posts ),
1060 'skipped' => 0,
1061 ];
1062 }
1063
1064 $indexed = 0;
1065 $failed = 0;
1066 $skipped = 0;
1067
1068 foreach ( $posts as $post_data ) {
1069 $post_id = $post_data['post_id'];
1070 $post_type = $post_data['post_type'];
1071
1072 // Build text content for embedding
1073 $content_parts = [];
1074 if ( ! empty( $post_data['title'] ) ) {
1075 $content_parts[] = $post_data['title'];
1076 }
1077 if ( ! empty( $post_data['excerpt'] ) ) {
1078 $content_parts[] = wp_strip_all_tags( $post_data['excerpt'] );
1079 }
1080 if ( ! empty( $post_data['content'] ) ) {
1081 $clean_content = strip_shortcodes( $post_data['content'] );
1082 $clean_content = wp_strip_all_tags( $clean_content );
1083 $clean_content = html_entity_decode( $clean_content, ENT_QUOTES, 'UTF-8' );
1084 $clean_content = preg_replace( '/\s+/', ' ', trim( $clean_content ) );
1085 $content_parts[] = $clean_content;
1086 }
1087
1088 // Add taxonomy context
1089 if ( ! empty( $post_data['taxonomy_terms'] ) ) {
1090 foreach ( $post_data['taxonomy_terms'] as $tax_label => $terms ) {
1091 $content_parts[] = $tax_label . ': ' . implode( ', ', $terms );
1092 }
1093 }
1094
1095 $content = implode( "\n\n", $content_parts );
1096
1097 // Truncate to fit within embedding model's token limit
1098 // Titan Embed v2: 8192 tokens ≈ 28000 chars (safe margin)
1099 $max_chars = 28000;
1100 if ( mb_strlen( $content, 'UTF-8' ) > $max_chars ) {
1101 $content = mb_substr( $content, 0, $max_chars, 'UTF-8' );
1102 }
1103
1104 $content_hash = md5( $content );
1105
1106 // Check if already indexed with same content (dedup)
1107 $existing = $local->get_embedding( $post_id );
1108 if ( $existing && $existing['content_hash'] === $content_hash ) {
1109 $skipped++;
1110 continue;
1111 }
1112
1113 // Generate embedding via cloud API
1114 $result = WPF()->ai_client->generate_embedding( $content );
1115 $embedding = is_wp_error( $result ) ? $result : ( $result['embedding'] ?? null );
1116
1117 if ( is_wp_error( $embedding ) || ! is_array( $embedding ) ) {
1118 $error_msg = is_wp_error( $embedding ) ? $embedding->get_error_message() : 'Invalid embedding response';
1119 \wpforo_ai_log( 'error', sprintf(
1120 'Failed to generate embedding for WP post %d: %s',
1121 $post_id,
1122 $error_msg
1123 ), 'WPIndexer' );
1124 $failed++;
1125 continue;
1126 }
1127
1128 // Build content preview
1129 $preview = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post_data['content'] ?? '' ) ), 80, '...' );
1130
1131 // Store locally with content_type = post_type
1132 $stored = $local->store_embedding(
1133 0, // topicid (not a forum topic)
1134 $post_id, // postid = WP post ID
1135 0, // forumid (not a forum)
1136 (int) $post_data['author_id'],
1137 $embedding,
1138 $content_hash,
1139 $preview,
1140 'amazon.titan-embed-text-v2',
1141 $post_type // content_type = post type (page, post, product, etc.)
1142 );
1143
1144 if ( $stored ) {
1145 $indexed++;
1146 } else {
1147 $failed++;
1148 }
1149 }
1150
1151 // Update queue progress
1152 $queue['indexed'] += $indexed;
1153 $queue['failed'] += $failed;
1154 $queue['skipped'] = ( $queue['skipped'] ?? 0 ) + $skipped;
1155
1156 // Move to next batch
1157 $queue['current']++;
1158 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1159
1160 // Schedule next batch
1161 if ( isset( $queue['batches'][ $queue['current'] ] ) ) {
1162 wp_schedule_single_event( time() + 2, 'wpforo_ai_process_wp_batch' );
1163 } else {
1164 // All done
1165 $queue['status'] = 'completed';
1166 $queue['completed_at'] = current_time( 'mysql', true );
1167 update_option( 'wpforo_ai_wp_indexing_queue', $queue );
1168 delete_transient( 'wpforo_ai_wp_indexing_status' );
1169 }
1170
1171 return [
1172 'status' => 'processing',
1173 'current' => $queue['current'],
1174 'indexed' => $queue['indexed'],
1175 'failed' => $queue['failed'],
1176 'skipped' => $queue['skipped'] ?? 0,
1177 ];
1178 }
1179
1180 /**
1181 * Get WordPress content indexing status
1182 *
1183 * @return array|WP_Error Status data or error
1184 */
1185 public function get_indexing_status( $skip_cache = false ) {
1186 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1187 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1188 }
1189
1190 // Check cache (skip if explicitly requested or if indexing is in progress)
1191 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1192 $is_processing = ! empty( $queue ) && isset( $queue['status'] ) && $queue['status'] === 'processing';
1193
1194 if ( ! $skip_cache && ! $is_processing ) {
1195 $cached = get_transient( 'wpforo_ai_wp_indexing_status' );
1196 if ( $cached !== false ) {
1197 return $cached;
1198 }
1199 }
1200
1201 // Get indexed counts — source depends on storage mode
1202 if ( $this->is_local_storage_mode() ) {
1203 // LOCAL mode: count from WordPress ai_embeddings table
1204 $local = $this->get_local_storage();
1205 if ( ! $local ) {
1206 return new \WP_Error( 'storage_unavailable', __( 'Local storage is not available', 'wpforo' ) );
1207 }
1208 $indexed_counts = $local->get_wp_indexed_counts();
1209 $response = [
1210 'content_source' => 'wordpress',
1211 'indexed_counts' => $indexed_counts,
1212 'total_indexed' => array_sum( $indexed_counts ),
1213 ];
1214 } else {
1215 // CLOUD mode: query backend API (has sync_state records)
1216 $response = WPF()->ai_client->api_get( '/rag/wordpress/status' );
1217 if ( is_wp_error( $response ) ) {
1218 return $response;
1219 }
1220 }
1221
1222 // Build by_type structure that JavaScript expects
1223 $post_types = $this->get_public_post_types();
1224 $indexed_counts = isset( $response['indexed_counts'] ) ? $response['indexed_counts'] : [];
1225 $by_type = [];
1226
1227 foreach ( $post_types as $type ) {
1228 $type_key = 'wp_' . $type['name'];
1229 $indexed = isset( $indexed_counts[ $type_key ] ) ? (int) $indexed_counts[ $type_key ] : 0;
1230 $total = (int) $type['count'];
1231 $by_type[ $type_key ] = [
1232 'indexed' => $indexed,
1233 'total' => $total,
1234 'percentage' => $total > 0 ? round( ( $indexed / $total ) * 100, 1 ) : 0,
1235 ];
1236 }
1237
1238 $response['by_type'] = $by_type;
1239 $response['total_indexed'] = isset( $response['total_indexed'] ) ? (int) $response['total_indexed'] : 0;
1240
1241 // Check if there's an active queue
1242 $queue = get_option( 'wpforo_ai_wp_indexing_queue' );
1243 $is_indexing = false;
1244 if ( ! empty( $queue ) ) {
1245 $queue_status = isset( $queue['status'] ) ? $queue['status'] : 'processing';
1246 $is_indexing = ( $queue_status === 'processing' );
1247 $response['queue'] = [
1248 'job_id' => $queue['job_id'],
1249 'total_posts' => $queue['total_posts'],
1250 'indexed' => $queue['indexed'],
1251 'failed' => $queue['failed'],
1252 'current' => $queue['current'],
1253 'total' => count( $queue['batches'] ),
1254 'status' => $queue_status,
1255 ];
1256 }
1257
1258 // Add is_indexing flag for UI
1259 $response['is_indexing'] = $is_indexing;
1260
1261 // Get last activity timestamp from queue or sync_state
1262 $last_indexed_at = null;
1263 if ( ! empty( $queue['completed_at'] ) ) {
1264 $last_indexed_at = $queue['completed_at'];
1265 } elseif ( ! empty( $queue['started_at'] ) ) {
1266 $last_indexed_at = $queue['started_at'];
1267 }
1268 $response['last_indexed_at'] = $last_indexed_at;
1269 // Format date using WordPress function (always available in AJAX context)
1270 $response['last_indexed_at_formatted'] = $last_indexed_at
1271 ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $last_indexed_at ) )
1272 : null;
1273
1274 // Cache for 5 minutes
1275 set_transient( 'wpforo_ai_wp_indexing_status', $response, 5 * MINUTE_IN_SECONDS );
1276
1277 return $response;
1278 }
1279
1280 /**
1281 * Delete WordPress content from index
1282 *
1283 * @param array $params Delete parameters (post_types, post_ids, all)
1284 * @return array|WP_Error Result or error
1285 */
1286 public function delete_content( $params ) {
1287 if ( $this->is_local_storage_mode() ) {
1288 // LOCAL mode: delete from WordPress ai_embeddings table
1289 $local = $this->get_local_storage();
1290 if ( ! $local ) {
1291 return new \WP_Error( 'storage_unavailable', __( 'Local storage is not available', 'wpforo' ) );
1292 }
1293
1294 $post_types = isset( $params['post_types'] ) ? $params['post_types'] : null;
1295 $post_ids = isset( $params['post_ids'] ) ? $params['post_ids'] : null;
1296
1297 // 'all' flag means delete all non-forum CPT embeddings
1298 if ( ! empty( $params['all'] ) ) {
1299 $post_types = null;
1300 $post_ids = null;
1301 }
1302
1303 $deleted = $local->delete_wp_embeddings( $post_types, $post_ids );
1304
1305 // Clear cache
1306 delete_transient( 'wpforo_ai_wp_indexing_status' );
1307
1308 return [
1309 'deleted' => $deleted,
1310 'message' => sprintf( 'Deleted %d embeddings from local storage.', $deleted ),
1311 ];
1312 }
1313
1314 // CLOUD mode: delete via backend API
1315 if ( ! WPF()->ai_client || ! WPF()->ai_client->is_service_available() ) {
1316 return new \WP_Error( 'not_connected', __( 'AI service is not connected', 'wpforo' ) );
1317 }
1318
1319 $response = WPF()->ai_client->api_post( '/rag/wordpress/delete', $params, 60 );
1320
1321 if ( ! is_wp_error( $response ) ) {
1322 // Clear cache
1323 delete_transient( 'wpforo_ai_wp_indexing_status' );
1324 }
1325
1326 return $response;
1327 }
1328
1329 // ===============================
1330 // AJAX Handlers
1331 // ===============================
1332
1333 /**
1334 * AJAX: Get public post types
1335 */
1336 public function ajax_get_post_types() {
1337 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1338
1339 if ( ! current_user_can( 'manage_options' ) ) {
1340 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1341 }
1342
1343 $post_types = $this->get_public_post_types();
1344 wp_send_json_success( [ 'post_types' => $post_types ] );
1345 }
1346
1347 /**
1348 * AJAX: Get taxonomies for post type
1349 */
1350 public function ajax_get_taxonomies() {
1351 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1352
1353 if ( ! current_user_can( 'manage_options' ) ) {
1354 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1355 }
1356
1357 $post_type = isset( $_POST['post_type'] ) ? sanitize_key( $_POST['post_type'] ) : 'post';
1358 $taxonomies = $this->get_taxonomies_for_post_type( $post_type );
1359
1360 wp_send_json_success( [ 'taxonomies' => $taxonomies ] );
1361 }
1362
1363 /**
1364 * AJAX: Get terms for taxonomy
1365 */
1366 public function ajax_get_taxonomy_terms() {
1367 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1368
1369 if ( ! current_user_can( 'manage_options' ) ) {
1370 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1371 }
1372
1373 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : 'category';
1374
1375 // Get post types if provided (to count only published posts for specific types)
1376 $post_types = [];
1377 if ( ! empty( $_POST['post_types'] ) ) {
1378 $post_types = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1379 }
1380
1381 $terms = $this->get_taxonomy_terms( $taxonomy, true, $post_types );
1382
1383 wp_send_json_success( [ 'terms' => $terms ] );
1384 }
1385
1386 /**
1387 * AJAX: Get indexing status
1388 */
1389 public function ajax_get_indexing_status() {
1390 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1391
1392 if ( ! current_user_can( 'manage_options' ) ) {
1393 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1394 }
1395
1396 $status = $this->get_indexing_status();
1397
1398 if ( is_wp_error( $status ) ) {
1399 wp_send_json_error( [ 'message' => $status->get_error_message() ] );
1400 }
1401
1402 wp_send_json_success( $status );
1403 }
1404
1405 /**
1406 * AJAX: Index by taxonomy
1407 */
1408 public function ajax_index_by_taxonomy() {
1409 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1410
1411 if ( ! current_user_can( 'manage_options' ) ) {
1412 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1413 }
1414
1415 $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '';
1416 $post_types = isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : [ 'post' ];
1417 $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( $_POST['date_from'] ) : '';
1418 $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( $_POST['date_to'] ) : '';
1419
1420 // Support both single term_id (legacy) and multiple term_ids
1421 $term_ids = [];
1422 if ( isset( $_POST['term_ids'] ) && is_array( $_POST['term_ids'] ) ) {
1423 $term_ids = array_map( 'intval', $_POST['term_ids'] );
1424 } elseif ( isset( $_POST['term_id'] ) ) {
1425 $term_ids = [ intval( $_POST['term_id'] ) ];
1426 }
1427
1428 if ( empty( $taxonomy ) || empty( $term_ids ) ) {
1429 wp_send_json_error( [ 'message' => __( 'Invalid taxonomy or term', 'wpforo' ) ] );
1430 }
1431
1432 $result = $this->index_by_taxonomy( $taxonomy, $term_ids, $post_types, $date_from, $date_to );
1433
1434 if ( is_wp_error( $result ) ) {
1435 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1436 }
1437
1438 wp_send_json_success( $result );
1439 }
1440
1441 /**
1442 * AJAX: Custom indexing
1443 */
1444 public function ajax_index_custom() {
1445 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1446
1447 if ( ! current_user_can( 'manage_options' ) ) {
1448 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1449 }
1450
1451 $params = [];
1452
1453 if ( ! empty( $_POST['post_types'] ) ) {
1454 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1455 }
1456
1457 if ( ! empty( $_POST['date_from'] ) ) {
1458 $params['date_from'] = sanitize_text_field( $_POST['date_from'] );
1459 }
1460
1461 if ( ! empty( $_POST['date_to'] ) ) {
1462 $params['date_to'] = sanitize_text_field( $_POST['date_to'] );
1463 }
1464
1465 if ( ! empty( $_POST['post_ids'] ) ) {
1466 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1467 }
1468
1469 if ( ! empty( $_POST['author'] ) ) {
1470 $params['author'] = intval( $_POST['author'] );
1471 }
1472
1473 $result = $this->index_custom( $params );
1474
1475 if ( is_wp_error( $result ) ) {
1476 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1477 }
1478
1479 wp_send_json_success( $result );
1480 }
1481
1482 /**
1483 * AJAX: Delete content
1484 */
1485 public function ajax_delete_content() {
1486 check_ajax_referer( 'wpforo_admin_ajax', 'security' );
1487
1488 if ( ! current_user_can( 'manage_options' ) ) {
1489 wp_send_json_error( [ 'message' => __( 'Permission denied', 'wpforo' ) ] );
1490 }
1491
1492 $params = [];
1493
1494 if ( isset( $_POST['delete_all'] ) && $_POST['delete_all'] === 'true' ) {
1495 $params['all'] = true; // API expects 'all', not 'delete_all'
1496 } elseif ( ! empty( $_POST['post_types'] ) ) {
1497 $params['post_types'] = array_map( 'sanitize_key', (array) $_POST['post_types'] );
1498 } elseif ( ! empty( $_POST['post_ids'] ) ) {
1499 $params['post_ids'] = array_map( 'intval', (array) $_POST['post_ids'] );
1500 }
1501
1502 $result = $this->delete_content( $params );
1503
1504 if ( is_wp_error( $result ) ) {
1505 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
1506 }
1507
1508 wp_send_json_success( $result );
1509 }
1510 }
1511