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 +165 -21 3.9.23.10.0 View file →
@@ -396,9 +396,11 @@
396 396 }
397 397
398 398 /**
399 399 * Get the saved sync scope as an array of product_cat term IDs.
400 - * 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.
401 403 */
402 404 private function get_sync_scope() {
403 405 return $this->parse_id_list(get_option('onwebchat_wc_sync_categories', ''));
404 406 }
@@ -424,11 +426,57 @@
424 426 return array_values($ids);
425 427 }
426 428
427 429 /**
428 - * 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.
429 436 */
430 - 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) {
431 479 $clean = array();
432 480 foreach ((array) $category_ids as $id) {
433 481 $id = (int) $id;
434 482 if ($id > 0) {
@@ -436,13 +484,15 @@
436 484 }
437 485 }
438 486
439 487 update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
488 + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0');
440 489 }
441 490
442 491 /**
443 492 * Is the product within the current sync scope?
444 - * No scope set means everything is in scope. The picker offers the whole
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
445 495 * category tree and selecting a category covers its whole subtree, so a
446 496 * product is in scope when any of its categories is a scoped category OR a
447 497 * descendant of one. This mirrors the bulk sync tax query
448 498 * (include_children = true).
@@ -449,9 +499,9 @@
449 499 */
450 500 private function product_in_scope($product) {
451 501 $scope = $this->get_sync_scope();
452 502 if (empty($scope)) {
453 - return true;
503 + return $this->is_scope_all();
454 504 }
455 505
456 506 foreach ($product->get_category_ids() as $cat_id) {
457 507 $cat_id = (int) $cat_id;
@@ -593,17 +643,24 @@
593 643 }
594 644
595 645 /**
596 646 * What the AI training data currently covers, for the settings screen:
597 - * array(categories, products, whole_catalogue).
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.
598 652 */
599 653 public function get_scope_summary() {
600 654 $scope = $this->get_sync_scope();
655 + $all = $this->is_scope_all();
601 656
602 657 return array(
603 658 'categories' => count($scope),
604 - 'products' => $this->count_products_in_scope($scope),
605 - 'whole_catalogue' => empty($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(),
606 663 );
607 664 }
608 665
609 666 /**
@@ -682,9 +739,11 @@
682 739 // Structured fields. The server rebuilds the embedding text from these,
683 740 // so there is no need to send a pre-formatted "text" blob.
684 741 $data = array(
685 742 'product_id' => $product->get_id(),
686 - '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()),
687 746 'short_description' => trim($description),
688 747 'url' => $url,
689 748 'sku' => $sku,
690 749 'categories' => $categories,
@@ -758,10 +817,79 @@
758 817 $data['rating'] = $rating;
759 818 $data['review_count'] = (int) $product->get_review_count();
760 819 }
761 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 +
762 826 return $data;
763 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 + }
764 892
765 893 /**
766 894 * Get product category names
767 895 */
@@ -771,9 +899,9 @@
771 899
772 900 foreach ($category_ids as $cat_id) {
773 901 $term = get_term($cat_id, 'product_cat');
774 902 if ($term && !is_wp_error($term)) {
775 - $categories[] = $term->name;
903 + $categories[] = $this->decode_entities($term->name);
776 904 }
777 905 }
778 906
779 907 return $categories;
@@ -792,9 +920,9 @@
792 920 }
793 921
794 922 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
795 923 if (!is_wp_error($terms) && !empty($terms)) {
796 - return $terms[0];
924 + return $this->decode_entities($terms[0]);
797 925 }
798 926 }
799 927
800 928 return '';
@@ -842,9 +970,9 @@
842 970 if (is_wp_error($tags) || empty($tags)) {
843 971 return array();
844 972 }
845 973
846 - return $tags;
974 + return array_map(array($this, 'decode_entities'), $tags);
847 975 }
848 976
849 977 /**
850 978 * Send batch of products to API (optimized)
@@ -1446,20 +1574,26 @@
1446 1574 // product scope no longer re-sends all 10,000.
1447 1575 $saved_scope = $this->get_sync_scope();
1448 1576
1449 1577 if (empty($category_ids)) {
1450 - $new_scope = array(); // whole catalogue
1578 + // Nothing ticked: the whole catalogue is the scope.
1579 + $new_scope = array();
1580 + $new_all = true;
1451 1581 $run_terms = array(); // push everything
1452 1582 $run_exclude = array();
1453 1583 } elseif (empty($saved_scope)) {
1454 - // Whole catalogue is already in scope; keep it that way and treat the
1455 - // selection as "refresh these categories".
1456 - $new_scope = array();
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;
1457 1590 $run_terms = $category_ids;
1458 1591 $run_exclude = array();
1459 1592 } else {
1460 1593 $added = $this->categories_added($category_ids, $saved_scope);
1461 1594 $new_scope = array_values(array_unique(array_merge($saved_scope, $category_ids)));
1595 + $new_all = false;
1462 1596
1463 1597 if (!empty($added)) {
1464 1598 $run_terms = $added;
1465 1599 $run_exclude = $saved_scope; // already synced, skip it
@@ -1469,9 +1603,10 @@
1469 1603 $run_exclude = array();
1470 1604 }
1471 1605 }
1472 1606
1473 - $this->save_sync_scope($new_scope);
1607 + $this->save_sync_scope($new_scope, $new_all);
1608 + $this->enable_auto_sync_for_run();
1474 1609 update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms)));
1475 1610 update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude)));
1476 1611
1477 1612 // Store the current sync start time
@@ -1545,10 +1680,11 @@
1545 1680 }
1546 1681
1547 1682 // Remember the merchant's choice so ongoing auto-sync stays within it:
1548 1683 // selected categories become the sync scope; an unrestricted "sync all"
1549 - // clears the scope (the whole catalogue is in scope again).
1550 - $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();
1551 1687
1552 1688 // Count total products within scope, capped at the hard limit.
1553 1689 $total = $this->count_products_in_scope($category_ids);
1554 1690 if ($total > self::MAX_SYNC_PRODUCTS) {
@@ -1565,9 +1701,9 @@
1565 1701 // sync, so a 0-product start (empty scope) can't leave the store stuck at
1566 1702 // "a sync is already in progress".
1567 1703 update_option('onwebchat_wc_bulk_in_progress', $total > 0);
1568 1704
1569 - wp_send_json_success(array('total' => $total));
1705 + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true));
1570 1706 }
1571 1707
1572 1708 /**
1573 1709 * AJAX: process the next page of the in-progress bulk sync and report progress.
@@ -2197,9 +2333,15 @@
2197 2333 private function finish_scope_removal() {
2198 2334 $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', ''));
2199 2335 $had_scope = (bool) $this->get_sync_scope();
2200 2336
2201 - $this->save_sync_scope($keep);
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 + }
2202 2344
2203 2345 // Nothing left ticked means the bot should hold no products at all. An
2204 2346 // empty scope means "the whole catalogue", so leaving automatic sync on
2205 2347 // would push every product straight back in on its next edit.
@@ -2206,8 +2348,10 @@
2206 2348 // Only when a scope was actually being removed: a no-op call on a store
2207 2349 // that already syncs its whole catalogue must never touch the toggle.
2208 2350 if (empty($keep) && $had_scope) {
2209 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);
2210 2354 }
2211 2355
2212 2356 update_option('onwebchat_wc_remove_in_progress', false);
2213 2357 delete_option('onwebchat_wc_remove_terms');