| 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 |
|