PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.10.0
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.10.0
3.10.0 3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 All 49 releases
← All changes | includes/woocommerce-sync.php +582 -49 3.9.13.10.0 View file →
@@ -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 ("&amp;quot;" for a quote), where a single pass still
482 669 * leaves "&quot;" 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 &amp; 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() {