PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 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 All 138 releases
wpforo / admin / pages / tabs / ai-features-helpers.php

ai-features-helpers.php in wpForo Forum 3.1.6, at admin/pages/tabs/ai-features-helpers.php

2,362 lines 73.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Features - Helper Functions
4 *
5 * Shared helper functions used across AI Features tabs
6 *
7 * @package wpForo
8 * @subpackage Admin
9 */
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Check if current site is running on localhost/development environment
17 *
18 * Detects common localhost patterns that are not suitable for API key generation.
19 *
20 * @return bool True if localhost/development site, false otherwise
21 */
22 function wpforo_ai_is_localhost() {
23 $site_url = site_url();
24 $host = wp_parse_url( $site_url, PHP_URL_HOST );
25
26 if ( empty( $host ) ) {
27 return true; // Invalid URL, treat as localhost
28 }
29
30 $host_lower = strtolower( $host );
31
32 // Common localhost patterns
33 $localhost_patterns = [
34 'localhost',
35 '127.0.0.1',
36 '0.0.0.0',
37 '::1',
38 ];
39
40 // Check exact matches
41 if ( in_array( $host_lower, $localhost_patterns, true ) ) {
42 return true;
43 }
44
45 // Check localhost TLDs (common development environments)
46 $localhost_tlds = [
47 '.local',
48 '.localhost',
49 '.test',
50 '.example',
51 '.invalid',
52 '.dev',
53 '.loc', // Common MAMP/XAMPP local development
54 ];
55
56 foreach ( $localhost_tlds as $tld ) {
57 if ( substr( $host_lower, -strlen( $tld ) ) === $tld ) {
58 return true;
59 }
60 }
61
62 // Check for local IP ranges (private networks)
63 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
64 // 10.x.x.x, 172.16-31.x.x, 192.168.x.x
65 if ( preg_match( '/^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.)/', $host ) ) {
66 return true;
67 }
68 }
69
70 return false;
71 }
72
73 /**
74 * Get Freemius pricing for subscription plans and credit packs
75 *
76 * Returns static pricing configuration for all available plans and credit packs.
77 *
78 * IMPORTANT: If you change prices in the Freemius Dashboard, you must manually
79 * update the prices in this function to keep them in sync. The prices here are
80 * used for display purposes only - Freemius handles the actual checkout and payment.
81 *
82 * @return array Pricing data with 'plans' and 'credit_packs' keys
83 */
84 function wpforo_ai_get_freemius_pricing() {
85 return [
86 'plans' => [
87 'starter' => [
88 'plan_id' => '36610',
89 'pricing_id' => '47813',
90 'price' => 9.00,
91 'currency' => 'usd',
92 'billing_cycle' => 'monthly'
93 ],
94 'professional' => [
95 'plan_id' => '36612',
96 'pricing_id' => '47815',
97 'price' => 19.00,
98 'currency' => 'usd',
99 'billing_cycle' => 'monthly'
100 ],
101 'business' => [
102 'plan_id' => '36613',
103 'pricing_id' => '47816',
104 'price' => 49.00,
105 'currency' => 'usd',
106 'billing_cycle' => 'monthly'
107 ],
108 'enterprise' => [
109 'plan_id' => '36615',
110 'pricing_id' => '47818',
111 'price' => 99.00,
112 'currency' => 'usd',
113 'billing_cycle' => 'monthly'
114 ],
115 ],
116 'credit_packs' => [
117 '500' => [
118 'plan_id' => '36667',
119 'pricing_id' => '47923',
120 'price' => 10.00,
121 'credits' => 500,
122 'currency' => 'usd'
123 ],
124 '1500' => [
125 'plan_id' => '36668',
126 'pricing_id' => '47924',
127 'price' => 20.00,
128 'credits' => 1500,
129 'currency' => 'usd'
130 ],
131 '4500' => [
132 'plan_id' => '36669',
133 'pricing_id' => '47925',
134 'price' => 50.00,
135 'credits' => 4500,
136 'currency' => 'usd'
137 ],
138 '15000' => [
139 'plan_id' => '36670',
140 'pricing_id' => '47926',
141 'price' => 120.00,
142 'credits' => 15000,
143 'currency' => 'usd'
144 ],
145 ]
146 ];
147 }
148
149 /**
150 * Get Paddle pricing for subscription plans and credit packs
151 *
152 * Returns Paddle price IDs for all plans and credit packs.
153 * Prices are the same as Freemius — only the checkout flow differs.
154 *
155 * IMPORTANT: If you change prices in the Paddle Dashboard, you must manually
156 * update the price IDs here. Paddle uses price_id (not plan_id/pricing_id like Freemius).
157 *
158 * Sandbox and Live have different price IDs. The correct set is selected based
159 * on the WPFORO_AI_PADDLE_ENV constant (default: 'sandbox').
160 *
161 * @return array Pricing data with 'plans' and 'credit_packs' keys
162 */
163 function wpforo_ai_get_paddle_pricing() {
164 // Determine environment: 'live' for production (default), 'sandbox' for testing
165 // To use sandbox, define WPFORO_AI_PADDLE_ENV as 'sandbox' in wp-config.php
166 $env = defined( 'WPFORO_AI_PADDLE_ENV' ) ? WPFORO_AI_PADDLE_ENV : 'live';
167
168 if ( $env === 'live' ) {
169 return [
170 'plans' => [
171 'starter' => [ 'price_id' => 'pri_01km85yz3r7yakw5z9vj0a7j4f', 'price' => 9.00 ],
172 'professional' => [ 'price_id' => 'pri_01km863kcef4vwtwm95ph7h5ys', 'price' => 19.00 ],
173 'business' => [ 'price_id' => 'pri_01km865rt82y0sckrw1fq59ftk', 'price' => 49.00 ],
174 'enterprise' => [ 'price_id' => 'pri_01km86kpkf9802tazpv912hgeh', 'price' => 99.00 ],
175 ],
176 'credit_packs' => [
177 '500' => [ 'price_id' => 'pri_01km86ayn4dmqrj20fxwg5f816', 'price' => 10.00, 'credits' => 500 ],
178 '1500' => [ 'price_id' => 'pri_01km86cgmnq3cm2fxdrnv6s6zp', 'price' => 20.00, 'credits' => 1500 ],
179 '4500' => [ 'price_id' => 'pri_01km86dy96gf99qb20mpzq7ksa', 'price' => 50.00, 'credits' => 4500 ],
180 '15000' => [ 'price_id' => 'pri_01km86f1br525kshdgqgw7yq0j', 'price' => 120.00, 'credits' => 15000 ],
181 ],
182 ];
183 }
184
185 // Sandbox (default)
186 return [
187 'plans' => [
188 'starter' => [ 'price_id' => 'pri_01km87cgsb37gtznd8qvyfx2zt', 'price' => 9.00 ],
189 'professional' => [ 'price_id' => 'pri_01km87dsd0ts43ay9rask9xsgg', 'price' => 19.00 ],
190 'business' => [ 'price_id' => 'pri_01km87evv46y756xwex931mjx5', 'price' => 49.00 ],
191 'enterprise' => [ 'price_id' => 'pri_01km87g2ppkw7xhp8j99qws7k7', 'price' => 99.00 ],
192 ],
193 'credit_packs' => [
194 '500' => [ 'price_id' => 'pri_01km87hc8at4jmww1k167yvv43', 'price' => 10.00, 'credits' => 500 ],
195 '1500' => [ 'price_id' => 'pri_01km87j555ed649fn1nss6xr0z', 'price' => 20.00, 'credits' => 1500 ],
196 '4500' => [ 'price_id' => 'pri_01km87jzgm3zmerqy71rk3hsb1', 'price' => 50.00, 'credits' => 4500 ],
197 '15000' => [ 'price_id' => 'pri_01km87kpva85jmvxxgnjaz43j5', 'price' => 120.00, 'credits' => 15000 ],
198 ],
199 ];
200 }
201
202 /**
203 * Get the list of enabled payment providers
204 *
205 * To disable a payment provider, remove it from the returned array.
206 * For example, to hide Freemius entirely, return only ['paddle'].
207 *
208 * @return array List of enabled provider slugs ('paddle', 'freemius')
209 */
210 function wpforo_ai_get_enabled_providers() {
211 // To disable Freemius: change to ['paddle']
212 // To disable Paddle: change to ['freemius']
213 return [ 'paddle', 'freemius' ];
214 }
215
216 /**
217 * Get the default payment provider
218 *
219 * @return string Default provider slug
220 */
221 function wpforo_ai_get_default_provider() {
222 return 'paddle';
223 }
224
225 /**
226 * Handle form actions (connect, regenerate, disconnect, refresh)
227 *
228 * @return array|null Notice data or null
229 */
230 function wpforo_ai_handle_form_actions() {
231 if ( ! isset( $_POST['wpforo_ai_action'] ) ) {
232 return null;
233 }
234
235 $action = sanitize_key( $_POST['wpforo_ai_action'] );
236
237 // Verify nonce
238 // Some actions share a form/nonce with another action
239 $shared_nonce_actions = [
240 'clear_forum_index' => 'forum_ingest', // Clear and Index share the same form
241 ];
242 $nonce_action = 'wpforo_ai_' . ( isset( $shared_nonce_actions[ $action ] ) ? $shared_nonce_actions[ $action ] : $action );
243 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], $nonce_action ) ) {
244 return [
245 'type' => 'error',
246 'message' => __( 'Security check failed. Please try again.', 'wpforo' ),
247 ];
248 }
249
250 // Check permissions
251 if ( ! wpforo_current_user_is( 'admin' ) && ! WPF()->usergroup->can( 'ms' ) ) {
252 return [
253 'type' => 'error',
254 'message' => __( 'Insufficient permissions.', 'wpforo' ),
255 ];
256 }
257
258 switch ( $action ) {
259 case 'connect':
260 return wpforo_ai_handle_connect();
261
262 case 'disconnect':
263 return wpforo_ai_handle_disconnect();
264
265 case 'refresh_status':
266 WPF()->ai_client->clear_status_cache();
267 delete_transient( 'wpforo_ai_indexed_counts' );
268 // Note: pending_approval transient will be cleared automatically
269 // in wpforo_ai_get_current_state if API returns active status
270 return [
271 'type' => 'success',
272 'message' => __( 'Status refreshed successfully.', 'wpforo' ),
273 ];
274
275 case 'manual_ingest':
276 return wpforo_ai_handle_manual_ingest();
277
278 case 'filtered_ingest':
279 return wpforo_ai_handle_filtered_ingest();
280
281 case 'forum_ingest':
282 return wpforo_ai_handle_forum_ingest();
283
284 case 'clear_forum_index':
285 return wpforo_ai_handle_clear_forum_index();
286
287 case 'reindex_all':
288 return wpforo_ai_handle_reindex_all();
289
290 case 'reindex_images':
291 return wpforo_ai_handle_reindex_images();
292
293 case 'clear_database':
294 return wpforo_ai_handle_clear_database();
295
296 case 'clear_and_reindex':
297 return wpforo_ai_handle_clear_and_reindex();
298
299 case 'save_chunking_config':
300 return wpforo_ai_handle_save_chunking_config();
301
302 case 'stop_indexing':
303 return wpforo_ai_handle_stop_indexing();
304
305 case 'process_local_batch':
306 return wpforo_ai_handle_process_local_batch();
307
308 case 'start_local_indexing':
309 return wpforo_ai_handle_start_local_indexing();
310
311 case 'stop_local_indexing':
312 return wpforo_ai_handle_stop_local_indexing();
313
314 case 'get_indexing_progress':
315 return wpforo_ai_handle_get_indexing_progress();
316
317 case 'clear_local_embeddings':
318 return wpforo_ai_handle_clear_local_embeddings();
319 }
320
321 return null;
322 }
323
324 /**
325 * Handle tenant connection/registration
326 *
327 * @return array Notice data
328 */
329 function wpforo_ai_handle_connect() {
330 $response = WPF()->ai_client->register_tenant();
331
332 if ( is_wp_error( $response ) ) {
333 return [
334 'type' => 'error',
335 'message' => sprintf(
336 __( 'Connection failed: %s', 'wpforo' ),
337 $response->get_error_message()
338 ),
339 ];
340 }
341
342 // Store API key (encrypted) and tenant ID
343 $api_key = wpfval( $response, 'api_key' );
344 $tenant_id = wpfval( $response, 'tenant_id' );
345
346 if ( empty( $api_key ) || empty( $tenant_id ) ) {
347 return [
348 'type' => 'error',
349 'message' => __( 'Invalid response from server. Please try again.', 'wpforo' ),
350 ];
351 }
352
353 // Use global options (shared across all boards)
354 WPF()->ai_client->update_global_option( 'ai_api_key', WPF()->ai_client->encrypt_api_key( $api_key ) );
355 WPF()->ai_client->update_global_option( 'ai_tenant_id', $tenant_id );
356
357 // Check if registration returned pending_approval status
358 // Note: wpfval doesn't support default values - use ?: instead
359 $subscription = wpfval( $response, 'subscription' ) ?: [];
360 $sub_status = wpfval( $subscription, 'status' );
361
362 // Cache subscription status and plan immediately (no API calls needed)
363 // This ensures is_service_available() works right after registration
364 WPF()->ai_client->update_global_option( 'ai_subscription_status', sanitize_text_field( $sub_status ) );
365 $plan = wpfval( $subscription, 'plan' ) ?: 'free_trial';
366 WPF()->ai_client->update_global_option( 'ai_subscription_plan', sanitize_text_field( $plan ) );
367
368 // Cache features enabled from registration response
369 $features_enabled = wpfval( $response, 'features_enabled' ) ?: [];
370 WPF()->ai_client->update_global_option( 'ai_features_enabled', array_map( 'sanitize_text_field', $features_enabled ) );
371
372 // Store last sync time
373 WPF()->ai_client->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql' ) );
374
375 // Store pending_approval status so UI can show correct state immediately
376 // This avoids needing to make API call which might be blocked for pending tenants
377 if ( $sub_status === 'pending_approval' ) {
378 set_transient( 'wpforo_ai_pending_approval', [
379 'status' => 'pending_approval',
380 'credits_total' => wpfval( $subscription, 'credits_total' ) ?: 500,
381 'registered_at' => time(),
382 ], DAY_IN_SECONDS );
383 } else {
384 // Clear any existing pending transient if registration succeeded with active status
385 delete_transient( 'wpforo_ai_pending_approval' );
386 }
387
388 // Clear status transient cache (the persistent options are already updated above)
389 WPF()->ai_client->clear_status_cache();
390
391 $message = wpfval( $response, 'message' );
392 if ( empty( $message ) ) {
393 $credits = wpfval( $subscription, 'credits_total' ) ?: 500;
394 $message = sprintf(
395 __( 'Successfully connected! You have %s credits to get started.', 'wpforo' ),
396 number_format( $credits )
397 );
398 }
399
400 return [
401 'type' => 'success',
402 'message' => $message,
403 ];
404 }
405
406 /**
407 * Handle service disconnection
408 *
409 * @return array Notice data
410 */
411 function wpforo_ai_handle_disconnect() {
412 $confirm = (bool) wpfval( $_POST, 'confirm' );
413
414 if ( ! $confirm ) {
415 return [
416 'type' => 'error',
417 'message' => __( 'You must confirm disconnection.', 'wpforo' ),
418 ];
419 }
420
421 $reason = sanitize_text_field( wpfval( $_POST, 'reason' ) );
422 $purge_data = (bool) wpfval( $_POST, 'purge_data' );
423
424 $response = WPF()->ai_client->disconnect_tenant( $reason, $confirm, $purge_data );
425
426 if ( is_wp_error( $response ) ) {
427 return [
428 'type' => 'error',
429 'message' => sprintf(
430 __( 'Disconnection failed: %s', 'wpforo' ),
431 $response->get_error_message()
432 ),
433 ];
434 }
435
436 // Clear global AI options (shared across all boards)
437 WPF()->ai_client->delete_global_option( 'ai_api_key' );
438 WPF()->ai_client->delete_global_option( 'ai_tenant_id' );
439
440 // Clear cached subscription info (no longer connected)
441 WPF()->ai_client->delete_global_option( 'ai_subscription_status' );
442 WPF()->ai_client->delete_global_option( 'ai_subscription_plan' );
443 WPF()->ai_client->delete_global_option( 'ai_features_enabled' );
444 WPF()->ai_client->delete_global_option( 'ai_subscription_synced_at' );
445
446 // Clear status transient cache (no longer connected)
447 WPF()->ai_client->clear_status_cache();
448
449 // Clear board-specific options (these use the current board prefix)
450 wpforo_delete_option( 'ai_chunk_size' );
451 wpforo_delete_option( 'ai_overlap_percent' );
452 wpforo_delete_option( 'ai_pagination_size' );
453
454 // Clear Freemius pricing cache
455 delete_transient( 'wpforo_ai_freemius_pricing' );
456
457 // Clear pending approval transient (if disconnecting from pending state)
458 delete_transient( 'wpforo_ai_pending_approval' );
459
460 // Clear AI cache table
461 WPF()->ai_client->clear_ai_cache();
462
463 // Unschedule all AI-related cron jobs
464 WPF()->ai_client->clear_pending_cron_jobs();
465 WPF()->ai_client->unschedule_cache_cleanup();
466 WPF()->ai_client->unschedule_pending_topics_indexing();
467 WPF()->ai_client->unschedule_daily_subscription_sync();
468 \wpforo\classes\AIContentModeration::get_instance()->unschedule_moderation_cleanup();
469
470 if ( $purge_data ) {
471 return [
472 'type' => 'success',
473 'message' => __( 'Service disconnected and all data has been permanently removed from gVectors AI servers.', 'wpforo' ),
474 ];
475 }
476
477 return [
478 'type' => 'success',
479 'message' => __( 'Service disconnected successfully. Your credits are preserved and will be restored when you reconnect. Your indexed content will be deleted after 30 days. You can reconnect anytime with the same site URL.', 'wpforo' ),
480 ];
481 }
482
483 /**
484 * Determine current state based on connection and subscription
485 *
486 * @param bool $is_connected Whether tenant is connected
487 * @param array $status Tenant status data
488 * @return string State identifier
489 */
490 function wpforo_ai_get_current_state( $is_connected, $status ) {
491 if ( ! $is_connected ) {
492 // Even if not connected (credentials not saved properly), check for pending transient
493 // This handles race conditions where transient is set but credentials aren't read yet
494 $pending = get_transient( 'wpforo_ai_pending_approval' );
495 if ( $pending && wpfval( $pending, 'status' ) === 'pending_approval' ) {
496 return 'pending_approval';
497 }
498 return 'not_connected';
499 }
500
501 // If API call failed, fall back to transient for pending approval state
502 if ( is_wp_error( $status ) ) {
503 $pending = get_transient( 'wpforo_ai_pending_approval' );
504 if ( $pending && wpfval( $pending, 'status' ) === 'pending_approval' ) {
505 return 'pending_approval';
506 }
507 return 'error';
508 }
509
510 $subscription = wpfval( $status, 'subscription' );
511 $sub_status = wpfval( $subscription, 'status' );
512 $plan = wpfval( $subscription, 'plan' );
513
514 // If API returns active status, clear any stale pending transient
515 // This must happen BEFORE checking the transient so activated tenants show correct state
516 if ( in_array( $sub_status, [ 'active', 'trial' ] ) ) {
517 delete_transient( 'wpforo_ai_pending_approval' );
518 }
519
520 // Check if tenant is pending approval (from API response)
521 if ( $sub_status === 'pending_approval' ) {
522 return 'pending_approval';
523 }
524
525 // Check if tenant is temporarily inactive (admin deactivated)
526 if ( $sub_status === 'inactive' ) {
527 return 'inactive';
528 }
529
530 // Check if subscription has explicitly expired (from API)
531 if ( $sub_status === 'expired' ) {
532 return 'expired';
533 }
534
535 // Check if subscription was cancelled or refunded (via Freemius/Paddle webhook)
536 if ( $sub_status === 'cancelled' || $sub_status === 'refunded' ) {
537 return 'cancelled';
538 }
539
540 // Check if subscription status is invalid (neither active, trial, nor a known state)
541 if ( ! in_array( $sub_status, [ 'active', 'trial' ] ) ) {
542 return 'error';
543 }
544
545 // Check if it's free trial
546 if ( $plan === 'free_trial' ) {
547 return 'free_trial';
548 }
549
550 // Otherwise it's a paid plan
551 return 'paid_plan';
552 }
553
554 /**
555 * Get indexed topic counts per forum from AI backend
556 *
557 * Uses VectorStorageManager abstraction to get counts from
558 * either local storage or cloud, depending on storage mode.
559 *
560 * @return array Array mapping forum_id => indexed_count, or empty array on failure
561 */
562 function wpforo_ai_get_indexed_counts_by_forum() {
563 // Use VectorStorageManager abstraction
564 return WPF()->vector_storage->get_indexed_counts_by_forum();
565 }
566
567 /**
568 * Display notice
569 *
570 * @param array $notice Notice data (type, message)
571 */
572 function wpforo_ai_display_notice( $notice ) {
573 $type = wpfval( $notice, 'type' );
574 $message = wpfval( $notice, 'message' );
575
576 if ( empty( $type ) || empty( $message ) ) {
577 return;
578 }
579
580 $class = 'notice notice-' . esc_attr( $type ) . ' is-dismissible';
581 ?>
582 <div class="<?php echo $class; ?>">
583 <p><?php echo wp_kses_post( $message ); ?></p>
584 </div>
585 <?php
586 if ( ! empty( $notice['needs_refresh'] ) ) :
587 ?>
588 <script>
589 (function() {
590 setTimeout(function() {
591 window.location.reload();
592 }, 5000);
593 })();
594 </script>
595 <?php
596 endif;
597 }
598
599 /**
600 * Get current admin user's timezone
601 *
602 * Priority:
603 * 1. wpforo_profile table (user's forum timezone)
604 * 2. WordPress site timezone
605 * 3. Default to UTC
606 *
607 * @return DateTimeZone User's timezone object
608 */
609 function wpforo_ai_get_user_timezone() {
610 static $timezone = null;
611
612 if ( $timezone !== null ) {
613 return $timezone;
614 }
615
616 $timezone_string = '';
617
618 // 1. Try to get user's timezone from wpforo profile
619 $user_id = get_current_user_id();
620 if ( $user_id ) {
621 $member = WPF()->member->get_member( $user_id );
622 if ( ! empty( $member['timezone'] ) ) {
623 $timezone_string = str_replace( '_', ' ', $member['timezone'] );
624 }
625 }
626
627 // 2. Fall back to WordPress site timezone
628 if ( empty( $timezone_string ) ) {
629 $timezone_string = wp_timezone_string();
630 }
631
632 // 3. Fall back to UTC
633 if ( empty( $timezone_string ) ) {
634 $timezone_string = 'UTC';
635 }
636
637 try {
638 // Handle UTC offset format (e.g., "UTC+3", "UTC-5")
639 if ( strpos( $timezone_string, 'UTC/' ) === 0 ) {
640 $timezone_string = str_replace( 'UTC/', '', $timezone_string );
641 }
642 $timezone = new DateTimeZone( $timezone_string );
643 } catch ( Exception $e ) {
644 $timezone = new DateTimeZone( 'UTC' );
645 }
646
647 return $timezone;
648 }
649
650 /**
651 * Convert UTC database timestamp to user's timezone
652 *
653 * @param string|int $utc_datetime UTC datetime string or timestamp
654 * @param string $format Output format (default: WordPress date + time format)
655 *
656 * @return string Formatted datetime in user's timezone
657 */
658 function wpforo_ai_format_datetime( $utc_datetime, $format = '' ) {
659 if ( empty( $utc_datetime ) ) {
660 return '';
661 }
662
663 // Default format: WordPress date + time format
664 if ( empty( $format ) ) {
665 $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
666 }
667
668 try {
669 // Parse the UTC datetime
670 $utc_tz = new DateTimeZone( 'UTC' );
671
672 if ( is_numeric( $utc_datetime ) ) {
673 $datetime = new DateTime( '@' . $utc_datetime );
674 } else {
675 $datetime = new DateTime( $utc_datetime, $utc_tz );
676 }
677
678 // Convert to user's timezone
679 $user_tz = wpforo_ai_get_user_timezone();
680 $datetime->setTimezone( $user_tz );
681
682 // Format using date_i18n for translation support
683 return date_i18n( $format, $datetime->getTimestamp() + $datetime->getOffset() );
684 } catch ( Exception $e ) {
685 // Fallback to original value if parsing fails
686 return is_numeric( $utc_datetime ) ? date( $format, $utc_datetime ) : $utc_datetime;
687 }
688 }
689
690 /**
691 * Format date only (without time) in user's timezone
692 *
693 * @param string|int $utc_datetime UTC datetime string or timestamp
694 *
695 * @return string Formatted date in user's timezone
696 */
697 function wpforo_ai_format_date( $utc_datetime ) {
698 if ( empty( $utc_datetime ) ) {
699 return '';
700 }
701
702 return wpforo_ai_format_datetime( $utc_datetime, get_option( 'date_format' ) );
703 }
704
705 /**
706 * Format time only (without date) in user's timezone
707 *
708 * @param string|int $utc_datetime UTC datetime string or timestamp
709 *
710 * @return string Formatted time in user's timezone
711 */
712 function wpforo_ai_format_time( $utc_datetime ) {
713 if ( empty( $utc_datetime ) ) {
714 return '';
715 }
716
717 return wpforo_ai_format_datetime( $utc_datetime, get_option( 'time_format' ) );
718 }
719
720 /**
721 * Format date with custom format in user's timezone
722 *
723 * @param string|int $utc_datetime UTC datetime string or timestamp
724 * @param string $format PHP date format
725 *
726 * @return string Formatted datetime in user's timezone
727 */
728 function wpforo_ai_format_date_custom( $utc_datetime, $format ) {
729 return wpforo_ai_format_datetime( $utc_datetime, $format );
730 }
731
732 /**
733 * Format time until a future date in "in X hours" style
734 *
735 * @param string|int $date Future date string or timestamp
736 * @return string Formatted string like "in 1 hour", "in 3 hours", "in 2 days"
737 */
738 function wpforo_ai_format_time_until( $date ) {
739 if ( empty( $date ) ) {
740 return '';
741 }
742
743 $timestamp = is_numeric( $date ) ? (int) $date : strtotime( $date );
744 if ( ! $timestamp ) {
745 return '';
746 }
747
748 $now = current_time( 'timestamp' );
749 $diff = $timestamp - $now;
750
751 // If the time has passed
752 if ( $diff <= 0 ) {
753 $overdue_mins = abs( $diff ) / MINUTE_IN_SECONDS;
754 // If overdue by more than 5 minutes, show "overdue"
755 if ( $overdue_mins > 5 ) {
756 return __( 'overdue', 'wpforo' );
757 }
758 return __( 'now', 'wpforo' );
759 }
760
761 $minutes = floor( $diff / MINUTE_IN_SECONDS );
762 $hours = floor( $diff / HOUR_IN_SECONDS );
763 $days = floor( $diff / DAY_IN_SECONDS );
764
765 if ( $days >= 1 ) {
766 /* translators: %d is the number of days */
767 return sprintf( _n( 'in %d day', 'in %d days', $days, 'wpforo' ), $days );
768 } elseif ( $hours >= 1 ) {
769 /* translators: %d is the number of hours */
770 return sprintf( _n( 'in %d hour', 'in %d hours', $hours, 'wpforo' ), $hours );
771 } else {
772 /* translators: %d is the number of minutes */
773 return sprintf( _n( 'in %d min', 'in %d mins', max( 1, $minutes ), 'wpforo' ), max( 1, $minutes ) );
774 }
775 }
776
777 /**
778 * Handle manual topic indexing
779 *
780 * @return array Notice data
781 */
782 function wpforo_ai_handle_manual_ingest() {
783 $topic_input = sanitize_text_field( wpfval( $_POST, 'topic_input' ) );
784
785 if ( empty( $topic_input ) ) {
786 return [
787 'type' => 'error',
788 'message' => __( 'Please provide a Topic ID or URL.', 'wpforo' ),
789 ];
790 }
791
792 // Extract topic ID from input (could be ID or URL)
793 $topic_id = wpforo_ai_extract_topic_id( $topic_input );
794
795 if ( ! $topic_id ) {
796 return [
797 'type' => 'error',
798 'message' => __( 'Invalid Topic ID or URL.', 'wpforo' ),
799 ];
800 }
801
802 // Get chunking configuration from saved options
803 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
804 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
805
806 // Use VectorStorageManager for indexing (handles both local and cloud modes)
807 $response = WPF()->vector_storage->ingest_topics( [ $topic_id ], $chunk_size, $overlap_percent );
808
809 if ( is_wp_error( $response ) ) {
810 return [
811 'type' => 'error',
812 'message' => sprintf(
813 __( 'Content indexing failed: %s', 'wpforo' ),
814 $response->get_error_message()
815 ),
816 ];
817 }
818
819 // Clear indexed counts cache to show updated stats
820 delete_transient( 'wpforo_ai_indexed_counts' );
821
822 // Check if all posts were unchanged (no reindexing needed)
823 $threads_indexed = isset( $response['threads_indexed'] ) ? (int) $response['threads_indexed'] : 0;
824 $posts_unchanged = isset( $response['stats']['deduplication']['posts_unchanged'] ) ? (int) $response['stats']['deduplication']['posts_unchanged'] : 0;
825
826 // If threads_indexed is 0 and we have unchanged posts, show appropriate message
827 if ( $threads_indexed === 0 && $posts_unchanged > 0 ) {
828 return [
829 'type' => 'info',
830 'message' => sprintf(
831 __( 'Topic #%d is already up to date. All %d posts are unchanged, no reindexing needed. (0 credits used)', 'wpforo' ),
832 $topic_id,
833 $posts_unchanged
834 ),
835 ];
836 }
837
838 // Check for updates (existing topic with changes)
839 $posts_changed = isset( $response['deduplication']['posts_changed'] ) ? (int) $response['deduplication']['posts_changed'] : 0;
840 $posts_new = isset( $response['deduplication']['posts_new'] ) ? (int) $response['deduplication']['posts_new'] : 0;
841 $credits_consumed = isset( $response['credits_consumed'] ) ? (int) $response['credits_consumed'] : 0;
842 $topics_queued = isset( $response['topics_queued'] ) ? (int) $response['topics_queued'] : 0;
843
844 // For queued jobs (local mode), credits are consumed during async processing
845 $credits_message = ( $topics_queued > 0 && $credits_consumed === 0 )
846 ? __( 'Credits will be consumed during processing.', 'wpforo' )
847 : sprintf( __( '%d credit(s) used.', 'wpforo' ), $credits_consumed );
848
849 if ( $posts_changed > 0 || ( $posts_new > 0 && $posts_unchanged > 0 ) ) {
850 // This is an UPDATE to an existing topic
851 return [
852 'type' => 'success',
853 'message' => sprintf(
854 __( 'Topic #%d update queued. %d posts to re-index (%d changed, %d new). %s', 'wpforo' ),
855 $topic_id,
856 $posts_changed + $posts_new,
857 $posts_changed,
858 $posts_new,
859 $credits_message
860 ),
861 ];
862 }
863
864 return [
865 'type' => 'success',
866 'message' => sprintf(
867 __( 'Topic #%d has been queued for indexing. Check the status box for progress. %s', 'wpforo' ),
868 $topic_id,
869 $credits_message
870 ),
871 ];
872 }
873
874 /**
875 * Handle filtered topic indexing
876 *
877 * @return array Notice data
878 */
879 function wpforo_ai_handle_filtered_ingest() {
880 $date_from = sanitize_text_field( wpfval( $_POST, 'date_from' ) );
881 $date_to = sanitize_text_field( wpfval( $_POST, 'date_to' ) );
882 $topic_tags = sanitize_text_field( wpfval( $_POST, 'topic_tags' ) );
883 $user_ids = sanitize_text_field( wpfval( $_POST, 'user_ids' ) );
884 $topic_inputs = sanitize_textarea_field( wpfval( $_POST, 'topic_inputs' ) );
885
886 // If specific topics are provided, use them directly (ignores other filters)
887 if ( ! empty( $topic_inputs ) ) {
888 // Parse comma or newline separated values
889 $inputs = preg_split( '/[\s,]+/', $topic_inputs, -1, PREG_SPLIT_NO_EMPTY );
890 $topic_ids = [];
891 $invalid_inputs = [];
892
893 foreach ( $inputs as $input ) {
894 $input = trim( $input );
895 if ( empty( $input ) ) {
896 continue;
897 }
898 $topic_id = wpforo_ai_extract_topic_id( $input );
899 if ( $topic_id ) {
900 $topic_ids[] = $topic_id;
901 } else {
902 $invalid_inputs[] = $input;
903 }
904 }
905
906 if ( ! empty( $invalid_inputs ) && empty( $topic_ids ) ) {
907 return [
908 'type' => 'error',
909 'message' => sprintf(
910 __( 'Could not find topics for: %s', 'wpforo' ),
911 implode( ', ', array_slice( $invalid_inputs, 0, 5 ) )
912 ),
913 ];
914 }
915
916 if ( empty( $topic_ids ) ) {
917 return [
918 'type' => 'error',
919 'message' => __( 'No valid topic IDs or URLs provided.', 'wpforo' ),
920 ];
921 }
922
923 // Remove duplicates
924 $topic_ids = array_unique( $topic_ids );
925
926 // Get chunking configuration from saved options
927 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
928 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
929
930 // Use VectorStorageManager for indexing (handles both local and cloud modes)
931 $response = WPF()->vector_storage->ingest_topics( $topic_ids, $chunk_size, $overlap_percent );
932
933 if ( is_wp_error( $response ) ) {
934 return [
935 'type' => 'error',
936 'message' => sprintf(
937 __( 'Content indexing failed: %s', 'wpforo' ),
938 $response->get_error_message()
939 ),
940 ];
941 }
942
943 // Clear indexed counts cache
944 delete_transient( 'wpforo_ai_indexed_counts' );
945
946 $threads_indexed = isset( $response['threads_indexed'] ) ? (int) $response['threads_indexed'] : 0;
947 $credits_consumed = isset( $response['credits_consumed'] ) ? (int) $response['credits_consumed'] : 0;
948 $topics_queued = isset( $response['topics_queued'] ) ? (int) $response['topics_queued'] : 0;
949
950 // For queued jobs (local mode), credits are consumed during async processing
951 if ( $topics_queued > 0 && $credits_consumed === 0 ) {
952 $message = sprintf(
953 __( '%d topic(s) queued for indexing. Credits will be consumed during processing.', 'wpforo' ),
954 count( $topic_ids )
955 );
956 } else {
957 $message = sprintf(
958 __( '%d topic(s) queued for indexing. %d credit(s) used.', 'wpforo' ),
959 count( $topic_ids ),
960 $credits_consumed
961 );
962 }
963
964 if ( ! empty( $invalid_inputs ) ) {
965 $message .= ' ' . sprintf(
966 __( 'Note: Could not find topics for: %s', 'wpforo' ),
967 implode( ', ', array_slice( $invalid_inputs, 0, 3 ) )
968 );
969 }
970
971 return [
972 'type' => 'success',
973 'message' => $message,
974 ];
975 }
976
977 // Validate filter combinations
978 if ( ! empty( $topic_tags ) && ! empty( $user_ids ) ) {
979 return [
980 'type' => 'error',
981 'message' => __( 'Cannot combine Topic Tags with User IDs filter. Please use only one.', 'wpforo' ),
982 ];
983 }
984
985 // Must have at least one filter
986 if ( empty( $date_from ) && empty( $date_to ) && empty( $topic_tags ) && empty( $user_ids ) ) {
987 return [
988 'type' => 'error',
989 'message' => __( 'Please provide at least one filter (date range, tags, user IDs, or specific topics).', 'wpforo' ),
990 ];
991 }
992
993 // Build WHERE conditions for direct SQL query
994 global $wpdb;
995 $table = WPF()->tables->topics;
996 $where_conditions = [];
997 $topics = [];
998
999 // Only index approved/public topics (status = 0)
1000 $where_conditions[] = "`status` = 0";
1001
1002 // Only get topics that haven't been indexed based on storage mode
1003 // Use VectorStorageManager's method to ensure consistency with actual storage mode
1004 $storage_mode = WPF()->vector_storage->get_storage_mode();
1005 $index_column = ( $storage_mode === 'cloud' ) ? 'cloud' : 'local';
1006 // Note: We still include already-indexed topics when specific filters are used
1007 // This allows re-indexing of specific content when users want to update it
1008 // The ingest process will handle updating existing vectors
1009
1010 // Date filter
1011 if ( ! empty( $date_from ) ) {
1012 $where_conditions[] = $wpdb->prepare( "`created` >= %s", $date_from . ' 00:00:00' );
1013 }
1014 if ( ! empty( $date_to ) ) {
1015 $where_conditions[] = $wpdb->prepare( "`created` <= %s", $date_to . ' 23:59:59' );
1016 }
1017
1018 // User ID filter
1019 if ( ! empty( $user_ids ) ) {
1020 $user_id_array = array_map( 'intval', array_filter( explode( ',', $user_ids ) ) );
1021 if ( ! empty( $user_id_array ) ) {
1022 $placeholders = implode( ', ', array_fill( 0, count( $user_id_array ), '%d' ) );
1023 $where_conditions[] = $wpdb->prepare( "`userid` IN ($placeholders)", ...$user_id_array );
1024 }
1025 }
1026
1027 // Tag filter - use same sanitization as wpForo's tag storage (sanitize_text_field, not sanitize_title)
1028 // Tags are stored comma-separated in the database using sanitize_text_field, so we must match that format
1029 if ( ! empty( $topic_tags ) ) {
1030 // Split by comma and trim each tag (same as wpForo's sanitize_tags function)
1031 $tag_list = array_map( 'trim', explode( ',', $topic_tags ) );
1032 $tag_list = array_map( 'sanitize_text_field', $tag_list );
1033 $tag_list = array_filter( $tag_list );
1034 // Apply lowercase if wpForo setting is enabled (same as sanitize_tags)
1035 if ( wpforo_setting( 'tags', 'lowercase' ) ) {
1036 $tag_list = array_map( function_exists( 'mb_strtolower' ) ? 'mb_strtolower' : 'strtolower', $tag_list );
1037 }
1038
1039 if ( ! empty( $tag_list ) ) {
1040 $tag_conditions = [];
1041 foreach ( $tag_list as $tag ) {
1042 $tag_conditions[] = $wpdb->prepare( "FIND_IN_SET(%s, `tags`)", $tag );
1043 }
1044 // Match topics that have ANY of the specified tags (OR logic)
1045 $where_conditions[] = '(' . implode( ' OR ', $tag_conditions ) . ')';
1046 }
1047 }
1048
1049 // Execute direct SQL query
1050 $where_sql = implode( ' AND ', $where_conditions );
1051 $sql = "SELECT * FROM `{$table}` WHERE {$where_sql} ORDER BY `created` DESC LIMIT 500";
1052
1053 $topics = $wpdb->get_results( $sql, ARRAY_A );
1054
1055 if ( empty( $topics ) ) {
1056 $filter_description = [];
1057 if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
1058 $filter_description[] = __( 'date range', 'wpforo' );
1059 }
1060 if ( ! empty( $topic_tags ) ) {
1061 $filter_description[] = __( 'tags', 'wpforo' );
1062 }
1063 if ( ! empty( $user_ids ) ) {
1064 $filter_description[] = __( 'user IDs', 'wpforo' );
1065 }
1066
1067 return [
1068 'type' => 'info',
1069 'message' => sprintf(
1070 __( 'No topics found matching the selected filters (%s).', 'wpforo' ),
1071 implode( ', ', $filter_description )
1072 ),
1073 ];
1074 }
1075
1076 // Extract topic IDs
1077 $topic_ids = array_column( $topics, 'topicid' );
1078
1079 // Get chunking configuration from saved options
1080 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1081 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1082
1083 // Use VectorStorageManager for indexing (handles both local and cloud modes)
1084 $response = WPF()->vector_storage->ingest_topics( $topic_ids, $chunk_size, $overlap_percent );
1085
1086 if ( is_wp_error( $response ) ) {
1087 return [
1088 'type' => 'error',
1089 'message' => sprintf(
1090 __( 'Content indexing failed: %s', 'wpforo' ),
1091 $response->get_error_message()
1092 ),
1093 ];
1094 }
1095
1096 // Clear indexed counts cache
1097 delete_transient( 'wpforo_ai_indexed_counts' );
1098
1099 $credits_consumed = isset( $response['credits_consumed'] ) ? (int) $response['credits_consumed'] : 0;
1100 $topics_queued = isset( $response['topics_queued'] ) ? (int) $response['topics_queued'] : 0;
1101
1102 // For queued jobs (local mode), credits are consumed during async processing
1103 if ( $topics_queued > 0 && $credits_consumed === 0 ) {
1104 return [
1105 'type' => 'success',
1106 'message' => sprintf(
1107 __( '%d topic(s) matching your filters have been queued for indexing. Credits will be consumed during processing.', 'wpforo' ),
1108 count( $topic_ids )
1109 ),
1110 ];
1111 }
1112
1113 return [
1114 'type' => 'success',
1115 'message' => sprintf(
1116 __( '%d topic(s) matching your filters have been queued for indexing. %d credit(s) used.', 'wpforo' ),
1117 count( $topic_ids ),
1118 $credits_consumed
1119 ),
1120 ];
1121 }
1122
1123 /**
1124 * Handle forum-based topic indexing
1125 *
1126 * Uses direct SQL with LIMIT to safely handle very large forums (hundreds of
1127 * thousands of topics) without loading every row into PHP memory. Filters
1128 * private/unapproved topics at the SQL level (not post-fetch) and skips
1129 * topics already indexed in the current storage mode (cloud/local column).
1130 *
1131 * The per-request cap is `WPFORO_AI_FORUM_INGEST_MAX_PER_RUN` (default 10000),
1132 * filterable via `wpforo_ai_forum_ingest_max_per_run`. Subsequent runs pick
1133 * up where the previous one left off because already-indexed topics are
1134 * excluded by the `cloud=0` / `local=0` WHERE clause.
1135 *
1136 * @return array Notice data
1137 */
1138 function wpforo_ai_handle_forum_ingest() {
1139 global $wpdb;
1140
1141 $forum_ids = isset( $_POST['forum_ids'] ) && is_array( $_POST['forum_ids'] ) ? array_map( 'intval', $_POST['forum_ids'] ) : [];
1142 $forum_ids = array_values( array_filter( $forum_ids, function( $id ) { return $id > 0; } ) );
1143 $date_from = sanitize_text_field( wpfval( $_POST, 'date_from' ) );
1144 $date_to = sanitize_text_field( wpfval( $_POST, 'date_to' ) );
1145
1146 if ( empty( $forum_ids ) ) {
1147 return [
1148 'type' => 'error',
1149 'message' => __( 'Please select at least one forum to index.', 'wpforo' ),
1150 ];
1151 }
1152
1153 $has_date_filter = ! empty( $date_from ) || ! empty( $date_to );
1154
1155 // Per-run cap, prevents OOM / autoloaded option bloat on huge forums.
1156 // Remaining topics are picked up on the next run because the SQL below
1157 // excludes rows that are already indexed in the current storage mode.
1158 if ( ! defined( 'WPFORO_AI_FORUM_INGEST_MAX_PER_RUN' ) ) {
1159 define( 'WPFORO_AI_FORUM_INGEST_MAX_PER_RUN', 10000 );
1160 }
1161 $max_per_run = (int) apply_filters( 'wpforo_ai_forum_ingest_max_per_run', WPFORO_AI_FORUM_INGEST_MAX_PER_RUN );
1162 if ( $max_per_run <= 0 ) {
1163 $max_per_run = 10000;
1164 }
1165
1166 // Choose the "already indexed" column based on current storage mode.
1167 // This way each run picks only not-yet-indexed topics, so running the
1168 // button again continues from where it stopped.
1169 $is_local = WPF()->vector_storage->is_local_mode();
1170 $mode_col = $is_local ? 'local' : 'cloud';
1171 $topics_tbl = WPF()->tables->topics;
1172
1173 // Build the WHERE clause with safe placeholders.
1174 // Filters applied at SQL level:
1175 // - forumid IN (...) : only selected forums
1176 // - status = 0 : only approved topics (skip pending/spam)
1177 // - private = 0 : never index private topics
1178 // - {mode_col} = 0 : skip already-indexed topics in this storage mode
1179 // - optional date range on `created`
1180 // ORDER BY topicid DESC : newest-first, deterministic, matches pagination UX
1181 // LIMIT {max_per_run} : hard cap per run
1182 $in_placeholders = implode( ',', array_fill( 0, count( $forum_ids ), '%d' ) );
1183 $where_sql = "`forumid` IN ({$in_placeholders}) AND `status` = 0 AND `private` = 0 AND `{$mode_col}` = 0";
1184 $params = $forum_ids;
1185
1186 if ( ! empty( $date_from ) ) {
1187 $where_sql .= " AND `created` >= %s";
1188 $params[] = $date_from . ' 00:00:00';
1189 }
1190 if ( ! empty( $date_to ) ) {
1191 $where_sql .= " AND `created` <= %s";
1192 $params[] = $date_to . ' 23:59:59';
1193 }
1194
1195 $params[] = $max_per_run;
1196
1197 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared — table name, column name, and IN placeholders are built from trusted sources
1198 $sql = "SELECT `topicid` FROM `{$topics_tbl}` WHERE {$where_sql} ORDER BY `topicid` DESC LIMIT %d";
1199 $topic_ids = $wpdb->get_col( $wpdb->prepare( $sql, $params ) );
1200 $topic_ids = array_values( array_map( 'intval', (array) $topic_ids ) );
1201
1202 if ( empty( $topic_ids ) ) {
1203 $message = $has_date_filter
1204 ? __( 'No topics found in the selected forums within the specified date range (already-indexed, private and unapproved topics are excluded).', 'wpforo' )
1205 : __( 'No topics found in the selected forums to index. All eligible topics may already be indexed (private and unapproved topics are always skipped).', 'wpforo' );
1206 return [
1207 'type' => 'warning',
1208 'message' => $message,
1209 ];
1210 }
1211
1212 $selected_count = count( $topic_ids );
1213 $capped = ( $selected_count >= $max_per_run );
1214
1215 // Get chunking configuration from saved options
1216 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1217 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1218
1219 // Use VectorStorageManager for indexing (handles both local and cloud modes).
1220 // For cloud mode this enqueues to the option-backed queue and schedules ONE
1221 // self-rescheduling cron event. For local mode it uses the same pattern.
1222 $response = WPF()->vector_storage->ingest_topics( $topic_ids, $chunk_size, $overlap_percent );
1223
1224 if ( is_wp_error( $response ) ) {
1225 return [
1226 'type' => 'error',
1227 'message' => sprintf(
1228 __( 'Forum indexing failed: %s', 'wpforo' ),
1229 $response->get_error_message()
1230 ),
1231 ];
1232 }
1233
1234 $forum_count = count( $forum_ids );
1235
1236 // Clear indexed counts cache to show updated stats
1237 delete_transient( 'wpforo_ai_indexed_counts' );
1238
1239 // Build success message
1240 $base_message = sprintf(
1241 _n(
1242 '%1$d topic(s) from %2$d forum queued for indexing.',
1243 '%1$d topic(s) from %2$d forums queued for indexing.',
1244 $forum_count,
1245 'wpforo'
1246 ),
1247 $selected_count,
1248 $forum_count
1249 );
1250
1251 if ( $has_date_filter ) {
1252 if ( $date_from && $date_to ) {
1253 $base_message .= ' ' . sprintf( __( '(from %1$s to %2$s)', 'wpforo' ), $date_from, $date_to );
1254 } elseif ( $date_from ) {
1255 $base_message .= ' ' . sprintf( __( '(from %s)', 'wpforo' ), $date_from );
1256 } elseif ( $date_to ) {
1257 $base_message .= ' ' . sprintf( __( '(until %s)', 'wpforo' ), $date_to );
1258 }
1259 }
1260
1261 if ( $capped ) {
1262 $base_message .= ' ' . sprintf(
1263 __( 'Note: capped at %d topics per run. Click the button again after this run completes to continue with the remaining topics.', 'wpforo' ),
1264 $max_per_run
1265 );
1266 }
1267
1268 $base_message .= ' ' . __( 'Check the status box for progress.', 'wpforo' );
1269
1270 return [
1271 'type' => 'success',
1272 'message' => $base_message,
1273 'needs_refresh' => true,
1274 ];
1275 }
1276
1277 /**
1278 * Handle clear forum index - remove embeddings for selected forums only.
1279 *
1280 * @return array Notice data
1281 */
1282 function wpforo_ai_handle_clear_forum_index() {
1283 // Nonce already verified in wpforo_ai_handle_form_actions() using shared nonce mapping
1284
1285 $forum_ids = isset( $_POST['forum_ids'] ) && is_array( $_POST['forum_ids'] ) ? array_map( 'intval', $_POST['forum_ids'] ) : [];
1286 $forum_ids = array_values( array_filter( $forum_ids, function( $id ) { return $id > 0; } ) );
1287
1288 if ( empty( $forum_ids ) ) {
1289 return [
1290 'type' => 'error',
1291 'message' => __( 'Please select at least one forum to clear.', 'wpforo' ),
1292 ];
1293 }
1294
1295 if ( ! WPF()->vector_storage ) {
1296 return [
1297 'type' => 'error',
1298 'message' => __( 'Vector storage is not available.', 'wpforo' ),
1299 ];
1300 }
1301
1302 $result = WPF()->vector_storage->clear_forum_embeddings( $forum_ids );
1303
1304 if ( is_wp_error( $result ) ) {
1305 return [
1306 'type' => 'error',
1307 'message' => sprintf( __( 'Failed to clear forum index: %s', 'wpforo' ), $result->get_error_message() ),
1308 ];
1309 }
1310
1311 $is_local = WPF()->vector_storage->is_local_mode();
1312 $mode = $is_local ? __( 'local', 'wpforo' ) : __( 'cloud', 'wpforo' );
1313
1314 // Cloud mode returns async response, local mode returns sync counts
1315 if ( ! $is_local && isset( $result['status'] ) && 'clearing' === $result['status'] ) {
1316 // Cloud async response
1317 return [
1318 'type' => 'success',
1319 'message' => sprintf(
1320 _n(
1321 'Forum index clear queued for %1$d forum (%2$s storage mode). Clearing in background - this may take a few minutes.',
1322 'Forum index clear queued for %1$d forums (%2$s storage mode). Clearing in background - this may take a few minutes.',
1323 count( $forum_ids ),
1324 'wpforo'
1325 ),
1326 count( $forum_ids ),
1327 $mode
1328 ),
1329 'needs_refresh' => true,
1330 ];
1331 }
1332
1333 // Local sync response
1334 $deleted = isset( $result['deleted_embeddings'] ) ? (int) $result['deleted_embeddings'] : 0;
1335
1336 return [
1337 'type' => 'success',
1338 'message' => sprintf(
1339 _n(
1340 'Cleared %1$d embedding(s) from %2$d forum (%3$s storage mode). Topics can now be re-indexed.',
1341 'Cleared %1$d embedding(s) from %2$d forums (%3$s storage mode). Topics can now be re-indexed.',
1342 count( $forum_ids ),
1343 'wpforo'
1344 ),
1345 $deleted,
1346 count( $forum_ids ),
1347 $mode
1348 ),
1349 'needs_refresh' => true,
1350 ];
1351 }
1352
1353 /**
1354 * Handle re-index all topics
1355 *
1356 * @return array Notice data
1357 */
1358 function wpforo_ai_handle_reindex_all() {
1359 // Get chunking configuration from POST, fallback to saved options
1360 $saved_chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1361 $saved_overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1362
1363 $chunk_size = isset( $_POST['chunk_size'] ) ? (int) $_POST['chunk_size'] : $saved_chunk_size;
1364 $overlap_percent = isset( $_POST['overlap_percent'] ) ? (int) $_POST['overlap_percent'] : $saved_overlap_percent;
1365
1366 // Validate parameters (chunk_size is in tokens, range 100-1024)
1367 if ( $chunk_size < 100 || $chunk_size > 1024 ) {
1368 $chunk_size = $saved_chunk_size; // Reset to saved if invalid
1369 }
1370
1371 if ( $overlap_percent < 5 || $overlap_percent > 50 ) {
1372 $overlap_percent = $saved_overlap_percent; // Reset to saved if invalid
1373 }
1374
1375 // Use VectorStorageManager for reindexing (handles both local and cloud modes)
1376 $response = WPF()->vector_storage->reindex_all_topics( $chunk_size, $overlap_percent );
1377
1378 if ( is_wp_error( $response ) ) {
1379 return [
1380 'type' => 'error',
1381 'message' => sprintf(
1382 __( 'Re-indexing failed: %s', 'wpforo' ),
1383 $response->get_error_message()
1384 ),
1385 ];
1386 }
1387
1388 $topics_queued = isset( $response['topics_queued'] ) ? (int) $response['topics_queued'] : 0;
1389 $topics_limited = isset( $response['topics_limited'] ) ? (bool) $response['topics_limited'] : false;
1390 $skipped_topics = isset( $response['skipped_topics'] ) ? (int) $response['skipped_topics'] : 0;
1391
1392 // Clear indexed counts cache to show updated stats
1393 delete_transient( 'wpforo_ai_indexed_counts' );
1394
1395 // Use the detailed message from VectorStorageManager if available, otherwise build one
1396 if ( isset( $response['message'] ) && ! empty( $response['message'] ) ) {
1397 $message = $response['message'];
1398 } else {
1399 $message = sprintf(
1400 __( 'Re-indexing started! %d topics have been queued for processing with chunk size %d and %d%% overlap. This will run in the background.', 'wpforo' ),
1401 $topics_queued,
1402 $chunk_size,
1403 $overlap_percent
1404 );
1405 }
1406
1407 return [
1408 'type' => 'success',
1409 'message' => $message,
1410 ];
1411 }
1412
1413 /**
1414 * Handle re-index topics with images only (cloud mode)
1415 *
1416 * Finds all topics with images in the first post and re-indexes them.
1417 * This is used when user wants to update image embeddings.
1418 *
1419 * @return array Notice data
1420 */
1421 function wpforo_ai_handle_reindex_images() {
1422 // Get chunking configuration
1423 $saved_chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1424 $saved_overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1425
1426 $chunk_size = isset( $_POST['chunk_size'] ) ? (int) $_POST['chunk_size'] : $saved_chunk_size;
1427 $overlap_percent = isset( $_POST['overlap_percent'] ) ? (int) $_POST['overlap_percent'] : $saved_overlap_percent;
1428
1429 // Validate parameters
1430 $chunk_size = max( 100, min( 1024, $chunk_size ) );
1431 $overlap_percent = max( 5, min( 50, $overlap_percent ) );
1432
1433 // Get all topics
1434 $topics = WPF()->topic->get_topics( [
1435 'status' => 0,
1436 'row_count' => 999999999,
1437 'orderby' => 'topicid',
1438 'order' => 'ASC',
1439 ] );
1440
1441 if ( empty( $topics ) ) {
1442 return [
1443 'type' => 'info',
1444 'message' => __( 'No topics found.', 'wpforo' ),
1445 ];
1446 }
1447
1448 // Filter to only topics with images
1449 $topics_with_images = wpforo_ai_filter_topics_with_images( $topics );
1450
1451 if ( empty( $topics_with_images ) ) {
1452 return [
1453 'type' => 'info',
1454 'message' => __( 'No topics with images found to re-index.', 'wpforo' ),
1455 ];
1456 }
1457
1458 $topic_ids = array_column( $topics_with_images, 'topicid' );
1459 $total_topics = count( $topic_ids );
1460
1461 // Clear cloud indexed status for these topics to force re-indexing
1462 global $wpdb;
1463 $placeholders = implode( ',', array_fill( 0, count( $topic_ids ), '%d' ) );
1464 $wpdb->query(
1465 $wpdb->prepare(
1466 "UPDATE `" . WPF()->tables->topics . "` SET `cloud` = 0 WHERE `topicid` IN ($placeholders)",
1467 ...$topic_ids
1468 )
1469 );
1470
1471 // Clear cache
1472 wpforo_clean_cache( 'topic' );
1473 delete_transient( 'wpforo_ai_indexed_counts' );
1474
1475 // Now use the regular reindex which will pick up unindexed topics
1476 $response = WPF()->vector_storage->reindex_all_topics( $chunk_size, $overlap_percent );
1477
1478 if ( is_wp_error( $response ) ) {
1479 return [
1480 'type' => 'error',
1481 'message' => sprintf(
1482 __( 'Re-indexing failed: %s', 'wpforo' ),
1483 $response->get_error_message()
1484 ),
1485 ];
1486 }
1487
1488 $message = sprintf(
1489 __( 'Re-indexing started! %d topics with images have been queued for processing. This will run in the background.', 'wpforo' ),
1490 $total_topics
1491 );
1492
1493 return [
1494 'type' => 'success',
1495 'message' => $message,
1496 ];
1497 }
1498
1499 /**
1500 * Handle clear RAG database
1501 *
1502 * @return array Notice data
1503 */
1504 function wpforo_ai_handle_clear_database() {
1505 $confirm = sanitize_text_field( wpfval( $_POST, 'confirm' ) );
1506
1507 if ( $confirm !== 'DELETE' ) {
1508 return [
1509 'type' => 'error',
1510 'message' => __( 'You must type DELETE to confirm clearing the database.', 'wpforo' ),
1511 ];
1512 }
1513
1514 // Use VectorStorageManager for clearing (handles both local and cloud modes)
1515 $response = WPF()->vector_storage->clear_all_embeddings();
1516
1517 if ( is_wp_error( $response ) ) {
1518 return [
1519 'type' => 'error',
1520 'message' => sprintf(
1521 __( 'Clear failed: %s', 'wpforo' ),
1522 $response->get_error_message()
1523 ),
1524 ];
1525 }
1526
1527 // Clear indexed counts cache to show updated stats
1528 delete_transient( 'wpforo_ai_indexed_counts' );
1529
1530 return [
1531 'type' => 'success',
1532 'message' => __( 'RAG database has been cleared successfully. All indexed data has been removed.', 'wpforo' ),
1533 ];
1534 }
1535
1536 /**
1537 * Handle clear and re-index
1538 *
1539 * @return array Notice data
1540 */
1541 function wpforo_ai_handle_clear_and_reindex() {
1542 $confirm = sanitize_text_field( wpfval( $_POST, 'confirm' ) );
1543
1544 if ( $confirm !== 'CLEAR' ) {
1545 return [
1546 'type' => 'error',
1547 'message' => __( 'You must type CLEAR to confirm this action.', 'wpforo' ),
1548 ];
1549 }
1550
1551 // Get chunking configuration from POST, fallback to saved options
1552 $saved_chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1553 $saved_overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1554
1555 $chunk_size = isset( $_POST['chunk_size'] ) ? (int) $_POST['chunk_size'] : $saved_chunk_size;
1556 $overlap_percent = isset( $_POST['overlap_percent'] ) ? (int) $_POST['overlap_percent'] : $saved_overlap_percent;
1557
1558 // Validate parameters (chunk_size is in tokens, range 100-1024)
1559 if ( $chunk_size < 100 || $chunk_size > 1024 ) {
1560 $chunk_size = $saved_chunk_size; // Reset to saved if invalid
1561 }
1562
1563 if ( $overlap_percent < 5 || $overlap_percent > 50 ) {
1564 $overlap_percent = $saved_overlap_percent; // Reset to saved if invalid
1565 }
1566
1567 // First clear (using VectorStorageManager)
1568 $clear_response = WPF()->vector_storage->clear_all_embeddings();
1569
1570 if ( is_wp_error( $clear_response ) ) {
1571 return [
1572 'type' => 'error',
1573 'message' => sprintf(
1574 __( 'Clear failed: %s', 'wpforo' ),
1575 $clear_response->get_error_message()
1576 ),
1577 ];
1578 }
1579
1580 // Then re-index (using VectorStorageManager)
1581 $reindex_response = WPF()->vector_storage->reindex_all_topics( $chunk_size, $overlap_percent );
1582
1583 if ( is_wp_error( $reindex_response ) ) {
1584 return [
1585 'type' => 'error',
1586 'message' => sprintf(
1587 __( 'Database cleared but re-indexing failed: %s', 'wpforo' ),
1588 $reindex_response->get_error_message()
1589 ),
1590 ];
1591 }
1592
1593 $total_topics = isset( $reindex_response['total_topics'] ) ? (int) $reindex_response['total_topics'] : 0;
1594
1595 // Clear indexed counts cache to show updated stats
1596 delete_transient( 'wpforo_ai_indexed_counts' );
1597
1598 return [
1599 'type' => 'success',
1600 'message' => sprintf(
1601 __( 'Database cleared successfully! %d topics are now being indexed fresh with chunk size %d and %d%% overlap. This will run in the background.', 'wpforo' ),
1602 $total_topics,
1603 $chunk_size,
1604 $overlap_percent
1605 ),
1606 ];
1607 }
1608
1609 /**
1610 * Handle saving chunking configuration
1611 *
1612 * @return array Notice data
1613 */
1614 function wpforo_ai_handle_save_chunking_config() {
1615 // Get and validate chunk_size (in tokens, not characters)
1616 $chunk_size = isset( $_POST['chunk_size'] ) ? (int) $_POST['chunk_size'] : 512;
1617 if ( $chunk_size < 100 || $chunk_size > 1024 ) {
1618 return [
1619 'type' => 'error',
1620 'message' => __( 'Chunk size must be between 100 and 1024 tokens.', 'wpforo' ),
1621 ];
1622 }
1623
1624 // Get and validate overlap_percent
1625 $overlap_percent = isset( $_POST['overlap_percent'] ) ? (int) $_POST['overlap_percent'] : 20;
1626 if ( $overlap_percent < 5 || $overlap_percent > 50 ) {
1627 return [
1628 'type' => 'error',
1629 'message' => __( 'Overlap percentage must be between 5 and 50.', 'wpforo' ),
1630 ];
1631 }
1632
1633 // Get and validate pagination_size (also used as batch size for local indexing)
1634 $pagination_size = isset( $_POST['pagination_size'] ) ? (int) $_POST['pagination_size'] : 10;
1635 if ( $pagination_size < 1 || $pagination_size > 50 ) {
1636 return [
1637 'type' => 'error',
1638 'message' => __( 'Topics per batch must be between 1 and 50.', 'wpforo' ),
1639 ];
1640 }
1641
1642 // Save to wpForo options
1643 wpforo_update_option( 'ai_chunk_size', $chunk_size );
1644 wpforo_update_option( 'ai_overlap_percent', $overlap_percent );
1645 wpforo_update_option( 'ai_pagination_size', $pagination_size );
1646
1647 return [
1648 'type' => 'success',
1649 'message' => sprintf(
1650 __( 'Indexing configuration saved: Chunk size %d tokens, Overlap %d%%, Topics per batch %d.', 'wpforo' ),
1651 $chunk_size,
1652 $overlap_percent,
1653 $pagination_size
1654 ),
1655 ];
1656 }
1657
1658 /**
1659 * Handle stop indexing (clear all pending cron jobs)
1660 *
1661 * @return array Notice data
1662 */
1663 function wpforo_ai_handle_stop_indexing() {
1664 $result = WPF()->ai_client->clear_pending_cron_jobs();
1665
1666 if ( empty( $result['success'] ) ) {
1667 return [
1668 'type' => 'error',
1669 'message' => __( 'Failed to stop indexing. Please try again.', 'wpforo' ),
1670 ];
1671 }
1672
1673 $cleared_jobs = isset( $result['cleared_jobs'] ) ? (int) $result['cleared_jobs'] : 0;
1674 $cleared_topics = isset( $result['cleared_topics'] ) ? (int) $result['cleared_topics'] : 0;
1675
1676 if ( $cleared_jobs === 0 ) {
1677 return [
1678 'type' => 'info',
1679 'message' => __( 'No pending indexing jobs found.', 'wpforo' ),
1680 ];
1681 }
1682
1683 return [
1684 'type' => 'success',
1685 'message' => sprintf(
1686 __( 'Indexing stopped. %d scheduled jobs removed (%d topics will not be processed).', 'wpforo' ),
1687 $cleared_jobs,
1688 $cleared_topics
1689 ),
1690 ];
1691 }
1692
1693 /**
1694 * Extract topic ID from input (ID or URL)
1695 *
1696 * @param string $input Topic ID or URL
1697 * @return int|false Topic ID or false on failure
1698 */
1699 function wpforo_ai_extract_topic_id( $input ) {
1700 $input = trim( $input );
1701
1702 // If it's already numeric, return it
1703 if ( is_numeric( $input ) ) {
1704 return (int) $input;
1705 }
1706
1707 // Try to extract ID from URL
1708 // Common formats:
1709 // - /topic/123/slug
1710 // - /topic/123
1711 // - ?topicid=123
1712 if ( preg_match( '/topic[\/=](\d+)/i', $input, $matches ) ) {
1713 return (int) $matches[1];
1714 }
1715
1716 // Try wpForo's native URL parsing if available
1717 if ( function_exists( 'wpforo_get_topic_id_from_url' ) ) {
1718 $topic_id = wpforo_get_topic_id_from_url( $input );
1719 if ( $topic_id ) {
1720 return $topic_id;
1721 }
1722 }
1723
1724 // Try to extract slug from URL and look up topic
1725 // URL format: https://example.com/forum/category/topic-slug-123/#postid456
1726 $url = $input;
1727
1728 // Remove anchor (e.g., #postid456)
1729 $url = preg_replace( '/#.*$/', '', $url );
1730
1731 // Remove query string
1732 $url = preg_replace( '/\?.*$/', '', $url );
1733
1734 // Remove trailing slash
1735 $url = rtrim( $url, '/' );
1736
1737 // Get the last path segment (slug)
1738 $slug = basename( $url );
1739
1740 if ( ! empty( $slug ) && ! is_numeric( $slug ) ) {
1741 // Try to find topic by slug
1742 $topic = WPF()->topic->get_topic( $slug );
1743 if ( $topic && ! empty( $topic['topicid'] ) ) {
1744 return (int) $topic['topicid'];
1745 }
1746 }
1747
1748 return false;
1749 }
1750
1751 // =============================================================================
1752 // LOCAL INDEXING - AJAX-DRIVEN BATCH PROCESSING
1753 // =============================================================================
1754
1755 /**
1756 * Filter topics to only include those with images in the first post
1757 *
1758 * Used by the "Re-Index Topic Images" button to only index topics
1759 * that contain images.
1760 *
1761 * @param array $topics Array of topic data from get_topics()
1762 * @return array Filtered topics with images
1763 */
1764 function wpforo_ai_filter_topics_with_images( $topics ) {
1765 if ( empty( $topics ) ) {
1766 return [];
1767 }
1768
1769 $ai_client = WPF()->ai_client;
1770 $filtered = [];
1771
1772 foreach ( $topics as $topic ) {
1773 $first_postid = isset( $topic['first_postid'] ) ? (int) $topic['first_postid'] : 0;
1774 if ( ! $first_postid ) {
1775 continue;
1776 }
1777
1778 // Get the first post content
1779 $post = WPF()->post->get_post( $first_postid );
1780 if ( empty( $post['body'] ) ) {
1781 continue;
1782 }
1783
1784 // Check if post has images
1785 $images = $ai_client->extract_post_images( $post['body'] );
1786 if ( ! empty( $images ) ) {
1787 $filtered[] = $topic;
1788 }
1789 }
1790
1791 return $filtered;
1792 }
1793
1794 /**
1795 * Start local indexing - queues all topics and returns initial info
1796 *
1797 * This is called when user clicks "Index All" in local mode.
1798 * Topics are queued, then JavaScript calls process_local_batch in a loop.
1799 *
1800 * @return array Response with queue info
1801 */
1802 function wpforo_ai_handle_start_local_indexing() {
1803 $storage_manager = WPF()->vector_storage;
1804
1805 // Only for local mode
1806 if ( ! $storage_manager->is_local_mode() ) {
1807 return [
1808 'type' => 'error',
1809 'message' => __( 'This action is only for local storage mode.', 'wpforo' ),
1810 ];
1811 }
1812
1813 // Get settings from POST or saved options
1814 $saved_chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
1815 $saved_overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
1816 $saved_batch_size = (int) wpforo_get_option( 'ai_pagination_size', 5 );
1817
1818 $chunk_size = isset( $_POST['chunk_size'] ) ? (int) $_POST['chunk_size'] : $saved_chunk_size;
1819 $overlap_percent = isset( $_POST['overlap_percent'] ) ? (int) $_POST['overlap_percent'] : $saved_overlap_percent;
1820 $batch_size = isset( $_POST['batch_size'] ) ? (int) $_POST['batch_size'] : $saved_batch_size;
1821
1822 // Validate
1823 $chunk_size = max( 100, min( 1024, $chunk_size ) );
1824 $overlap_percent = max( 5, min( 50, $overlap_percent ) );
1825 $batch_size = max( 1, min( 50, $batch_size ) );
1826
1827 // Check if we're only indexing topics with images
1828 $images_only = isset( $_POST['images_only'] ) && $_POST['images_only'];
1829
1830 // Get all topics - order by topicid to ensure consistent processing
1831 $topics = WPF()->topic->get_topics( [
1832 'status' => 0,
1833 'row_count' => 999999999,
1834 'orderby' => 'topicid',
1835 'order' => 'ASC',
1836 ] );
1837
1838 if ( empty( $topics ) ) {
1839 return [
1840 'type' => 'info',
1841 'message' => __( 'No topics found to index.', 'wpforo' ),
1842 ];
1843 }
1844
1845 // If images_only mode, filter to topics with images in first post
1846 if ( $images_only ) {
1847 $topics = wpforo_ai_filter_topics_with_images( $topics );
1848 if ( empty( $topics ) ) {
1849 return [
1850 'type' => 'info',
1851 'message' => __( 'No topics with images found to index.', 'wpforo' ),
1852 ];
1853 }
1854 }
1855
1856 $topic_ids = array_column( $topics, 'topicid' );
1857 $total_topics = count( $topic_ids );
1858
1859 // Check credits
1860 $ai_client = WPF()->ai_client;
1861 $status = $ai_client->get_tenant_status( true );
1862 if ( is_wp_error( $status ) ) {
1863 return [
1864 'type' => 'error',
1865 'message' => $status->get_error_message(),
1866 ];
1867 }
1868
1869 $credits_available = isset( $status['subscription']['credits_remaining'] )
1870 ? (int) $status['subscription']['credits_remaining']
1871 : 0;
1872
1873 if ( $credits_available <= 0 ) {
1874 return [
1875 'type' => 'error',
1876 'message' => __( 'No credits available. Please wait for your monthly reset or purchase additional credits.', 'wpforo' ),
1877 ];
1878 }
1879
1880 // Check if there are enough credits for all topics (1 credit per topic)
1881 $credits_needed = $total_topics;
1882 $will_complete = $credits_available >= $credits_needed;
1883
1884 // Store queue and settings in options
1885 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
1886 $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
1887 $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
1888
1889 update_option( $queue_key, $topic_ids, false );
1890 update_option( $settings_key, [
1891 'chunk_size' => $chunk_size,
1892 'overlap_percent' => $overlap_percent,
1893 'batch_size' => $batch_size,
1894 'total_topics' => $total_topics,
1895 'started_at' => time(),
1896 ], false );
1897
1898 // Also schedule WP Cron as fallback (in case page is closed)
1899 $cron_hook = 'wpforo_ai_process_queue';
1900 $cron_args = [ $board_id ];
1901 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
1902 wp_schedule_single_event( time() + 60, $cron_hook, $cron_args );
1903 }
1904
1905 // Build message based on credit availability and mode
1906 $topic_label = $images_only
1907 ? __( 'topics with images', 'wpforo' )
1908 : __( 'topics', 'wpforo' );
1909
1910 if ( $will_complete ) {
1911 $message = sprintf(
1912 /* translators: 1: number of topics, 2: topic type label, 3: batch size, 4: credits available */
1913 __( 'Indexing started! %1$d %2$s will be processed in batches of %3$d. You have %4$d credits available.', 'wpforo' ),
1914 $total_topics,
1915 $topic_label,
1916 $batch_size,
1917 $credits_available
1918 );
1919 } else {
1920 $message = sprintf(
1921 /* translators: 1: number of topics, 2: topic type label, 3: batch size, 4: credits available, 5: credits needed */
1922 __( 'Indexing started! %1$d %2$s will be processed in batches of %3$d. Note: You have %4$d credits but need %5$d. Indexing will stop when credits run out.', 'wpforo' ),
1923 $total_topics,
1924 $topic_label,
1925 $batch_size,
1926 $credits_available,
1927 $credits_needed
1928 );
1929 }
1930
1931 return [
1932 'type' => 'success',
1933 'action' => 'indexing_started',
1934 'total_topics' => $total_topics,
1935 'queue_count' => $total_topics,
1936 'batch_size' => $batch_size,
1937 'credits_available' => $credits_available,
1938 'credits_needed' => $credits_needed,
1939 'will_complete' => $will_complete,
1940 'message' => $message,
1941 ];
1942 }
1943
1944 /**
1945 * Stop local indexing
1946 *
1947 * Clears the indexing queue from WordPress options.
1948 * Called via AJAX when user clicks Stop Indexing in local mode.
1949 *
1950 * @return array Response with status info
1951 */
1952 function wpforo_ai_handle_stop_local_indexing() {
1953 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
1954 $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
1955 $remaining_count = 0;
1956
1957 // Clear mode-specific queue keys (current format) and legacy key
1958 foreach ( [ 'local', 'cloud', '' ] as $mode ) {
1959 $queue_key = 'wpforo_ai_indexing_queue_' . ( $mode ? $mode . '_' : '' ) . $board_id;
1960 $queue = get_option( $queue_key, [] );
1961 if ( ! empty( $queue ) && is_array( $queue ) ) {
1962 $remaining_count += count( $queue );
1963 }
1964 delete_option( $queue_key );
1965 }
1966
1967 // Clear settings
1968 delete_option( $settings_key );
1969
1970 // Clear all queue processor crons (mode-specific and legacy)
1971 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_local', [ $board_id ] );
1972 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_cloud', [ $board_id ] );
1973 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
1974
1975 // Release the indexing lock
1976 $lock_key = 'wpforo_ai_indexing_lock_' . $board_id;
1977 delete_transient( $lock_key );
1978
1979 // Clear the RAG status cache
1980 WPF()->ai_client->clear_rag_status_cache();
1981
1982 return [
1983 'type' => 'success',
1984 'message' => sprintf(
1985 __( 'Local indexing stopped. %d topics removed from queue.', 'wpforo' ),
1986 $remaining_count
1987 ),
1988 'cleared' => $remaining_count,
1989 ];
1990 }
1991
1992 /**
1993 * Process one batch of local indexing
1994 *
1995 * Called by JavaScript in a loop until queue is empty.
1996 *
1997 * @return array Response with progress info
1998 */
1999 function wpforo_ai_handle_process_local_batch() {
2000 // Extend PHP timeout for this long-running operation
2001 // API timeout is 25 seconds, so we need at least 30+ seconds
2002 @set_time_limit( 120 );
2003
2004 try {
2005 $storage_manager = WPF()->vector_storage;
2006
2007 // Only for local mode
2008 if ( ! $storage_manager->is_local_mode() ) {
2009 return [
2010 'type' => 'error',
2011 'message' => __( 'This action is only for local storage mode.', 'wpforo' ),
2012 ];
2013 }
2014
2015 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
2016 $lock_key = 'wpforo_ai_indexing_lock_' . $board_id;
2017 $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
2018 $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
2019
2020 // Acquire lock to prevent concurrent processing (AJAX vs AJAX and AJAX vs WP-Cron).
2021 // Without this, page reloads trigger checkLocalIndexingProgress() which starts
2022 // a second processing loop while the first is still in-flight.
2023 $existing_lock = get_transient( $lock_key );
2024 if ( $existing_lock ) {
2025 return [
2026 'type' => 'info',
2027 'message' => __( 'Another batch is being processed. Retrying...', 'wpforo' ),
2028 'action' => 'wait',
2029 ];
2030 }
2031 // Lock per batch — expires after 2 minutes in case of crash
2032 set_transient( $lock_key, 'batch_' . time(), 120 );
2033
2034 // Get queue and settings
2035 $pending_topics = get_option( $queue_key, [] );
2036 $settings = get_option( $settings_key, [
2037 'chunk_size' => 512,
2038 'overlap_percent' => 20,
2039 'batch_size' => 10,
2040 'total_topics' => 0,
2041 ] );
2042
2043 if ( empty( $pending_topics ) ) {
2044 // Queue is empty - indexing complete
2045 delete_option( $queue_key );
2046 delete_option( $settings_key );
2047 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
2048 delete_transient( $lock_key ); // Release lock
2049
2050 // Get final stats
2051 $stats = $storage_manager->get_indexing_stats();
2052
2053 return [
2054 'type' => 'success',
2055 'action' => 'indexing_complete',
2056 'done' => true,
2057 'processed' => 0,
2058 'remaining' => 0,
2059 'total' => $settings['total_topics'],
2060 'indexed' => $stats['total_indexed'] ?? 0,
2061 'message' => __( 'Indexing complete!', 'wpforo' ),
2062 ];
2063 }
2064
2065 $batch_size = (int) ( $settings['batch_size'] ?? 10 );
2066 $chunk_size = (int) ( $settings['chunk_size'] ?? 512 );
2067 $overlap_percent = (int) ( $settings['overlap_percent'] ?? 20 );
2068 $total_topics = (int) ( $settings['total_topics'] ?? 0 );
2069
2070 // Early credit check - stop immediately if no credits available
2071 $status = WPF()->ai_client->get_tenant_status( true );
2072 if ( ! is_wp_error( $status ) && isset( $status['subscription']['credits_remaining'] ) ) {
2073 $credits_remaining = (int) $status['subscription']['credits_remaining'];
2074 if ( $credits_remaining <= 0 ) {
2075 $processed = $total_topics - count( $pending_topics );
2076
2077 // Clear queue so page reload doesn't auto-resume
2078 delete_option( $queue_key );
2079 delete_option( $settings_key );
2080 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
2081 delete_transient( $lock_key );
2082 WPF()->ai_client->clear_rag_status_cache();
2083
2084 return [
2085 'type' => 'error',
2086 'action' => 'credits_exhausted',
2087 'done' => true,
2088 'processed' => $processed,
2089 'remaining' => 0,
2090 'total' => $total_topics,
2091 'credits_remaining' => 0,
2092 'errors' => [],
2093 'message' => __( 'Indexing stopped: Insufficient credits. Please wait for your monthly reset or purchase additional credits.', 'wpforo' ),
2094 ];
2095 }
2096 }
2097
2098 // Take next batch
2099 $batch = array_slice( $pending_topics, 0, $batch_size );
2100 $remaining = array_slice( $pending_topics, $batch_size );
2101
2102 // Update queue IMMEDIATELY before processing — matches cron_process_queue() pattern.
2103 // Prevents duplicate batch extraction if page reloads during processing.
2104 // If processing fails, topics are removed from queue but content_hash dedup
2105 // in index_topics_batch_local() will skip them on next "Index Remaining" run.
2106 if ( ! empty( $remaining ) ) {
2107 update_option( $queue_key, $remaining, false );
2108 } else {
2109 delete_option( $queue_key );
2110 }
2111
2112 // Process this batch
2113 $result = $storage_manager->index_topics_batch_local( $batch, [
2114 'chunk_size' => $chunk_size,
2115 'overlap_percent' => $overlap_percent,
2116 ] );
2117
2118 $indexed_count = is_array( $result ) ? ( $result['indexed_count'] ?? 0 ) : 0;
2119 $skipped_count = is_array( $result ) ? ( $result['skipped_count'] ?? 0 ) : 0;
2120 $credits_used = is_array( $result ) ? ( $result['credits_used'] ?? 0 ) : 0;
2121 $errors = is_array( $result ) ? ( $result['errors'] ?? [] ) : [];
2122
2123 // Check for credit exhaustion - stop if no credits left
2124 $has_credit_error = false;
2125 foreach ( $errors as $error ) {
2126 if ( stripos( $error, 'insufficient credits' ) !== false || stripos( $error, '402' ) !== false ) {
2127 $has_credit_error = true;
2128 break;
2129 }
2130 }
2131
2132 if ( $has_credit_error ) {
2133 $processed = $total_topics - count( $pending_topics );
2134
2135 // Clear queue so page reload doesn't auto-resume
2136 delete_option( $queue_key );
2137 delete_option( $settings_key );
2138 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
2139 delete_transient( $lock_key );
2140 WPF()->ai_client->clear_rag_status_cache();
2141
2142 return [
2143 'type' => 'error',
2144 'action' => 'credits_exhausted',
2145 'done' => true,
2146 'processed' => $processed,
2147 'remaining' => 0,
2148 'total' => $total_topics,
2149 'credits_remaining' => 0,
2150 'errors' => $errors,
2151 'message' => __( 'Indexing stopped: Insufficient credits. Please wait for your monthly reset or purchase additional credits.', 'wpforo' ),
2152 ];
2153 }
2154
2155 // Reschedule WP Cron as backup (in case page is closed now)
2156 if ( ! empty( $remaining ) ) {
2157 $cron_hook = 'wpforo_ai_process_queue';
2158 $cron_args = [ $board_id ];
2159 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2160 wp_schedule_single_event( time() + 60, $cron_hook, $cron_args );
2161 }
2162 } else {
2163 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
2164 }
2165
2166 // Clear caches
2167 WPF()->ai_client->clear_rag_status_cache();
2168 delete_transient( 'wpforo_ai_indexed_counts' );
2169
2170 // Always release lock after each batch — next AJAX call will reacquire.
2171 // The queue was already updated before processing (line ~1892), so even if
2172 // a concurrent request sneaks in between batches, it gets different topics.
2173 delete_transient( $lock_key );
2174
2175 // Get updated credit balance (fresh fetch)
2176 $status = WPF()->ai_client->get_tenant_status( true );
2177 $credits_remaining = 0;
2178 if ( ! is_wp_error( $status ) && isset( $status['subscription']['credits_remaining'] ) ) {
2179 $credits_remaining = (int) $status['subscription']['credits_remaining'];
2180 }
2181
2182 $processed = $total_topics - count( $remaining );
2183
2184 return [
2185 'type' => 'success',
2186 'action' => 'batch_processed',
2187 'done' => empty( $remaining ),
2188 'processed' => $processed,
2189 'remaining' => count( $remaining ),
2190 'total' => $total_topics,
2191 'batch_indexed' => $indexed_count,
2192 'batch_skipped' => $skipped_count,
2193 'credits_used' => $credits_used,
2194 'credits_remaining' => $credits_remaining,
2195 'errors' => $errors,
2196 'message' => sprintf(
2197 __( 'Processed %d of %d topics...', 'wpforo' ),
2198 $processed,
2199 $total_topics
2200 ),
2201 ];
2202
2203 } catch ( \Exception $e ) {
2204 if ( isset( $lock_key ) ) {
2205 delete_transient( $lock_key );
2206 }
2207 wpforo_ai_log( 'error', 'process_local_batch exception: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), 'Indexing' );
2208 return [
2209 'type' => 'error',
2210 'message' => 'Exception: ' . $e->getMessage(),
2211 'file' => $e->getFile(),
2212 'line' => $e->getLine(),
2213 ];
2214 } catch ( \Error $e ) {
2215 if ( isset( $lock_key ) ) {
2216 delete_transient( $lock_key );
2217 }
2218 wpforo_ai_log( 'error', 'process_local_batch error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), 'Indexing' );
2219 return [
2220 'type' => 'error',
2221 'message' => 'PHP Error: ' . $e->getMessage(),
2222 'file' => $e->getFile(),
2223 'line' => $e->getLine(),
2224 ];
2225 }
2226 }
2227
2228 /**
2229 * Get current indexing progress
2230 *
2231 * Called on page load to check if indexing is in progress and auto-resume.
2232 *
2233 * @return array Response with current progress
2234 */
2235 function wpforo_ai_handle_get_indexing_progress() {
2236 $storage_manager = WPF()->vector_storage;
2237
2238 // Only for local mode
2239 if ( ! $storage_manager->is_local_mode() ) {
2240 return [
2241 'type' => 'success',
2242 'is_local_mode' => false,
2243 'indexing_active' => false,
2244 ];
2245 }
2246
2247 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
2248 $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
2249 $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
2250
2251 $pending_topics = get_option( $queue_key, [] );
2252 $settings = get_option( $settings_key, [] );
2253
2254 $indexing_active = ! empty( $pending_topics );
2255
2256 if ( ! $indexing_active ) {
2257 return [
2258 'type' => 'success',
2259 'is_local_mode' => true,
2260 'indexing_active' => false,
2261 ];
2262 }
2263
2264 $total_topics = (int) ( $settings['total_topics'] ?? count( $pending_topics ) );
2265 $processed = $total_topics - count( $pending_topics );
2266
2267 return [
2268 'type' => 'success',
2269 'is_local_mode' => true,
2270 'indexing_active' => true,
2271 'processed' => $processed,
2272 'remaining' => count( $pending_topics ),
2273 'total' => $total_topics,
2274 'batch_size' => (int) ( $settings['batch_size'] ?? 10 ),
2275 'started_at' => $settings['started_at'] ?? null,
2276 'message' => sprintf(
2277 __( 'Indexing in progress: %d of %d topics processed', 'wpforo' ),
2278 $processed,
2279 $total_topics
2280 ),
2281 ];
2282 }
2283
2284 /**
2285 * Clear local embeddings only (no reindex)
2286 *
2287 * Used before starting AJAX-driven local indexing.
2288 *
2289 * @return array Response
2290 */
2291 function wpforo_ai_handle_clear_local_embeddings() {
2292 $storage_manager = WPF()->vector_storage;
2293
2294 // Only for local mode
2295 if ( ! $storage_manager->is_local_mode() ) {
2296 return [
2297 'type' => 'error',
2298 'message' => __( 'This action is only for local storage mode.', 'wpforo' ),
2299 ];
2300 }
2301
2302 // Clear all local embeddings
2303 $result = $storage_manager->clear_all_embeddings();
2304
2305 if ( is_wp_error( $result ) ) {
2306 return [
2307 'type' => 'error',
2308 'message' => $result->get_error_message(),
2309 ];
2310 }
2311
2312 // Clear any pending indexing queue
2313 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
2314 delete_option( 'wpforo_ai_indexing_queue_' . $board_id );
2315 delete_option( 'wpforo_ai_indexing_settings_' . $board_id );
2316 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
2317
2318 // Clear caches
2319 WPF()->ai_client->clear_rag_status_cache();
2320 delete_transient( 'wpforo_ai_indexed_counts' );
2321
2322 return [
2323 'type' => 'success',
2324 'message' => __( 'Local embeddings cleared successfully.', 'wpforo' ),
2325 ];
2326 }
2327
2328 /**
2329 * Clear the forum topic counts cache used in AI Content Indexing
2330 *
2331 * This cache stores topic counts per forum to avoid N+1 queries.
2332 * It should be cleared when topics are created, deleted, or moved.
2333 *
2334 * @param int|null $board_id Specific board ID to clear, or null for all boards
2335 */
2336 function wpforo_ai_clear_forum_topic_counts_cache( $board_id = null ) {
2337 if ( $board_id !== null ) {
2338 delete_transient( 'wpforo_ai_ftc_' . intval( $board_id ) );
2339 } else {
2340 global $wpdb;
2341 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wpforo_ai_ftc_%' OR option_name LIKE '_transient_timeout_wpforo_ai_ftc_%'" );
2342 }
2343 }
2344
2345 /**
2346 * Hook callback to clear forum topic counts cache when topics change
2347 *
2348 * Called on topic create, delete, move, merge, and edit actions.
2349 * Clears only the current board's cache for efficiency.
2350 */
2351 function wpforo_ai_on_topic_count_changed() {
2352 $board_id = WPF()->board->get_current( 'boardid' );
2353 wpforo_ai_clear_forum_topic_counts_cache( $board_id );
2354 }
2355
2356 // Register hooks to clear forum topic counts cache when topics change
2357 add_action( 'wpforo_after_add_topic', 'wpforo_ai_on_topic_count_changed', 20 );
2358 add_action( 'wpforo_after_delete_topic', 'wpforo_ai_on_topic_count_changed', 20 );
2359 add_action( 'wpforo_after_move_topic', 'wpforo_ai_on_topic_count_changed', 20 );
2360 add_action( 'wpforo_after_merge_topic', 'wpforo_ai_on_topic_count_changed', 20 );
2361 add_action( 'wpforo_after_edit_topic', 'wpforo_ai_on_topic_count_changed', 20 );
2362