| @@ -11,9 +11,16 @@ | ||
| 11 | 11 | class OnWebChat_WooCommerce_Sync { |
| 12 | 12 | |
| 13 | 13 | private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce'; |
| 14 | 14 | private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce'; |
| 15 | - private $max_description_length = 1500; | |
| 15 | + // Descriptions are sent WHOLE: the onWebChat server decides what to do with | |
| 16 | + // them and rewrites the ones that do not fit its training text with a small | |
| 17 | + // model, instead of cutting them off (the tail of a description usually | |
| 18 | + // holds the specs and compatibility info). These are only sanity limits | |
| 19 | + // against pathological descriptions (page-builder dumps), sized so a | |
| 20 | + // 50-product batch stays far below the server's 10MB body limit. | |
| 21 | + private $max_description_length = 20000; | |
| 22 | + private $max_description_length_combined = 20000; | |
| 16 | 23 | private $batch_size = 50; |
| 17 | 24 | private $use_testing_mode; |
| 18 | 25 | |
| 19 | 26 | // Large-catalogue sync scope. |
| @@ -22,8 +29,12 @@ | ||
| 22 | 29 | // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded |
| 23 | 30 | // catalogue. Above the cap a category selection is required. |
| 24 | 31 | const CATEGORY_SELECT_THRESHOLD = 2000; |
| 25 | 32 | const MAX_SYNC_PRODUCTS = 15000; |
| 33 | + | |
| 34 | + // How many products one removal request deletes from the AI training data. | |
| 35 | + // The server accepts up to 500 product_ids per call. | |
| 36 | + const REMOVE_PAGE_SIZE = 200; | |
| 26 | 37 | |
| 27 | 38 | /** |
| 28 | 39 | * Get the API endpoint based on testing mode |
| 29 | 40 | * @return string |
| @@ -34,8 +45,19 @@ | ||
| 34 | 45 | |
| 35 | 46 | public function __construct() { |
| 36 | 47 | // Read testing mode from global constant (defined in onwebchat.php) |
| 37 | 48 | $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false; |
| 49 | + | |
| 50 | + // One-time migration to the "short + full" default. The Description Mode | |
| 51 | + // selector was hidden in the UI before this version, so a stored | |
| 52 | + // 'short_fallback_full' was the hidden form field's value, never a real | |
| 53 | + // merchant choice. An explicit 'short_only' is left untouched. | |
| 54 | + if (!get_option('onwebchat_wc_desc_mode_migrated')) { | |
| 55 | + if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') { | |
| 56 | + update_option('onwebchat_wc_sync_mode', 'short_plus_full'); | |
| 57 | + } | |
| 58 | + update_option('onwebchat_wc_desc_mode_migrated', 1); | |
| 59 | + } | |
| 38 | 60 | // Initialize settings |
| 39 | 61 | add_action('admin_init', array($this, 'register_settings')); |
| 40 | 62 | |
| 41 | 63 | // Show authentication error notice globally (not just on WooCommerce tab) |
| @@ -52,8 +74,22 @@ | ||
| 52 | 74 | // parent product's stock status from its variations and fires this action for the |
| 53 | 75 | // parent, which is the entity synced to onWebChat. |
| 54 | 76 | add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3); |
| 55 | 77 | |
| 78 | + // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale | |
| 79 | + // prices via direct meta updates, NOT through a product save, so | |
| 80 | + // woocommerce_update_product never fires and the AI would keep quoting the | |
| 81 | + // pre-sale price. These two actions receive the affected product/variation | |
| 82 | + // IDs right after the cron applies or removes the sale prices. | |
| 83 | + add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1); | |
| 84 | + add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1); | |
| 85 | + | |
| 86 | + // Variation price edits (the Variations tab saves via AJAX without always | |
| 87 | + // re-saving the parent post). Collect the parent IDs and sync each parent | |
| 88 | + // once on shutdown, so the synced price range follows variation changes. | |
| 89 | + add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1); | |
| 90 | + add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1); | |
| 91 | + | |
| 56 | 92 | // Handle product deletion (both trash and permanent delete) |
| 57 | 93 | add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1); |
| 58 | 94 | add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2); |
| 59 | 95 | |
| @@ -61,8 +97,17 @@ | ||
| 61 | 97 | add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch')); |
| 62 | 98 | |
| 63 | 99 | // Admin AJAX handlers |
| 64 | 100 | add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products')); |
| 101 | + // Client-driven chunked bulk sync: the browser starts a run, then calls | |
| 102 | + // the batch action repeatedly (one page per request) until it completes. | |
| 103 | + // This replaces the single long request that timed out on large | |
| 104 | + // catalogues and reported a false "sync failed" while products kept | |
| 105 | + // syncing. | |
| 106 | + add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync')); | |
| 107 | + add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch')); | |
| 108 | + add_action('wp_ajax_onwebchat_wc_scope_remove_start', array($this, 'ajax_scope_remove_start')); | |
| 109 | + add_action('wp_ajax_onwebchat_wc_scope_remove_batch', array($this, 'ajax_scope_remove_batch')); | |
| 65 | 110 | add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret')); |
| 66 | 111 | add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status')); |
| 67 | 112 | add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce')); |
| 68 | 113 | add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch')); |
| @@ -79,10 +124,15 @@ | ||
| 79 | 124 | if (!current_user_can('manage_options')) { |
| 80 | 125 | wp_send_json_error('Insufficient permissions'); |
| 81 | 126 | } |
| 82 | 127 | |
| 83 | - $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; | |
| 84 | - $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : ''; | |
| 128 | + // The password is only forwarded to onWebChat, never stored, echoed or put in a query, | |
| 129 | + // so it must NOT be sanitized. WordPress slash-escapes $_POST (wp_magic_quotes), and | |
| 130 | + // sanitize_text_field() on top of that trims it, collapses repeated spaces, turns "<" | |
| 131 | + // into an entity and DELETES any %xx sequence, so a correct password containing a quote, | |
| 132 | + // a space or a percent sign could never authenticate. wp_unslash() alone is right here. | |
| 133 | + $email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : ''; | |
| 134 | + $password = isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : ''; | |
| 85 | 135 | |
| 86 | 136 | if (empty($email) || empty($password)) { |
| 87 | 137 | wp_send_json_error('Email and password are required'); |
| 88 | 138 | } |
| @@ -181,8 +231,86 @@ | ||
| 181 | 231 | $this->send_product_upsert($product_data, $product_id); |
| 182 | 232 | } |
| 183 | 233 | |
| 184 | 234 | /** |
| 235 | + * Hook: WooCommerce's scheduled-sales cron started or ended sales on these | |
| 236 | + * products. IDs can be simple products OR variations; variations are mapped to | |
| 237 | + * their parent (the entity synced to onWebChat) and each product is re-pushed | |
| 238 | + * once, so the AI immediately quotes the new (sale or regular) price. | |
| 239 | + * | |
| 240 | + * @param array $product_ids | |
| 241 | + */ | |
| 242 | + public function on_scheduled_sales($product_ids) { | |
| 243 | + if (!get_option('onwebchat_wc_sync_enabled', false)) { | |
| 244 | + return; | |
| 245 | + } | |
| 246 | + | |
| 247 | + $ids = array(); | |
| 248 | + foreach ((array) $product_ids as $product_id) { | |
| 249 | + $product = wc_get_product($product_id); | |
| 250 | + if (!$product) { | |
| 251 | + continue; | |
| 252 | + } | |
| 253 | + | |
| 254 | + $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id(); | |
| 255 | + if ($id > 0) { | |
| 256 | + $ids[$id] = $id; // de-duplicate | |
| 257 | + } | |
| 258 | + } | |
| 259 | + | |
| 260 | + foreach ($ids as $id) { | |
| 261 | + $this->on_product_update($id); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + /** | |
| 266 | + * Parent product IDs whose variations changed in this request; flushed once on | |
| 267 | + * shutdown so a save touching 30 variations pushes the parent a single time. | |
| 268 | + */ | |
| 269 | + private $pending_variation_parents = array(); | |
| 270 | + | |
| 271 | + /** | |
| 272 | + * Hook: a variation was created/updated (Variations tab saves happen over AJAX | |
| 273 | + * and don't always re-save the parent post, so woocommerce_update_product may | |
| 274 | + * never fire). Queue the parent for one sync at the end of the request. | |
| 275 | + * | |
| 276 | + * @param int $variation_id | |
| 277 | + */ | |
| 278 | + public function on_variation_update($variation_id) { | |
| 279 | + if (!get_option('onwebchat_wc_sync_enabled', false)) { | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + | |
| 283 | + $variation = wc_get_product($variation_id); | |
| 284 | + if (!$variation || !$variation->is_type('variation')) { | |
| 285 | + return; | |
| 286 | + } | |
| 287 | + | |
| 288 | + $parent_id = (int) $variation->get_parent_id(); | |
| 289 | + if ($parent_id <= 0) { | |
| 290 | + return; | |
| 291 | + } | |
| 292 | + | |
| 293 | + if (empty($this->pending_variation_parents)) { | |
| 294 | + add_action('shutdown', array($this, 'flush_variation_parent_syncs')); | |
| 295 | + } | |
| 296 | + | |
| 297 | + $this->pending_variation_parents[$parent_id] = $parent_id; | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 301 | + * Shutdown: sync every parent whose variations changed in this request. | |
| 302 | + */ | |
| 303 | + public function flush_variation_parent_syncs() { | |
| 304 | + $parent_ids = $this->pending_variation_parents; | |
| 305 | + $this->pending_variation_parents = array(); | |
| 306 | + | |
| 307 | + foreach ($parent_ids as $parent_id) { | |
| 308 | + $this->on_product_update($parent_id); | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + /** | |
| 185 | 313 | * Hook: product stock STATUS changed (in stock / out of stock / on backorder). |
| 186 | 314 | * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder" |
| 187 | 315 | * is treated as available since the store still accepts orders. |
| 188 | 316 | * |
| @@ -268,12 +396,22 @@ | ||
| 268 | 396 | } |
| 269 | 397 | |
| 270 | 398 | /** |
| 271 | 399 | * Get the saved sync scope as an array of product_cat term IDs. |
| 272 | - * An empty array means the whole catalogue is in scope. | |
| 400 | + * An empty array on its own is ambiguous, so what it means is held | |
| 401 | + * separately, see is_scope_all(): with no categories the scope is either the | |
| 402 | + * whole catalogue or nothing at all. | |
| 273 | 403 | */ |
| 274 | 404 | private function get_sync_scope() { |
| 275 | - $raw = (string) get_option('onwebchat_wc_sync_categories', ''); | |
| 405 | + return $this->parse_id_list(get_option('onwebchat_wc_sync_categories', '')); | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** | |
| 409 | + * Comma-separated ids (as stored in options and posted by the picker) to a | |
| 410 | + * de-duplicated array of positive ints. | |
| 411 | + */ | |
| 412 | + private function parse_id_list($raw) { | |
| 413 | + $raw = (string) $raw; | |
| 276 | 414 | if ($raw === '') { |
| 277 | 415 | return array(); |
| 278 | 416 | } |
| 279 | 417 | |
| @@ -288,11 +426,57 @@ | ||
| 288 | 426 | return array_values($ids); |
| 289 | 427 | } |
| 290 | 428 | |
| 291 | 429 | /** |
| 292 | - * Persist the sync scope. Pass an empty array to clear it (whole catalogue). | |
| 430 | + * Is the scope the whole catalogue? An empty category list means two | |
| 431 | + * opposite things, so the answer is stored explicitly: | |
| 432 | + * '1' whole catalogue, '0' exactly the saved categories (none = nothing). | |
| 433 | + * Sites upgraded from an older version have no flag yet, and there an empty | |
| 434 | + * list always meant "the whole catalogue", which is what they keep until | |
| 435 | + * their next sync or removal writes the flag. | |
| 293 | 436 | */ |
| 294 | - private function save_sync_scope($category_ids) { | |
| 437 | + private function is_scope_all() { | |
| 438 | + $raw = (string) get_option('onwebchat_wc_sync_scope_all', ''); | |
| 439 | + | |
| 440 | + if ($raw === '') { | |
| 441 | + return !$this->get_sync_scope(); | |
| 442 | + } | |
| 443 | + | |
| 444 | + return $raw === '1'; | |
| 445 | + } | |
| 446 | + | |
| 447 | + /** | |
| 448 | + * Has the site ever recorded what its empty scope means? False only on a | |
| 449 | + * site upgraded from an older version that never picked categories, where | |
| 450 | + * a synced catalogue and an empty one look exactly the same. | |
| 451 | + */ | |
| 452 | + private function is_scope_known() { | |
| 453 | + return (string) get_option('onwebchat_wc_sync_scope_all', '') !== '' || (bool) $this->get_sync_scope(); | |
| 454 | + } | |
| 455 | + | |
| 456 | + /** | |
| 457 | + * Running a product sync is the merchant asking for their products in the | |
| 458 | + * chatbot, so it also switches automatic sync on: later edits, stock changes | |
| 459 | + * and images then reach the bot on their own. Before 3.10.0 the Sync button | |
| 460 | + * left the switch alone, so a store that never ticked it synced once and | |
| 461 | + * went stale without anyone noticing. The "off by removal" note (set when | |
| 462 | + * removing every category turned the switch off) has served its purpose. | |
| 463 | + */ | |
| 464 | + private function enable_auto_sync_for_run() { | |
| 465 | + delete_option('onwebchat_wc_sync_off_by_removal'); | |
| 466 | + | |
| 467 | + if (!get_option('onwebchat_wc_sync_enabled', false)) { | |
| 468 | + update_option('onwebchat_wc_sync_enabled', true); | |
| 469 | + } | |
| 470 | + } | |
| 471 | + | |
| 472 | + /** | |
| 473 | + * Persist the sync scope: the categories auto-sync covers, plus whether the | |
| 474 | + * scope is the whole catalogue. An empty array with $all false means the AI | |
| 475 | + * training data holds nothing (a fresh site, or one whose products were | |
| 476 | + * removed), so auto-sync has nothing to cover either. | |
| 477 | + */ | |
| 478 | + private function save_sync_scope($category_ids, $all) { | |
| 295 | 479 | $clean = array(); |
| 296 | 480 | foreach ((array) $category_ids as $id) { |
| 297 | 481 | $id = (int) $id; |
| 298 | 482 | if ($id > 0) { |
| @@ -300,14 +484,16 @@ | ||
| 300 | 484 | } |
| 301 | 485 | } |
| 302 | 486 | |
| 303 | 487 | update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean))); |
| 488 | + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0'); | |
| 304 | 489 | } |
| 305 | 490 | |
| 306 | 491 | /** |
| 307 | 492 | * Is the product within the current sync scope? |
| 308 | - * No scope set means everything is in scope. The picker only offers | |
| 309 | - * top-level categories, and selecting one covers its whole subtree, so a | |
| 493 | + * With no categories saved it comes down to what the empty list means: the | |
| 494 | + * whole catalogue (any product qualifies) or nothing at all. The picker offers the whole | |
| 495 | + * category tree and selecting a category covers its whole subtree, so a | |
| 310 | 496 | * product is in scope when any of its categories is a scoped category OR a |
| 311 | 497 | * descendant of one. This mirrors the bulk sync tax query |
| 312 | 498 | * (include_children = true). |
| 313 | 499 | */ |
| @@ -313,9 +499,9 @@ | ||
| 313 | 499 | */ |
| 314 | 500 | private function product_in_scope($product) { |
| 315 | 501 | $scope = $this->get_sync_scope(); |
| 316 | 502 | if (empty($scope)) { |
| 317 | - return true; | |
| 503 | + return $this->is_scope_all(); | |
| 318 | 504 | } |
| 319 | 505 | |
| 320 | 506 | foreach ($product->get_category_ids() as $cat_id) { |
| 321 | 507 | $cat_id = (int) $cat_id; |
| @@ -333,12 +519,113 @@ | ||
| 333 | 519 | return false; |
| 334 | 520 | } |
| 335 | 521 | |
| 336 | 522 | /** |
| 337 | - * Count published products within the given scope (empty = whole catalogue). | |
| 523 | + * Is this category already covered by the given scope? A scope covers a | |
| 524 | + * category when it holds the category itself or any of its ancestors, | |
| 525 | + * because selecting a category always includes its whole subtree. | |
| 526 | + */ | |
| 527 | + private function scope_covers($scope, $category_id) { | |
| 528 | + $category_id = (int) $category_id; | |
| 529 | + if (in_array($category_id, $scope, true)) { | |
| 530 | + return true; | |
| 531 | + } | |
| 532 | + | |
| 533 | + foreach (get_ancestors($category_id, 'product_cat', 'taxonomy') as $ancestor_id) { | |
| 534 | + if (in_array((int) $ancestor_id, $scope, true)) { | |
| 535 | + return true; | |
| 536 | + } | |
| 537 | + } | |
| 538 | + | |
| 539 | + return false; | |
| 540 | + } | |
| 541 | + | |
| 542 | + /** | |
| 543 | + * Which of the submitted categories are NOT yet covered by the saved scope. | |
| 544 | + * These are the only ones a sync has to push: everything already in scope is | |
| 545 | + * in the training data already. | |
| 546 | + */ | |
| 547 | + private function categories_added($submitted, $saved_scope) { | |
| 548 | + if (empty($saved_scope)) { | |
| 549 | + return array(); // whole catalogue already in scope, nothing is new | |
| 550 | + } | |
| 551 | + | |
| 552 | + $added = array(); | |
| 553 | + foreach ($submitted as $category_id) { | |
| 554 | + $category_id = (int) $category_id; | |
| 555 | + if ($category_id > 0 && !$this->scope_covers($saved_scope, $category_id)) { | |
| 556 | + $added[$category_id] = $category_id; | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + return array_values($added); | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * Which of the saved categories the merchant just unticked. Used to offer an | |
| 565 | + * explicit removal: unticking alone never drops anything (see | |
| 566 | + * ajax_scope_remove_start), because the saved scope only grows on sync. | |
| 567 | + */ | |
| 568 | + private function categories_removed($submitted, $saved_scope) { | |
| 569 | + if (empty($saved_scope)) { | |
| 570 | + return array(); | |
| 571 | + } | |
| 572 | + | |
| 573 | + $removed = array(); | |
| 574 | + foreach ($saved_scope as $category_id) { | |
| 575 | + $category_id = (int) $category_id; | |
| 576 | + if ($category_id > 0 && !$this->scope_covers($submitted, $category_id)) { | |
| 577 | + $removed[$category_id] = $category_id; | |
| 578 | + } | |
| 579 | + } | |
| 580 | + | |
| 581 | + return array_values($removed); | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** | |
| 585 | + * tax_query for "products inside $terms but not inside $exclude", both | |
| 586 | + * including their subtrees. Empty $terms means the whole catalogue. | |
| 587 | + * Returns null when no restriction applies at all. | |
| 588 | + */ | |
| 589 | + private function build_scope_tax_query($terms, $exclude = array()) { | |
| 590 | + $clauses = array(); | |
| 591 | + | |
| 592 | + if (!empty($terms)) { | |
| 593 | + $clauses[] = array( | |
| 594 | + 'taxonomy' => 'product_cat', | |
| 595 | + 'field' => 'term_id', | |
| 596 | + 'terms' => array_map('intval', $terms), | |
| 597 | + 'include_children' => true, | |
| 598 | + ); | |
| 599 | + } | |
| 600 | + | |
| 601 | + if (!empty($exclude)) { | |
| 602 | + $clauses[] = array( | |
| 603 | + 'taxonomy' => 'product_cat', | |
| 604 | + 'field' => 'term_id', | |
| 605 | + 'terms' => array_map('intval', $exclude), | |
| 606 | + 'include_children' => true, | |
| 607 | + 'operator' => 'NOT IN', | |
| 608 | + ); | |
| 609 | + } | |
| 610 | + | |
| 611 | + if (empty($clauses)) { | |
| 612 | + return null; | |
| 613 | + } | |
| 614 | + | |
| 615 | + if (count($clauses) > 1) { | |
| 616 | + $clauses['relation'] = 'AND'; | |
| 617 | + } | |
| 618 | + | |
| 619 | + return $clauses; | |
| 620 | + } | |
| 621 | + | |
| 622 | + /** | |
| 623 | + * Count published products within the given scope (empty = whole catalogue), | |
| 624 | + * optionally excluding everything inside $exclude and its subtrees. | |
| 338 | 625 | * Uses found_posts so we do not load every ID into memory. |
| 339 | 626 | */ |
| 340 | - private function count_products_in_scope($category_ids) { | |
| 627 | + private function count_products_in_scope($category_ids, $exclude = array()) { | |
| 341 | 628 | $args = array( |
| 342 | 629 | 'post_type' => 'product', |
| 343 | 630 | 'post_status' => 'publish', |
| 344 | 631 | 'posts_per_page' => 1, |
| @@ -345,15 +632,11 @@ | ||
| 345 | 632 | 'fields' => 'ids', |
| 346 | 633 | 'no_found_rows' => false, |
| 347 | 634 | ); |
| 348 | 635 | |
| 349 | - if (!empty($category_ids)) { | |
| 350 | - $args['tax_query'] = array(array( | |
| 351 | - 'taxonomy' => 'product_cat', | |
| 352 | - 'field' => 'term_id', | |
| 353 | - 'terms' => array_map('intval', $category_ids), | |
| 354 | - 'include_children' => true, | |
| 355 | - )); | |
| 636 | + $tax_query = $this->build_scope_tax_query($category_ids, $exclude); | |
| 637 | + if ($tax_query !== null) { | |
| 638 | + $args['tax_query'] = $tax_query; | |
| 356 | 639 | } |
| 357 | 640 | |
| 358 | 641 | $query = new WP_Query($args); |
| 359 | 642 | return (int) $query->found_posts; |
| @@ -359,19 +642,69 @@ | ||
| 359 | 642 | return (int) $query->found_posts; |
| 360 | 643 | } |
| 361 | 644 | |
| 362 | 645 | /** |
| 646 | + * What the AI training data currently covers, for the settings screen: | |
| 647 | + * array(categories, products, whole_catalogue, nothing, known). Three | |
| 648 | + * states: the whole catalogue, the saved categories, or nothing synced yet. | |
| 649 | + * 'known' is false only on a site upgraded from an older version whose | |
| 650 | + * empty scope could mean either, and there the screen says nothing at all | |
| 651 | + * rather than something wrong. | |
| 652 | + */ | |
| 653 | + public function get_scope_summary() { | |
| 654 | + $scope = $this->get_sync_scope(); | |
| 655 | + $all = $this->is_scope_all(); | |
| 656 | + | |
| 657 | + return array( | |
| 658 | + 'categories' => count($scope), | |
| 659 | + 'products' => $all ? $this->count_products_in_scope(array()) : ($scope ? $this->count_products_in_scope($scope) : 0), | |
| 660 | + 'whole_catalogue' => $all, | |
| 661 | + 'nothing' => !$all && !$scope, | |
| 662 | + 'known' => $this->is_scope_known(), | |
| 663 | + ); | |
| 664 | + } | |
| 665 | + | |
| 666 | + /** | |
| 667 | + * Turn HTML entities into real characters. Product text is often stored | |
| 668 | + * double-encoded ("&quot;" for a quote), where a single pass still | |
| 669 | + * leaves """ in the text the bot is trained on, so decode until the | |
| 670 | + * string stops changing (3 passes is far more than any real content needs). | |
| 671 | + * Always call this AFTER strip_tags: decoding first could turn text like | |
| 672 | + * "price < 100 and > 50" into something strip_tags eats as a tag. | |
| 673 | + */ | |
| 674 | + private function decode_entities($value) { | |
| 675 | + $value = (string) $value; | |
| 676 | + | |
| 677 | + for ($i = 0; $i < 3; $i++) { | |
| 678 | + $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8'); | |
| 679 | + if ($decoded === $value) { | |
| 680 | + break; | |
| 681 | + } | |
| 682 | + $value = $decoded; | |
| 683 | + } | |
| 684 | + | |
| 685 | + // html_entity_decode turns into a non-breaking space; make it a | |
| 686 | + // plain space so the text does not carry invisible oddities. | |
| 687 | + return str_replace("\xC2\xA0", ' ', $value); | |
| 688 | + } | |
| 689 | + | |
| 690 | + /** | |
| 363 | 691 | * Prepare product data for sync |
| 364 | 692 | */ |
| 365 | 693 | private function prepare_product_data($product) { |
| 366 | - $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full'); | |
| 694 | + $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full'); | |
| 367 | 695 | |
| 368 | 696 | // Get description based on sync mode |
| 369 | 697 | $description = ''; |
| 370 | - $short_description = strip_tags($product->get_short_description()); | |
| 698 | + $short_description = $this->decode_entities(strip_tags($product->get_short_description())); | |
| 371 | 699 | |
| 372 | 700 | if ($sync_mode === 'short_only') { |
| 373 | 701 | $description = $short_description; |
| 702 | + } else if ($sync_mode === 'short_plus_full') { | |
| 703 | + // Send both texts: the short description first, then the full one. | |
| 704 | + $full_description = $this->decode_entities(strip_tags($product->get_description())); | |
| 705 | + $parts = array_filter(array(trim($short_description), trim($full_description))); | |
| 706 | + $description = implode("\n\n", $parts); | |
| 374 | 707 | } else if ($sync_mode === 'short_fallback_full') { |
| 375 | 708 | if (!empty($short_description)) { |
| 376 | 709 | $description = $short_description; |
| 377 | 710 | } else { |
| @@ -378,9 +711,9 @@ | ||
| 378 | 711 | // Fallback to the first 200 words of the full description. |
| 379 | 712 | // Split with a Unicode-aware regex: str_word_count() does not |
| 380 | 713 | // recognize non-latin (e.g. Greek) words, so the old word cut |
| 381 | 714 | // was unreliable on multibyte text. |
| 382 | - $full_description = strip_tags($product->get_description()); | |
| 715 | + $full_description = $this->decode_entities(strip_tags($product->get_description())); | |
| 383 | 716 | $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY); |
| 384 | 717 | |
| 385 | 718 | if (is_array($words) && count($words) > 200) { |
| 386 | 719 | $description = implode(' ', array_slice($words, 0, 200)) . '...'; |
| @@ -391,10 +724,13 @@ | ||
| 391 | 724 | } |
| 392 | 725 | |
| 393 | 726 | // Enforce max length by characters, not bytes: a byte-based substr() |
| 394 | 727 | // can cut a multibyte UTF-8 character (e.g. Greek text) in half. |
| 395 | - if (mb_strlen($description, 'UTF-8') > $this->max_description_length) { | |
| 396 | - $description = mb_substr($description, 0, $this->max_description_length, 'UTF-8') . '...'; | |
| 728 | + $max_length = ($sync_mode === 'short_plus_full') | |
| 729 | + ? $this->max_description_length_combined | |
| 730 | + : $this->max_description_length; | |
| 731 | + if (mb_strlen($description, 'UTF-8') > $max_length) { | |
| 732 | + $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...'; | |
| 397 | 733 | } |
| 398 | 734 | |
| 399 | 735 | $sku = $product->get_sku(); |
| 400 | 736 | $categories = $this->get_product_category_names($product); |
| @@ -403,9 +739,11 @@ | ||
| 403 | 739 | // Structured fields. The server rebuilds the embedding text from these, |
| 404 | 740 | // so there is no need to send a pre-formatted "text" blob. |
| 405 | 741 | $data = array( |
| 406 | 742 | 'product_id' => $product->get_id(), |
| 407 | - 'name' => $product->get_name(), | |
| 743 | + // Names are stored HTML-escaped ("Bags & Belts"), so decode them: the name is | |
| 744 | + // also the title of the product card the widget shows (3.10.0+). | |
| 745 | + 'name' => $this->decode_entities($product->get_name()), | |
| 408 | 746 | 'short_description' => trim($description), |
| 409 | 747 | 'url' => $url, |
| 410 | 748 | 'sku' => $sku, |
| 411 | 749 | 'categories' => $categories, |
| @@ -411,23 +749,39 @@ | ||
| 411 | 749 | 'categories' => $categories, |
| 412 | 750 | 'currency' => get_woocommerce_currency(), |
| 413 | 751 | ); |
| 414 | 752 | |
| 415 | - // Price (always sent). Variable products carry a min/max range. | |
| 416 | - $data['price'] = $product->get_price(); | |
| 753 | + // Price, as the customer sees it in the shop. get_price() returns the value | |
| 754 | + // as entered in admin, which excludes tax on shops that enter net prices but | |
| 755 | + // display gross ones, so the AI would quote a price the visitor never sees. | |
| 756 | + // wc_get_price_to_display() applies the shop's tax display settings. | |
| 757 | + $raw_price = $product->get_price(); | |
| 758 | + if ($raw_price !== '') { | |
| 759 | + $data['price'] = wc_get_price_to_display($product); | |
| 417 | 760 | |
| 761 | + // When the shop displays taxed prices, also send the untaxed price so the | |
| 762 | + // AI can quote both. | |
| 763 | + if (wc_tax_enabled()) { | |
| 764 | + $price_excl_tax = wc_get_price_excluding_tax($product); | |
| 765 | + if ((float) $price_excl_tax !== (float) $data['price']) { | |
| 766 | + $data['price_excl_tax'] = $price_excl_tax; | |
| 767 | + } | |
| 768 | + } | |
| 769 | + } | |
| 770 | + | |
| 418 | 771 | if ($product->is_type('variable')) { |
| 419 | - // Raw min/max prices, consistent with get_price() used for simple products. | |
| 420 | - $data['price_min'] = $product->get_variation_price('min', false); | |
| 421 | - $data['price_max'] = $product->get_variation_price('max', false); | |
| 772 | + // Display min/max prices, consistent with the display price used for | |
| 773 | + // simple products. | |
| 774 | + $data['price_min'] = $product->get_variation_price('min', true); | |
| 775 | + $data['price_max'] = $product->get_variation_price('max', true); | |
| 422 | 776 | } else { |
| 423 | 777 | $regular_price = $product->get_regular_price(); |
| 424 | 778 | if ($regular_price !== '') { |
| 425 | - $data['regular_price'] = $regular_price; | |
| 779 | + $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price)); | |
| 426 | 780 | } |
| 427 | 781 | // Only advertise a sale price while the sale is actually active. |
| 428 | - if ($product->is_on_sale()) { | |
| 429 | - $data['sale_price'] = $product->get_sale_price(); | |
| 782 | + if ($product->is_on_sale() && $product->get_sale_price() !== '') { | |
| 783 | + $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price())); | |
| 430 | 784 | } |
| 431 | 785 | } |
| 432 | 786 | |
| 433 | 787 | // Stock availability |
| @@ -463,10 +817,79 @@ | ||
| 463 | 817 | $data['rating'] = $rating; |
| 464 | 818 | $data['review_count'] = (int) $product->get_review_count(); |
| 465 | 819 | } |
| 466 | 820 | |
| 821 | + // Product thumbnail (3.10.0+): shown as a small product card under the chatbot's reply | |
| 822 | + // when it recommends this product. Always sent, '' when the product has no usable | |
| 823 | + // image: the server then clears the thumbnail it stored for an earlier sync. | |
| 824 | + $data['image'] = $this->get_product_image_url($product); | |
| 825 | + | |
| 467 | 826 | return $data; |
| 468 | 827 | } |
| 828 | + | |
| 829 | + /** | |
| 830 | + * Thumbnail URL for the product card the chat widget shows under a chatbot reply, or '' | |
| 831 | + * when the product has no usable image. | |
| 832 | + * | |
| 833 | + * - The WooCommerce catalogue thumbnail size (300px by default), so the widget never | |
| 834 | + * loads the full-size photo. WordPress falls back to the original file when that | |
| 835 | + * size was never generated. | |
| 836 | + * - Uploaded file names keep non-Latin letters (a Greek "κούπα.jpg" stays Greek in the | |
| 837 | + * URL) and WordPress returns them unencoded, so every byte outside printable ASCII | |
| 838 | + * is percent-encoded here: the widget, the dashboard and the server then all handle | |
| 839 | + * one plain ASCII URL. Already encoded parts (%CE%BA...) are left as they are. | |
| 840 | + * - Shops served over HTTPS get an HTTPS image link, otherwise the browser would block | |
| 841 | + * the picture on the shop page as mixed content. | |
| 842 | + * - Anything that is not an absolute http(s) URL, or is longer than the 1000 characters | |
| 843 | + * the server stores, is dropped (the product then syncs without a picture). | |
| 844 | + */ | |
| 845 | + private function get_product_image_url($product) { | |
| 846 | + $image_id = (int) $product->get_image_id(); | |
| 847 | + if ($image_id <= 0) { | |
| 848 | + return ''; | |
| 849 | + } | |
| 850 | + | |
| 851 | + $image_url = wp_get_attachment_image_url($image_id, 'woocommerce_thumbnail'); | |
| 852 | + if (!$image_url) { | |
| 853 | + $image_url = wp_get_attachment_image_url($image_id, 'thumbnail'); | |
| 854 | + } | |
| 855 | + if (!is_string($image_url)) { | |
| 856 | + return ''; | |
| 857 | + } | |
| 858 | + | |
| 859 | + $image_url = trim($image_url); | |
| 860 | + if ($image_url === '') { | |
| 861 | + return ''; | |
| 862 | + } | |
| 863 | + | |
| 864 | + $site_is_https = is_ssl() || (stripos(home_url('/'), 'https://') === 0); | |
| 865 | + | |
| 866 | + // Protocol-relative URL (some CDN plugins return "//cdn.example.com/..."). | |
| 867 | + if (substr($image_url, 0, 2) === '//') { | |
| 868 | + $image_url = ($site_is_https ? 'https:' : 'http:') . $image_url; | |
| 869 | + } | |
| 870 | + | |
| 871 | + if ($site_is_https && stripos($image_url, 'http://') === 0) { | |
| 872 | + $image_url = set_url_scheme($image_url, 'https'); | |
| 873 | + } | |
| 874 | + | |
| 875 | + // Percent-encode every byte outside printable ASCII (multibyte letters, spaces, | |
| 876 | + // control characters). No /u flag on purpose: each byte of a UTF-8 sequence is | |
| 877 | + // encoded separately, which is exactly the encoding a browser would apply. | |
| 878 | + $image_url = preg_replace_callback('/[^\x21-\x7E]/', function ($m) { | |
| 879 | + return rawurlencode($m[0]); | |
| 880 | + }, $image_url); | |
| 881 | + | |
| 882 | + if (!is_string($image_url) || !preg_match('#^https?://[^\s<>"\'\\\\]+$#i', $image_url)) { | |
| 883 | + return ''; | |
| 884 | + } | |
| 885 | + | |
| 886 | + if (strlen($image_url) > 1000) { | |
| 887 | + return ''; | |
| 888 | + } | |
| 889 | + | |
| 890 | + return $image_url; | |
| 891 | + } | |
| 469 | 892 | |
| 470 | 893 | /** |
| 471 | 894 | * Get product category names |
| 472 | 895 | */ |
| @@ -476,9 +899,9 @@ | ||
| 476 | 899 | |
| 477 | 900 | foreach ($category_ids as $cat_id) { |
| 478 | 901 | $term = get_term($cat_id, 'product_cat'); |
| 479 | 902 | if ($term && !is_wp_error($term)) { |
| 480 | - $categories[] = $term->name; | |
| 903 | + $categories[] = $this->decode_entities($term->name); | |
| 481 | 904 | } |
| 482 | 905 | } |
| 483 | 906 | |
| 484 | 907 | return $categories; |
| @@ -497,9 +920,9 @@ | ||
| 497 | 920 | } |
| 498 | 921 | |
| 499 | 922 | $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); |
| 500 | 923 | if (!is_wp_error($terms) && !empty($terms)) { |
| 501 | - return $terms[0]; | |
| 924 | + return $this->decode_entities($terms[0]); | |
| 502 | 925 | } |
| 503 | 926 | } |
| 504 | 927 | |
| 505 | 928 | return ''; |
| @@ -547,16 +970,21 @@ | ||
| 547 | 970 | if (is_wp_error($tags) || empty($tags)) { |
| 548 | 971 | return array(); |
| 549 | 972 | } |
| 550 | 973 | |
| 551 | - return $tags; | |
| 974 | + return array_map(array($this, 'decode_entities'), $tags); | |
| 552 | 975 | } |
| 553 | 976 | |
| 554 | 977 | /** |
| 555 | 978 | * Send batch of products to API (optimized) |
| 556 | - * @param array $products - Array of product data | |
| 979 | + * @param array $products - Array of product data | |
| 980 | + * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run) | |
| 981 | + * @param int $sync_done - Products pushed so far in the run, including this batch | |
| 982 | + * | |
| 983 | + * When $sync_total is > 0 the batch is tagged with the run total/progress so | |
| 984 | + * the server can relay a live progress bar to open dashboards. | |
| 557 | 985 | */ |
| 558 | - private function send_product_batch($products) { | |
| 986 | + private function send_product_batch($products, $sync_total = 0, $sync_done = 0) { | |
| 559 | 987 | $chatId = get_option('onwebchat_plugin_option'); |
| 560 | 988 | $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; |
| 561 | 989 | |
| 562 | 990 | if (empty($chatId)) { |
| @@ -583,8 +1011,16 @@ | ||
| 583 | 1011 | 'site_id' => $chatIdKey, |
| 584 | 1012 | 'site_url' => get_site_url(), |
| 585 | 1013 | 'products' => $products |
| 586 | 1014 | ); |
| 1015 | + | |
| 1016 | + // Tag bulk-run batches with the run total + progress so the server can | |
| 1017 | + // relay a live progress bar to open dashboards. Omitted for incremental | |
| 1018 | + // single-product syncs (which pass no total). | |
| 1019 | + if ((int) $sync_total > 0) { | |
| 1020 | + $payload['sync_total'] = (int) $sync_total; | |
| 1021 | + $payload['sync_done'] = min((int) $sync_done, (int) $sync_total); | |
| 1022 | + } | |
| 587 | 1023 | |
| 588 | 1024 | // Generate authentication headers (same as send_authenticated_request) |
| 589 | 1025 | $timestamp = time(); |
| 590 | 1026 | $nonce = base64_encode(random_bytes(16)); |
| @@ -596,9 +1032,12 @@ | ||
| 596 | 1032 | |
| 597 | 1033 | // Send request |
| 598 | 1034 | $request_args = array( |
| 599 | 1035 | 'method' => 'POST', |
| 600 | - 'timeout' => 30, // Longer timeout for batch operations | |
| 1036 | + // A batch whose descriptions are summarized for the first time costs | |
| 1037 | + // the server one model call per oversized product, so it needs far | |
| 1038 | + // more than the 30s that is plenty for every other endpoint. | |
| 1039 | + 'timeout' => 180, | |
| 601 | 1040 | 'headers' => array( |
| 602 | 1041 | 'Content-Type' => 'application/json', |
| 603 | 1042 | 'X-OWC-SiteId' => $chatIdKey, |
| 604 | 1043 | 'X-OWC-Timestamp' => $timestamp, |
| @@ -786,8 +1225,44 @@ | ||
| 786 | 1225 | $this->send_authenticated_request($endpoint, $payload, $product_id); |
| 787 | 1226 | } |
| 788 | 1227 | |
| 789 | 1228 | /** |
| 1229 | + * Remove many products from the AI training data in one call. Used by the | |
| 1230 | + * scope-removal flow: one request per product would hit onWebChat's | |
| 1231 | + * product-sync rate limit on any real catalogue. | |
| 1232 | + * | |
| 1233 | + * @param array $product_ids | |
| 1234 | + * @return array {success, deleted, errors} | |
| 1235 | + */ | |
| 1236 | + private function send_products_delete_batch($product_ids) { | |
| 1237 | + $product_ids = array_values(array_unique(array_map('intval', (array) $product_ids))); | |
| 1238 | + if (empty($product_ids)) { | |
| 1239 | + return array('success' => true, 'deleted' => 0, 'errors' => 0); | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + $chatId = get_option('onwebchat_plugin_option'); | |
| 1243 | + $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; | |
| 1244 | + | |
| 1245 | + if (empty($chatId)) { | |
| 1246 | + return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); | |
| 1247 | + } | |
| 1248 | + | |
| 1249 | + $chatIdKey = explode('/', $chatId)[0]; | |
| 1250 | + | |
| 1251 | + $result = $this->send_authenticated_request($this->get_api_endpoint() . '/product/delete', array( | |
| 1252 | + 'site_id' => $chatIdKey, | |
| 1253 | + 'site_url' => get_site_url(), | |
| 1254 | + 'product_ids' => $product_ids, | |
| 1255 | + )); | |
| 1256 | + | |
| 1257 | + if (empty($result['success'])) { | |
| 1258 | + return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); | |
| 1259 | + } | |
| 1260 | + | |
| 1261 | + return array('success' => true, 'deleted' => count($product_ids), 'errors' => 0); | |
| 1262 | + } | |
| 1263 | + | |
| 1264 | + /** | |
| 790 | 1265 | * Send a lightweight availability update to onWebChat (no re-embed on the server). |
| 791 | 1266 | */ |
| 792 | 1267 | private function send_product_stock($product_id, $in_stock) { |
| 793 | 1268 | $chatId = get_option('onwebchat_plugin_option'); |
| @@ -1040,9 +1515,13 @@ | ||
| 1040 | 1515 | * AJAX: Start bulk sync |
| 1041 | 1516 | */ |
| 1042 | 1517 | public function ajax_sync_existing_products() { |
| 1043 | 1518 | check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); |
| 1044 | - | |
| 1519 | + | |
| 1520 | + // This path can walk the whole catalogue in one request, and each batch | |
| 1521 | + // waits for the server (which may summarize descriptions with a model). | |
| 1522 | + @set_time_limit(0); | |
| 1523 | + | |
| 1045 | 1524 | if (!current_user_can('manage_options')) { |
| 1046 | 1525 | wp_send_json_error('Insufficient permissions'); |
| 1047 | 1526 | } |
| 1048 | 1527 | |
| @@ -1052,15 +1531,14 @@ | ||
| 1052 | 1531 | } |
| 1053 | 1532 | |
| 1054 | 1533 | // Rate limiting: prevent syncing more than once every 5 minutes |
| 1055 | 1534 | $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0); |
| 1056 | - $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60 | |
| 1535 | + $cooldown_period = 30; // seconds; keep in step with admin/tabs/woocommerce.php | |
| 1057 | 1536 | $time_since_last_sync = time() - $last_sync_time; |
| 1058 | 1537 | |
| 1059 | 1538 | if ($time_since_last_sync < $cooldown_period) { |
| 1060 | - $wait_time = $cooldown_period - $time_since_last_sync; | |
| 1061 | - $minutes = ceil($wait_time / 60); | |
| 1062 | - wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.'); | |
| 1539 | + $wait_time = max(1, $cooldown_period - $time_since_last_sync); | |
| 1540 | + wp_send_json_error('Please wait ' . $wait_time . ' second(s) before syncing again.'); | |
| 1063 | 1541 | } |
| 1064 | 1542 | |
| 1065 | 1543 | // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. |
| 1066 | 1544 | $category_ids = array(); |
| @@ -1083,13 +1561,55 @@ | ||
| 1083 | 1561 | number_format_i18n(self::MAX_SYNC_PRODUCTS) |
| 1084 | 1562 | )); |
| 1085 | 1563 | } |
| 1086 | 1564 | |
| 1087 | - // Remember the merchant's choice so ongoing auto-sync stays within it: | |
| 1088 | - // selected categories become the sync scope; an unrestricted "sync all" | |
| 1089 | - // clears the scope (the whole catalogue is in scope again). | |
| 1090 | - $this->save_sync_scope($category_ids); | |
| 1565 | + // The saved scope only ever GROWS on a sync. Ticking more categories adds | |
| 1566 | + // them to what the bot knows; unticking never silently drops products, | |
| 1567 | + // removal is its own explicit, confirmed action (ajax_scope_remove_*). | |
| 1568 | + // An empty selection means the whole catalogue, which covers everything, | |
| 1569 | + // so it clears the scope. | |
| 1570 | + // | |
| 1571 | + // $run_terms / $run_exclude are what THIS run pushes, which is not the | |
| 1572 | + // same as the scope: when categories are added to an existing scope only | |
| 1573 | + // the added ones are pushed, so adding one subcategory to a 10,000 | |
| 1574 | + // product scope no longer re-sends all 10,000. | |
| 1575 | + $saved_scope = $this->get_sync_scope(); | |
| 1091 | 1576 | |
| 1577 | + if (empty($category_ids)) { | |
| 1578 | + // Nothing ticked: the whole catalogue is the scope. | |
| 1579 | + $new_scope = array(); | |
| 1580 | + $new_all = true; | |
| 1581 | + $run_terms = array(); // push everything | |
| 1582 | + $run_exclude = array(); | |
| 1583 | + } elseif (empty($saved_scope)) { | |
| 1584 | + // Nothing picked before (a fresh site, or one whose scope was | |
| 1585 | + // removed, or one that used to sync everything): the ticks become | |
| 1586 | + // the scope, so they are still ticked after a refresh and the | |
| 1587 | + // summary can name them. | |
| 1588 | + $new_scope = $category_ids; | |
| 1589 | + $new_all = false; | |
| 1590 | + $run_terms = $category_ids; | |
| 1591 | + $run_exclude = array(); | |
| 1592 | + } else { | |
| 1593 | + $added = $this->categories_added($category_ids, $saved_scope); | |
| 1594 | + $new_scope = array_values(array_unique(array_merge($saved_scope, $category_ids))); | |
| 1595 | + $new_all = false; | |
| 1596 | + | |
| 1597 | + if (!empty($added)) { | |
| 1598 | + $run_terms = $added; | |
| 1599 | + $run_exclude = $saved_scope; // already synced, skip it | |
| 1600 | + } else { | |
| 1601 | + // Nothing new was ticked, so the click means "refresh what I have". | |
| 1602 | + $run_terms = $new_scope; | |
| 1603 | + $run_exclude = array(); | |
| 1604 | + } | |
| 1605 | + } | |
| 1606 | + | |
| 1607 | + $this->save_sync_scope($new_scope, $new_all); | |
| 1608 | + $this->enable_auto_sync_for_run(); | |
| 1609 | + update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms))); | |
| 1610 | + update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude))); | |
| 1611 | + | |
| 1092 | 1612 | // Store the current sync start time |
| 1093 | 1613 | update_option('onwebchat_wc_last_sync_start', time()); |
| 1094 | 1614 | |
| 1095 | 1615 | // Reset bulk sync progress |
| @@ -1095,10 +1615,10 @@ | ||
| 1095 | 1615 | // Reset bulk sync progress |
| 1096 | 1616 | update_option('onwebchat_wc_bulk_page', 0); |
| 1097 | 1617 | update_option('onwebchat_wc_bulk_done', 0); |
| 1098 | 1618 | |
| 1099 | - // Count total products within scope, capped at the hard limit. | |
| 1100 | - $total = $this->count_products_in_scope($category_ids); | |
| 1619 | + // Count the products THIS run will push, capped at the hard limit. | |
| 1620 | + $total = $this->count_products_in_scope($run_terms, $run_exclude); | |
| 1101 | 1621 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| 1102 | 1622 | $total = self::MAX_SYNC_PRODUCTS; |
| 1103 | 1623 | } |
| 1104 | 1624 | |
| @@ -1116,8 +1636,232 @@ | ||
| 1116 | 1636 | )); |
| 1117 | 1637 | } |
| 1118 | 1638 | |
| 1119 | 1639 | /** |
| 1640 | + * AJAX: begin a client-driven bulk sync. | |
| 1641 | + * | |
| 1642 | + * Sets up the progress state and returns the total number of products to | |
| 1643 | + * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page | |
| 1644 | + * per request) until the run reports it is complete. Because each request is | |
| 1645 | + * short, the whole sync no longer rides on a single request that outran the | |
| 1646 | + * web server timeout and reported a false failure while products kept | |
| 1647 | + * syncing. | |
| 1648 | + */ | |
| 1649 | + public function ajax_start_bulk_sync() { | |
| 1650 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 1651 | + | |
| 1652 | + if (!current_user_can('manage_options')) { | |
| 1653 | + wp_send_json_error('Insufficient permissions'); | |
| 1654 | + } | |
| 1655 | + | |
| 1656 | + if (get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 1657 | + wp_send_json_error('A sync is already in progress. Please wait for it to complete.'); | |
| 1658 | + } | |
| 1659 | + | |
| 1660 | + // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. | |
| 1661 | + $category_ids = array(); | |
| 1662 | + if (isset($_POST['categories']) && $_POST['categories'] !== '') { | |
| 1663 | + foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) { | |
| 1664 | + $id = (int) trim($id); | |
| 1665 | + if ($id > 0) { | |
| 1666 | + $category_ids[] = $id; | |
| 1667 | + } | |
| 1668 | + } | |
| 1669 | + } | |
| 1670 | + | |
| 1671 | + // Above the hard cap a category selection is required: refuse an | |
| 1672 | + // unrestricted "sync all" when the catalogue is larger than the cap. | |
| 1673 | + $published_total = $this->count_products_in_scope(array()); | |
| 1674 | + if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) { | |
| 1675 | + wp_send_json_error(sprintf( | |
| 1676 | + 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.', | |
| 1677 | + number_format_i18n($published_total), | |
| 1678 | + number_format_i18n(self::MAX_SYNC_PRODUCTS) | |
| 1679 | + )); | |
| 1680 | + } | |
| 1681 | + | |
| 1682 | + // Remember the merchant's choice so ongoing auto-sync stays within it: | |
| 1683 | + // selected categories become the sync scope; an unrestricted "sync all" | |
| 1684 | + // puts the whole catalogue in scope. | |
| 1685 | + $this->save_sync_scope($category_ids, empty($category_ids)); | |
| 1686 | + $this->enable_auto_sync_for_run(); | |
| 1687 | + | |
| 1688 | + // Count total products within scope, capped at the hard limit. | |
| 1689 | + $total = $this->count_products_in_scope($category_ids); | |
| 1690 | + if ($total > self::MAX_SYNC_PRODUCTS) { | |
| 1691 | + $total = self::MAX_SYNC_PRODUCTS; | |
| 1692 | + } | |
| 1693 | + | |
| 1694 | + // Reset progress state for a fresh run. | |
| 1695 | + update_option('onwebchat_wc_last_sync_start', time()); | |
| 1696 | + update_option('onwebchat_wc_bulk_page', 0); | |
| 1697 | + update_option('onwebchat_wc_bulk_done', 0); | |
| 1698 | + update_option('onwebchat_wc_bulk_total', $total); | |
| 1699 | + update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0)); | |
| 1700 | + // Only enter the "in progress" state when there is actually something to | |
| 1701 | + // sync, so a 0-product start (empty scope) can't leave the store stuck at | |
| 1702 | + // "a sync is already in progress". | |
| 1703 | + update_option('onwebchat_wc_bulk_in_progress', $total > 0); | |
| 1704 | + | |
| 1705 | + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); | |
| 1706 | + } | |
| 1707 | + | |
| 1708 | + /** | |
| 1709 | + * AJAX: process the next page of the in-progress bulk sync and report progress. | |
| 1710 | + */ | |
| 1711 | + public function ajax_sync_next_batch() { | |
| 1712 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 1713 | + | |
| 1714 | + // One page of products waits for the server, which may summarize long | |
| 1715 | + // descriptions with a model: that outlives a default max_execution_time. | |
| 1716 | + @set_time_limit(0); | |
| 1717 | + | |
| 1718 | + if (!current_user_can('manage_options')) { | |
| 1719 | + wp_send_json_error('Insufficient permissions'); | |
| 1720 | + } | |
| 1721 | + | |
| 1722 | + wp_send_json_success($this->sync_next_page()); | |
| 1723 | + } | |
| 1724 | + | |
| 1725 | + /** | |
| 1726 | + * Process exactly one page (batch_size products) of the in-progress bulk | |
| 1727 | + * sync, advancing the persisted progress. The browser calls this once per | |
| 1728 | + * request (via ajax_sync_next_batch) until it reports the run is complete, | |
| 1729 | + * so no single request has to stay open for the whole catalogue. | |
| 1730 | + * | |
| 1731 | + * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does | |
| 1732 | + * NOT schedule a follow-up cron event: the browser drives the loop. Stats | |
| 1733 | + * are accumulated in an option across pages so the completion notification | |
| 1734 | + * (which drives the dashboard notice) carries the full run totals. | |
| 1735 | + * | |
| 1736 | + * @return array Progress snapshot: in_progress, complete, done, total, stats. | |
| 1737 | + */ | |
| 1738 | + private function sync_next_page() { | |
| 1739 | + $total = (int) get_option('onwebchat_wc_bulk_total', 0); | |
| 1740 | + | |
| 1741 | + if (!get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 1742 | + return array( | |
| 1743 | + 'in_progress' => false, | |
| 1744 | + 'complete' => true, | |
| 1745 | + 'done' => (int) get_option('onwebchat_wc_bulk_done', 0), | |
| 1746 | + 'total' => $total, | |
| 1747 | + 'stats' => $this->get_bulk_stats(), | |
| 1748 | + ); | |
| 1749 | + } | |
| 1750 | + | |
| 1751 | + $page = (int) get_option('onwebchat_wc_bulk_page', 0); | |
| 1752 | + $done = (int) get_option('onwebchat_wc_bulk_done', 0); | |
| 1753 | + $stats = $this->get_bulk_stats(); | |
| 1754 | + | |
| 1755 | + $args = array( | |
| 1756 | + 'post_type' => 'product', | |
| 1757 | + 'post_status' => 'publish', | |
| 1758 | + 'posts_per_page' => $this->batch_size, | |
| 1759 | + 'paged' => $page + 1, | |
| 1760 | + 'orderby' => 'ID', | |
| 1761 | + 'order' => 'ASC', | |
| 1762 | + ); | |
| 1763 | + | |
| 1764 | + // Restrict to what THIS run pushes (see ajax_start_bulk_sync): the | |
| 1765 | + // categories being added, minus everything already synced. Falls back to | |
| 1766 | + // the saved scope for a run started before these options existed. | |
| 1767 | + $run_terms_raw = get_option('onwebchat_wc_bulk_run_terms', null); | |
| 1768 | + $run_terms = ($run_terms_raw === null) | |
| 1769 | + ? $this->get_sync_scope() | |
| 1770 | + : $this->parse_id_list($run_terms_raw); | |
| 1771 | + $run_exclude = $this->parse_id_list(get_option('onwebchat_wc_bulk_run_exclude', '')); | |
| 1772 | + | |
| 1773 | + $tax_query = $this->build_scope_tax_query($run_terms, $run_exclude); | |
| 1774 | + if ($tax_query !== null) { | |
| 1775 | + $args['tax_query'] = $tax_query; | |
| 1776 | + } | |
| 1777 | + | |
| 1778 | + $query = new WP_Query($args); | |
| 1779 | + $complete = false; | |
| 1780 | + | |
| 1781 | + if ($query->have_posts()) { | |
| 1782 | + $products_batch = array(); | |
| 1783 | + foreach ($query->posts as $post) { | |
| 1784 | + $product = wc_get_product($post->ID); | |
| 1785 | + if ($product && !$this->is_product_excluded($product)) { | |
| 1786 | + $products_batch[] = $this->prepare_product_data($product); | |
| 1787 | + } | |
| 1788 | + } | |
| 1789 | + | |
| 1790 | + $batch_done = 0; | |
| 1791 | + if (!empty($products_batch)) { | |
| 1792 | + // Tag with run total + running progress so the dashboard bar advances. | |
| 1793 | + $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch)); | |
| 1794 | + if ($result && isset($result['stats'])) { | |
| 1795 | + $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped']; | |
| 1796 | + $stats['created'] += (int) $result['stats']['created']; | |
| 1797 | + $stats['updated'] += (int) $result['stats']['updated']; | |
| 1798 | + $stats['skipped'] += (int) $result['stats']['skipped']; | |
| 1799 | + $stats['errors'] += (int) $result['stats']['errors']; | |
| 1800 | + } else { | |
| 1801 | + // Batch failed outright: count the products as errors so the | |
| 1802 | + // summary reflects reality rather than silently skipping them. | |
| 1803 | + $batch_done = count($products_batch); | |
| 1804 | + $stats['errors'] += count($products_batch); | |
| 1805 | + } | |
| 1806 | + } | |
| 1807 | + | |
| 1808 | + $done += $batch_done; | |
| 1809 | + $page += 1; | |
| 1810 | + | |
| 1811 | + update_option('onwebchat_wc_bulk_page', $page); | |
| 1812 | + update_option('onwebchat_wc_bulk_done', $done); | |
| 1813 | + update_option('onwebchat_wc_bulk_stats', $stats); | |
| 1814 | + | |
| 1815 | + // Stop once we have covered the counted total or reached the hard cap. | |
| 1816 | + if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) { | |
| 1817 | + $complete = true; | |
| 1818 | + } | |
| 1819 | + } else { | |
| 1820 | + // No more products in scope. | |
| 1821 | + $complete = true; | |
| 1822 | + } | |
| 1823 | + | |
| 1824 | + wp_reset_postdata(); | |
| 1825 | + | |
| 1826 | + if ($complete) { | |
| 1827 | + $this->send_sync_completion_notification($stats); | |
| 1828 | + update_option('onwebchat_wc_bulk_in_progress', false); | |
| 1829 | + update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); | |
| 1830 | + // Show 100% when the counted total was reached; otherwise leave the | |
| 1831 | + // real processed figure (e.g. an early empty page or the hard cap). | |
| 1832 | + if ($total > 0 && $done >= $total) { | |
| 1833 | + $done = $total; | |
| 1834 | + } | |
| 1835 | + update_option('onwebchat_wc_bulk_done', $done); | |
| 1836 | + } | |
| 1837 | + | |
| 1838 | + return array( | |
| 1839 | + 'in_progress' => !$complete, | |
| 1840 | + 'complete' => $complete, | |
| 1841 | + 'done' => $done, | |
| 1842 | + 'total' => $total, | |
| 1843 | + 'stats' => $stats, | |
| 1844 | + ); | |
| 1845 | + } | |
| 1846 | + | |
| 1847 | + /** | |
| 1848 | + * Read the accumulated bulk-sync stats option, normalised to the four keys. | |
| 1849 | + */ | |
| 1850 | + private function get_bulk_stats() { | |
| 1851 | + $stats = get_option('onwebchat_wc_bulk_stats', array()); | |
| 1852 | + if (!is_array($stats)) { | |
| 1853 | + $stats = array(); | |
| 1854 | + } | |
| 1855 | + return array( | |
| 1856 | + 'created' => isset($stats['created']) ? (int) $stats['created'] : 0, | |
| 1857 | + 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0, | |
| 1858 | + 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0, | |
| 1859 | + 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0, | |
| 1860 | + ); | |
| 1861 | + } | |
| 1862 | + | |
| 1863 | + /** | |
| 1120 | 1864 | * Process all products in bulk sync directly (not via cron). |
| 1121 | 1865 | * |
| 1122 | 1866 | * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue. |
| 1123 | 1867 | */ |
| @@ -1139,10 +1883,10 @@ | ||
| 1139 | 1883 | 'orderby' => 'ID', |
| 1140 | 1884 | 'order' => 'ASC', |
| 1141 | 1885 | ); |
| 1142 | 1886 | |
| 1143 | - // Restrict to the chosen top-level categories and their subtrees, to | |
| 1144 | - // match the per-product scope check and the counts shown in the picker. | |
| 1887 | + // Restrict to the chosen categories and their subtrees, to match the | |
| 1888 | + // per-product scope check and the counts shown in the picker. | |
| 1145 | 1889 | if (!empty($category_ids)) { |
| 1146 | 1890 | $args['tax_query'] = array(array( |
| 1147 | 1891 | 'taxonomy' => 'product_cat', |
| 1148 | 1892 | 'field' => 'term_id', |
| @@ -1188,10 +1932,14 @@ | ||
| 1188 | 1932 | |
| 1189 | 1933 | // Update progress after each batch so AJAX polling can see it |
| 1190 | 1934 | update_option('onwebchat_wc_bulk_done', $total_done); |
| 1191 | 1935 | |
| 1192 | - // Wait 4 seconds before next batch | |
| 1193 | - sleep(4); | |
| 1936 | + // Breathe between batches so a long run cannot walk into the | |
| 1937 | + // server's product-sync rate limit (150 requests per 5 minutes | |
| 1938 | + // per IP). One second is plenty: each batch already costs a | |
| 1939 | + // synchronous HTTP call of its own, so the real cycle time is | |
| 1940 | + // seconds even when nothing needs summarizing. | |
| 1941 | + sleep(1); | |
| 1194 | 1942 | } |
| 1195 | 1943 | |
| 1196 | 1944 | wp_reset_postdata(); |
| 1197 | 1945 | $page++; |
| @@ -1420,8 +2168,199 @@ | ||
| 1420 | 2168 | 'total' => $total, |
| 1421 | 2169 | ); |
| 1422 | 2170 | } |
| 1423 | 2171 | |
| 2172 | + /** | |
| 2173 | + * AJAX: start removing categories from the AI training data. | |
| 2174 | + * | |
| 2175 | + * The counterpart of the additive sync scope: unticking a category never | |
| 2176 | + * removes anything by itself, the merchant has to ask for it here. Posts the | |
| 2177 | + * categories that should REMAIN ticked; whatever the saved scope holds on top | |
| 2178 | + * of that is what gets removed, together with its products, unless those | |
| 2179 | + * products also sit in a category that stays. | |
| 2180 | + */ | |
| 2181 | + public function ajax_scope_remove_start() { | |
| 2182 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 2183 | + | |
| 2184 | + if (!current_user_can('manage_options')) { | |
| 2185 | + wp_send_json_error('Insufficient permissions'); | |
| 2186 | + } | |
| 2187 | + | |
| 2188 | + if (get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 2189 | + wp_send_json_error('A sync is in progress. Please wait for it to finish.'); | |
| 2190 | + } | |
| 2191 | + | |
| 2192 | + @set_time_limit(0); | |
| 2193 | + | |
| 2194 | + $keep = array(); | |
| 2195 | + if (isset($_POST['categories']) && $_POST['categories'] !== '') { | |
| 2196 | + $keep = $this->parse_id_list(sanitize_text_field(wp_unslash($_POST['categories']))); | |
| 2197 | + } | |
| 2198 | + | |
| 2199 | + $saved_scope = $this->get_sync_scope(); | |
| 2200 | + if (empty($saved_scope)) { | |
| 2201 | + wp_send_json_error('Your whole catalogue is synced, so there are no categories to remove. Select the categories you want to keep and sync again first.'); | |
| 2202 | + } | |
| 2203 | + | |
| 2204 | + $removed = $this->categories_removed($keep, $saved_scope); | |
| 2205 | + if (empty($removed)) { | |
| 2206 | + wp_send_json_error('No synced categories were unticked, so there is nothing to remove.'); | |
| 2207 | + } | |
| 2208 | + | |
| 2209 | + // Products of the dropped categories that are not also in a category the | |
| 2210 | + // merchant keeps: a product in both stays in the training data. | |
| 2211 | + $total = $this->count_products_in_scope($removed, $keep); | |
| 2212 | + | |
| 2213 | + update_option('onwebchat_wc_remove_terms', implode(',', $removed)); | |
| 2214 | + update_option('onwebchat_wc_remove_keep', implode(',', $keep)); | |
| 2215 | + update_option('onwebchat_wc_remove_total', $total); | |
| 2216 | + update_option('onwebchat_wc_remove_done', 0); | |
| 2217 | + update_option('onwebchat_wc_remove_in_progress', true); | |
| 2218 | + | |
| 2219 | + $complete = ($total === 0); | |
| 2220 | + if ($complete) { | |
| 2221 | + $this->finish_scope_removal(); | |
| 2222 | + } | |
| 2223 | + | |
| 2224 | + wp_send_json_success(array( | |
| 2225 | + 'total' => $total, | |
| 2226 | + 'categories' => count($removed), | |
| 2227 | + 'done' => 0, | |
| 2228 | + 'complete' => $complete, | |
| 2229 | + // Removing everything also switches automatic product sync off, see | |
| 2230 | + // finish_scope_removal(); the UI says so before the merchant confirms. | |
| 2231 | + 'disables_sync' => empty($keep), | |
| 2232 | + )); | |
| 2233 | + } | |
| 2234 | + | |
| 2235 | + /** | |
| 2236 | + * AJAX: delete one page of products of the categories being removed. | |
| 2237 | + * The browser calls this until it reports complete, exactly like the sync. | |
| 2238 | + */ | |
| 2239 | + public function ajax_scope_remove_batch() { | |
| 2240 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 2241 | + | |
| 2242 | + if (!current_user_can('manage_options')) { | |
| 2243 | + wp_send_json_error('Insufficient permissions'); | |
| 2244 | + } | |
| 2245 | + | |
| 2246 | + @set_time_limit(0); | |
| 2247 | + | |
| 2248 | + if (!get_option('onwebchat_wc_remove_in_progress', false)) { | |
| 2249 | + wp_send_json_success(array( | |
| 2250 | + 'complete' => true, | |
| 2251 | + 'done' => (int) get_option('onwebchat_wc_remove_done', 0), | |
| 2252 | + 'total' => (int) get_option('onwebchat_wc_remove_total', 0), | |
| 2253 | + )); | |
| 2254 | + } | |
| 2255 | + | |
| 2256 | + $removed = $this->parse_id_list(get_option('onwebchat_wc_remove_terms', '')); | |
| 2257 | + $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); | |
| 2258 | + $total = (int) get_option('onwebchat_wc_remove_total', 0); | |
| 2259 | + $done = (int) get_option('onwebchat_wc_remove_done', 0); | |
| 2260 | + | |
| 2261 | + // Never query without a category restriction. An empty $removed would make | |
| 2262 | + // build_scope_tax_query() return no clause at all, and this page would then | |
| 2263 | + // delete the first 200 products of the WHOLE catalogue from the training | |
| 2264 | + // data. That can only happen if the run state was lost half way (option | |
| 2265 | + // cleared, in-progress flag left behind), so treat it as "nothing to do". | |
| 2266 | + if (empty($removed)) { | |
| 2267 | + update_option('onwebchat_wc_remove_in_progress', false); | |
| 2268 | + delete_option('onwebchat_wc_remove_terms'); | |
| 2269 | + delete_option('onwebchat_wc_remove_keep'); | |
| 2270 | + | |
| 2271 | + wp_send_json_success(array( | |
| 2272 | + 'complete' => true, | |
| 2273 | + 'done' => $done, | |
| 2274 | + 'total' => $total, | |
| 2275 | + )); | |
| 2276 | + } | |
| 2277 | + | |
| 2278 | + $args = array( | |
| 2279 | + 'post_type' => 'product', | |
| 2280 | + 'post_status' => 'publish', | |
| 2281 | + 'posts_per_page' => self::REMOVE_PAGE_SIZE, | |
| 2282 | + 'orderby' => 'ID', | |
| 2283 | + 'order' => 'ASC', | |
| 2284 | + 'fields' => 'ids', | |
| 2285 | + // Deleting on the onWebChat side never changes this query, but the | |
| 2286 | + // rows already handled must be skipped, hence the offset. | |
| 2287 | + 'offset' => $done, | |
| 2288 | + ); | |
| 2289 | + | |
| 2290 | + $tax_query = $this->build_scope_tax_query($removed, $keep); | |
| 2291 | + if ($tax_query !== null) { | |
| 2292 | + $args['tax_query'] = $tax_query; | |
| 2293 | + } | |
| 2294 | + | |
| 2295 | + $query = new WP_Query($args); | |
| 2296 | + $ids = $query->posts; | |
| 2297 | + | |
| 2298 | + if (empty($ids)) { | |
| 2299 | + $this->finish_scope_removal(); | |
| 2300 | + return wp_send_json_success(array( | |
| 2301 | + 'complete' => true, | |
| 2302 | + 'done' => $done, | |
| 2303 | + 'total' => $total, | |
| 2304 | + )); | |
| 2305 | + } | |
| 2306 | + | |
| 2307 | + $result = $this->send_products_delete_batch($ids); | |
| 2308 | + if (empty($result['success'])) { | |
| 2309 | + wp_send_json_error('Could not remove the products from onWebChat. Please try again.'); | |
| 2310 | + } | |
| 2311 | + | |
| 2312 | + $done += count($ids); | |
| 2313 | + update_option('onwebchat_wc_remove_done', $done); | |
| 2314 | + | |
| 2315 | + $complete = ($done >= $total) || (count($ids) < self::REMOVE_PAGE_SIZE); | |
| 2316 | + if ($complete) { | |
| 2317 | + $this->finish_scope_removal(); | |
| 2318 | + } | |
| 2319 | + | |
| 2320 | + wp_send_json_success(array( | |
| 2321 | + 'complete' => $complete, | |
| 2322 | + 'done' => min($done, max($total, $done)), | |
| 2323 | + 'total' => max($total, $done), | |
| 2324 | + )); | |
| 2325 | + } | |
| 2326 | + | |
| 2327 | + /** | |
| 2328 | + * Close a removal run: the kept categories become the new sync scope, so | |
| 2329 | + * ongoing auto-sync stops covering what was just removed. | |
| 2330 | + * | |
| 2331 | + * @return bool | |
| 2332 | + */ | |
| 2333 | + private function finish_scope_removal() { | |
| 2334 | + $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); | |
| 2335 | + $had_scope = (bool) $this->get_sync_scope(); | |
| 2336 | + | |
| 2337 | + // After a removal the scope is exactly what is kept, nothing implied: an | |
| 2338 | + // empty list here means the AI training data holds no products, not the | |
| 2339 | + // whole catalogue. Only when something was really removed, so a no-op | |
| 2340 | + // call on a site that syncs everything leaves its scope alone. | |
| 2341 | + if ($had_scope || !empty($keep)) { | |
| 2342 | + $this->save_sync_scope($keep, false); | |
| 2343 | + } | |
| 2344 | + | |
| 2345 | + // Nothing left ticked means the bot should hold no products at all. An | |
| 2346 | + // empty scope means "the whole catalogue", so leaving automatic sync on | |
| 2347 | + // would push every product straight back in on its next edit. | |
| 2348 | + // Only when a scope was actually being removed: a no-op call on a store | |
| 2349 | + // that already syncs its whole catalogue must never touch the toggle. | |
| 2350 | + if (empty($keep) && $had_scope) { | |
| 2351 | + update_option('onwebchat_wc_sync_enabled', false); | |
| 2352 | + // Note who turned it off, so the next bulk sync can turn it back on. | |
| 2353 | + update_option('onwebchat_wc_sync_off_by_removal', true); | |
| 2354 | + } | |
| 2355 | + | |
| 2356 | + update_option('onwebchat_wc_remove_in_progress', false); | |
| 2357 | + delete_option('onwebchat_wc_remove_terms'); | |
| 2358 | + delete_option('onwebchat_wc_remove_keep'); | |
| 2359 | + | |
| 2360 | + return true; | |
| 2361 | + } | |
| 2362 | + | |
| 1424 | 2363 | /** |
| 1425 | 2364 | * AJAX: Manually process batch (for debugging) |
| 1426 | 2365 | */ |
| 1427 | 2366 | public function ajax_manual_process_batch() { |