| @@ -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'); |