| @@ -29,8 +29,12 @@ | ||
| 29 | 29 | // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded |
| 30 | 30 | // catalogue. Above the cap a category selection is required. |
| 31 | 31 | const CATEGORY_SELECT_THRESHOLD = 2000; |
| 32 | 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; | |
| 33 | 37 | |
| 34 | 38 | /** |
| 35 | 39 | * Get the API endpoint based on testing mode |
| 36 | 40 | * @return string |
| @@ -100,8 +104,10 @@ | ||
| 100 | 104 | // catalogues and reported a false "sync failed" while products kept |
| 101 | 105 | // syncing. |
| 102 | 106 | add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync')); |
| 103 | 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')); | |
| 104 | 110 | add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret')); |
| 105 | 111 | add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status')); |
| 106 | 112 | add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce')); |
| 107 | 113 | add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch')); |
| @@ -118,10 +124,15 @@ | ||
| 118 | 124 | if (!current_user_can('manage_options')) { |
| 119 | 125 | wp_send_json_error('Insufficient permissions'); |
| 120 | 126 | } |
| 121 | 127 | |
| 122 | - $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; | |
| 123 | - $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']) : ''; | |
| 124 | 135 | |
| 125 | 136 | if (empty($email) || empty($password)) { |
| 126 | 137 | wp_send_json_error('Email and password are required'); |
| 127 | 138 | } |
| @@ -385,12 +396,22 @@ | ||
| 385 | 396 | } |
| 386 | 397 | |
| 387 | 398 | /** |
| 388 | 399 | * Get the saved sync scope as an array of product_cat term IDs. |
| 389 | - * 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. | |
| 390 | 403 | */ |
| 391 | 404 | private function get_sync_scope() { |
| 392 | - $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; | |
| 393 | 414 | if ($raw === '') { |
| 394 | 415 | return array(); |
| 395 | 416 | } |
| 396 | 417 | |
| @@ -405,11 +426,57 @@ | ||
| 405 | 426 | return array_values($ids); |
| 406 | 427 | } |
| 407 | 428 | |
| 408 | 429 | /** |
| 409 | - * 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. | |
| 410 | 436 | */ |
| 411 | - 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) { | |
| 412 | 479 | $clean = array(); |
| 413 | 480 | foreach ((array) $category_ids as $id) { |
| 414 | 481 | $id = (int) $id; |
| 415 | 482 | if ($id > 0) { |
| @@ -417,14 +484,16 @@ | ||
| 417 | 484 | } |
| 418 | 485 | } |
| 419 | 486 | |
| 420 | 487 | update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean))); |
| 488 | + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0'); | |
| 421 | 489 | } |
| 422 | 490 | |
| 423 | 491 | /** |
| 424 | 492 | * Is the product within the current sync scope? |
| 425 | - * No scope set means everything is in scope. The picker only offers | |
| 426 | - * 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 | |
| 427 | 496 | * product is in scope when any of its categories is a scoped category OR a |
| 428 | 497 | * descendant of one. This mirrors the bulk sync tax query |
| 429 | 498 | * (include_children = true). |
| 430 | 499 | */ |
| @@ -430,9 +499,9 @@ | ||
| 430 | 499 | */ |
| 431 | 500 | private function product_in_scope($product) { |
| 432 | 501 | $scope = $this->get_sync_scope(); |
| 433 | 502 | if (empty($scope)) { |
| 434 | - return true; | |
| 503 | + return $this->is_scope_all(); | |
| 435 | 504 | } |
| 436 | 505 | |
| 437 | 506 | foreach ($product->get_category_ids() as $cat_id) { |
| 438 | 507 | $cat_id = (int) $cat_id; |
| @@ -450,12 +519,113 @@ | ||
| 450 | 519 | return false; |
| 451 | 520 | } |
| 452 | 521 | |
| 453 | 522 | /** |
| 454 | - * 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. | |
| 455 | 625 | * Uses found_posts so we do not load every ID into memory. |
| 456 | 626 | */ |
| 457 | - private function count_products_in_scope($category_ids) { | |
| 627 | + private function count_products_in_scope($category_ids, $exclude = array()) { | |
| 458 | 628 | $args = array( |
| 459 | 629 | 'post_type' => 'product', |
| 460 | 630 | 'post_status' => 'publish', |
| 461 | 631 | 'posts_per_page' => 1, |
| @@ -462,15 +632,11 @@ | ||
| 462 | 632 | 'fields' => 'ids', |
| 463 | 633 | 'no_found_rows' => false, |
| 464 | 634 | ); |
| 465 | 635 | |
| 466 | - if (!empty($category_ids)) { | |
| 467 | - $args['tax_query'] = array(array( | |
| 468 | - 'taxonomy' => 'product_cat', | |
| 469 | - 'field' => 'term_id', | |
| 470 | - 'terms' => array_map('intval', $category_ids), | |
| 471 | - 'include_children' => true, | |
| 472 | - )); | |
| 636 | + $tax_query = $this->build_scope_tax_query($category_ids, $exclude); | |
| 637 | + if ($tax_query !== null) { | |
| 638 | + $args['tax_query'] = $tax_query; | |
| 473 | 639 | } |
| 474 | 640 | |
| 475 | 641 | $query = new WP_Query($args); |
| 476 | 642 | return (int) $query->found_posts; |
| @@ -476,8 +642,29 @@ | ||
| 476 | 642 | return (int) $query->found_posts; |
| 477 | 643 | } |
| 478 | 644 | |
| 479 | 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 | + /** | |
| 480 | 667 | * Turn HTML entities into real characters. Product text is often stored |
| 481 | 668 | * double-encoded ("&quot;" for a quote), where a single pass still |
| 482 | 669 | * leaves """ in the text the bot is trained on, so decode until the |
| 483 | 670 | * string stops changing (3 passes is far more than any real content needs). |
| @@ -552,9 +739,11 @@ | ||
| 552 | 739 | // Structured fields. The server rebuilds the embedding text from these, |
| 553 | 740 | // so there is no need to send a pre-formatted "text" blob. |
| 554 | 741 | $data = array( |
| 555 | 742 | 'product_id' => $product->get_id(), |
| 556 | - '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()), | |
| 557 | 746 | 'short_description' => trim($description), |
| 558 | 747 | 'url' => $url, |
| 559 | 748 | 'sku' => $sku, |
| 560 | 749 | 'categories' => $categories, |
| @@ -628,10 +817,79 @@ | ||
| 628 | 817 | $data['rating'] = $rating; |
| 629 | 818 | $data['review_count'] = (int) $product->get_review_count(); |
| 630 | 819 | } |
| 631 | 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 | + | |
| 632 | 826 | return $data; |
| 633 | 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 | + } | |
| 634 | 892 | |
| 635 | 893 | /** |
| 636 | 894 | * Get product category names |
| 637 | 895 | */ |
| @@ -641,9 +899,9 @@ | ||
| 641 | 899 | |
| 642 | 900 | foreach ($category_ids as $cat_id) { |
| 643 | 901 | $term = get_term($cat_id, 'product_cat'); |
| 644 | 902 | if ($term && !is_wp_error($term)) { |
| 645 | - $categories[] = $term->name; | |
| 903 | + $categories[] = $this->decode_entities($term->name); | |
| 646 | 904 | } |
| 647 | 905 | } |
| 648 | 906 | |
| 649 | 907 | return $categories; |
| @@ -662,9 +920,9 @@ | ||
| 662 | 920 | } |
| 663 | 921 | |
| 664 | 922 | $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); |
| 665 | 923 | if (!is_wp_error($terms) && !empty($terms)) { |
| 666 | - return $terms[0]; | |
| 924 | + return $this->decode_entities($terms[0]); | |
| 667 | 925 | } |
| 668 | 926 | } |
| 669 | 927 | |
| 670 | 928 | return ''; |
| @@ -712,9 +970,9 @@ | ||
| 712 | 970 | if (is_wp_error($tags) || empty($tags)) { |
| 713 | 971 | return array(); |
| 714 | 972 | } |
| 715 | 973 | |
| 716 | - return $tags; | |
| 974 | + return array_map(array($this, 'decode_entities'), $tags); | |
| 717 | 975 | } |
| 718 | 976 | |
| 719 | 977 | /** |
| 720 | 978 | * Send batch of products to API (optimized) |
| @@ -967,8 +1225,44 @@ | ||
| 967 | 1225 | $this->send_authenticated_request($endpoint, $payload, $product_id); |
| 968 | 1226 | } |
| 969 | 1227 | |
| 970 | 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 | + /** | |
| 971 | 1265 | * Send a lightweight availability update to onWebChat (no re-embed on the server). |
| 972 | 1266 | */ |
| 973 | 1267 | private function send_product_stock($product_id, $in_stock) { |
| 974 | 1268 | $chatId = get_option('onwebchat_plugin_option'); |
| @@ -1237,15 +1531,14 @@ | ||
| 1237 | 1531 | } |
| 1238 | 1532 | |
| 1239 | 1533 | // Rate limiting: prevent syncing more than once every 5 minutes |
| 1240 | 1534 | $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0); |
| 1241 | - $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 | |
| 1242 | 1536 | $time_since_last_sync = time() - $last_sync_time; |
| 1243 | 1537 | |
| 1244 | 1538 | if ($time_since_last_sync < $cooldown_period) { |
| 1245 | - $wait_time = $cooldown_period - $time_since_last_sync; | |
| 1246 | - $minutes = ceil($wait_time / 60); | |
| 1247 | - 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.'); | |
| 1248 | 1541 | } |
| 1249 | 1542 | |
| 1250 | 1543 | // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. |
| 1251 | 1544 | $category_ids = array(); |
| @@ -1268,13 +1561,55 @@ | ||
| 1268 | 1561 | number_format_i18n(self::MAX_SYNC_PRODUCTS) |
| 1269 | 1562 | )); |
| 1270 | 1563 | } |
| 1271 | 1564 | |
| 1272 | - // Remember the merchant's choice so ongoing auto-sync stays within it: | |
| 1273 | - // selected categories become the sync scope; an unrestricted "sync all" | |
| 1274 | - // clears the scope (the whole catalogue is in scope again). | |
| 1275 | - $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(); | |
| 1276 | 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 | + | |
| 1277 | 1612 | // Store the current sync start time |
| 1278 | 1613 | update_option('onwebchat_wc_last_sync_start', time()); |
| 1279 | 1614 | |
| 1280 | 1615 | // Reset bulk sync progress |
| @@ -1280,10 +1615,10 @@ | ||
| 1280 | 1615 | // Reset bulk sync progress |
| 1281 | 1616 | update_option('onwebchat_wc_bulk_page', 0); |
| 1282 | 1617 | update_option('onwebchat_wc_bulk_done', 0); |
| 1283 | 1618 | |
| 1284 | - // Count total products within scope, capped at the hard limit. | |
| 1285 | - $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); | |
| 1286 | 1621 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| 1287 | 1622 | $total = self::MAX_SYNC_PRODUCTS; |
| 1288 | 1623 | } |
| 1289 | 1624 | |
| @@ -1345,10 +1680,11 @@ | ||
| 1345 | 1680 | } |
| 1346 | 1681 | |
| 1347 | 1682 | // Remember the merchant's choice so ongoing auto-sync stays within it: |
| 1348 | 1683 | // selected categories become the sync scope; an unrestricted "sync all" |
| 1349 | - // clears the scope (the whole catalogue is in scope again). | |
| 1350 | - $this->save_sync_scope($category_ids); | |
| 1684 | + // puts the whole catalogue in scope. | |
| 1685 | + $this->save_sync_scope($category_ids, empty($category_ids)); | |
| 1686 | + $this->enable_auto_sync_for_run(); | |
| 1351 | 1687 | |
| 1352 | 1688 | // Count total products within scope, capped at the hard limit. |
| 1353 | 1689 | $total = $this->count_products_in_scope($category_ids); |
| 1354 | 1690 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| @@ -1365,9 +1701,9 @@ | ||
| 1365 | 1701 | // sync, so a 0-product start (empty scope) can't leave the store stuck at |
| 1366 | 1702 | // "a sync is already in progress". |
| 1367 | 1703 | update_option('onwebchat_wc_bulk_in_progress', $total > 0); |
| 1368 | 1704 | |
| 1369 | - wp_send_json_success(array('total' => $total)); | |
| 1705 | + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); | |
| 1370 | 1706 | } |
| 1371 | 1707 | |
| 1372 | 1708 | /** |
| 1373 | 1709 | * AJAX: process the next page of the in-progress bulk sync and report progress. |
| @@ -1424,18 +1760,20 @@ | ||
| 1424 | 1760 | 'orderby' => 'ID', |
| 1425 | 1761 | 'order' => 'ASC', |
| 1426 | 1762 | ); |
| 1427 | 1763 | |
| 1428 | - // Restrict to the saved sync scope and its subcategories, consistent | |
| 1429 | - // with the per-product scope check and the counts shown in the picker. | |
| 1430 | - $scope = $this->get_sync_scope(); | |
| 1431 | - if (!empty($scope)) { | |
| 1432 | - $args['tax_query'] = array(array( | |
| 1433 | - 'taxonomy' => 'product_cat', | |
| 1434 | - 'field' => 'term_id', | |
| 1435 | - 'terms' => array_map('intval', $scope), | |
| 1436 | - 'include_children' => true, | |
| 1437 | - )); | |
| 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; | |
| 1438 | 1776 | } |
| 1439 | 1777 | |
| 1440 | 1778 | $query = new WP_Query($args); |
| 1441 | 1779 | $complete = false; |
| @@ -1545,10 +1883,10 @@ | ||
| 1545 | 1883 | 'orderby' => 'ID', |
| 1546 | 1884 | 'order' => 'ASC', |
| 1547 | 1885 | ); |
| 1548 | 1886 | |
| 1549 | - // Restrict to the chosen top-level categories and their subtrees, to | |
| 1550 | - // 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. | |
| 1551 | 1889 | if (!empty($category_ids)) { |
| 1552 | 1890 | $args['tax_query'] = array(array( |
| 1553 | 1891 | 'taxonomy' => 'product_cat', |
| 1554 | 1892 | 'field' => 'term_id', |
| @@ -1594,10 +1932,14 @@ | ||
| 1594 | 1932 | |
| 1595 | 1933 | // Update progress after each batch so AJAX polling can see it |
| 1596 | 1934 | update_option('onwebchat_wc_bulk_done', $total_done); |
| 1597 | 1935 | |
| 1598 | - // Wait 4 seconds before next batch | |
| 1599 | - 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); | |
| 1600 | 1942 | } |
| 1601 | 1943 | |
| 1602 | 1944 | wp_reset_postdata(); |
| 1603 | 1945 | $page++; |
| @@ -1826,8 +2168,199 @@ | ||
| 1826 | 2168 | 'total' => $total, |
| 1827 | 2169 | ); |
| 1828 | 2170 | } |
| 1829 | 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 | + | |
| 1830 | 2363 | /** |
| 1831 | 2364 | * AJAX: Manually process batch (for debugging) |
| 1832 | 2365 | */ |
| 1833 | 2366 | public function ajax_manual_process_batch() { |