| @@ -453,19 +453,16 @@ | ||
| 453 | 453 | return (string) get_option('onwebchat_wc_sync_scope_all', '') !== '' || (bool) $this->get_sync_scope(); |
| 454 | 454 | } |
| 455 | 455 | |
| 456 | 456 | /** |
| 457 | - * Removing everything switches automatic sync off, so the products cannot | |
| 458 | - * come straight back on the next product edit. Starting a bulk sync is the | |
| 459 | - * merchant asking for products again, so the switch goes back on, but only | |
| 460 | - * when it was this plugin that turned it off: a merchant who turned it off | |
| 461 | - * themselves keeps it off. The note is one-shot and cleared either way. | |
| 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. | |
| 462 | 463 | */ |
| 463 | - private function resume_auto_sync_after_removal() { | |
| 464 | - if (!get_option('onwebchat_wc_sync_off_by_removal', false)) { | |
| 465 | - return; | |
| 466 | - } | |
| 467 | - | |
| 464 | + private function enable_auto_sync_for_run() { | |
| 468 | 465 | delete_option('onwebchat_wc_sync_off_by_removal'); |
| 469 | 466 | |
| 470 | 467 | if (!get_option('onwebchat_wc_sync_enabled', false)) { |
| 471 | 468 | update_option('onwebchat_wc_sync_enabled', true); |
| @@ -742,9 +739,11 @@ | ||
| 742 | 739 | // Structured fields. The server rebuilds the embedding text from these, |
| 743 | 740 | // so there is no need to send a pre-formatted "text" blob. |
| 744 | 741 | $data = array( |
| 745 | 742 | 'product_id' => $product->get_id(), |
| 746 | - '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()), | |
| 747 | 746 | 'short_description' => trim($description), |
| 748 | 747 | 'url' => $url, |
| 749 | 748 | 'sku' => $sku, |
| 750 | 749 | 'categories' => $categories, |
| @@ -818,10 +817,79 @@ | ||
| 818 | 817 | $data['rating'] = $rating; |
| 819 | 818 | $data['review_count'] = (int) $product->get_review_count(); |
| 820 | 819 | } |
| 821 | 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 | + | |
| 822 | 826 | return $data; |
| 823 | 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 | + } | |
| 824 | 892 | |
| 825 | 893 | /** |
| 826 | 894 | * Get product category names |
| 827 | 895 | */ |
| @@ -831,9 +899,9 @@ | ||
| 831 | 899 | |
| 832 | 900 | foreach ($category_ids as $cat_id) { |
| 833 | 901 | $term = get_term($cat_id, 'product_cat'); |
| 834 | 902 | if ($term && !is_wp_error($term)) { |
| 835 | - $categories[] = $term->name; | |
| 903 | + $categories[] = $this->decode_entities($term->name); | |
| 836 | 904 | } |
| 837 | 905 | } |
| 838 | 906 | |
| 839 | 907 | return $categories; |
| @@ -852,9 +920,9 @@ | ||
| 852 | 920 | } |
| 853 | 921 | |
| 854 | 922 | $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); |
| 855 | 923 | if (!is_wp_error($terms) && !empty($terms)) { |
| 856 | - return $terms[0]; | |
| 924 | + return $this->decode_entities($terms[0]); | |
| 857 | 925 | } |
| 858 | 926 | } |
| 859 | 927 | |
| 860 | 928 | return ''; |
| @@ -902,9 +970,9 @@ | ||
| 902 | 970 | if (is_wp_error($tags) || empty($tags)) { |
| 903 | 971 | return array(); |
| 904 | 972 | } |
| 905 | 973 | |
| 906 | - return $tags; | |
| 974 | + return array_map(array($this, 'decode_entities'), $tags); | |
| 907 | 975 | } |
| 908 | 976 | |
| 909 | 977 | /** |
| 910 | 978 | * Send batch of products to API (optimized) |
| @@ -1536,9 +1604,9 @@ | ||
| 1536 | 1604 | } |
| 1537 | 1605 | } |
| 1538 | 1606 | |
| 1539 | 1607 | $this->save_sync_scope($new_scope, $new_all); |
| 1540 | - $this->resume_auto_sync_after_removal(); | |
| 1608 | + $this->enable_auto_sync_for_run(); | |
| 1541 | 1609 | update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms))); |
| 1542 | 1610 | update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude))); |
| 1543 | 1611 | |
| 1544 | 1612 | // Store the current sync start time |
| @@ -1614,8 +1682,9 @@ | ||
| 1614 | 1682 | // Remember the merchant's choice so ongoing auto-sync stays within it: |
| 1615 | 1683 | // selected categories become the sync scope; an unrestricted "sync all" |
| 1616 | 1684 | // puts the whole catalogue in scope. |
| 1617 | 1685 | $this->save_sync_scope($category_ids, empty($category_ids)); |
| 1686 | + $this->enable_auto_sync_for_run(); | |
| 1618 | 1687 | |
| 1619 | 1688 | // Count total products within scope, capped at the hard limit. |
| 1620 | 1689 | $total = $this->count_products_in_scope($category_ids); |
| 1621 | 1690 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| @@ -1632,9 +1701,9 @@ | ||
| 1632 | 1701 | // sync, so a 0-product start (empty scope) can't leave the store stuck at |
| 1633 | 1702 | // "a sync is already in progress". |
| 1634 | 1703 | update_option('onwebchat_wc_bulk_in_progress', $total > 0); |
| 1635 | 1704 | |
| 1636 | - wp_send_json_success(array('total' => $total)); | |
| 1705 | + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); | |
| 1637 | 1706 | } |
| 1638 | 1707 | |
| 1639 | 1708 | /** |
| 1640 | 1709 | * AJAX: process the next page of the in-progress bulk sync and report progress. |