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 +1274 -77 3.6.03.10.0 View file →
@@ -11,11 +11,30 @@
11 11 class OnWebChat_WooCommerce_Sync {
12 12
13 13 private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
14 14 private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
15 - private $max_description_length = 1000;
15 + // Descriptions are sent WHOLE: the onWebChat server decides what to do with
16 + // them and rewrites the ones that do not fit its training text with a small
17 + // model, instead of cutting them off (the tail of a description usually
18 + // holds the specs and compatibility info). These are only sanity limits
19 + // against pathological descriptions (page-builder dumps), sized so a
20 + // 50-product batch stays far below the server's 10MB body limit.
21 + private $max_description_length = 20000;
22 + private $max_description_length_combined = 20000;
16 23 private $batch_size = 50;
17 24 private $use_testing_mode;
25 +
26 + // Large-catalogue sync scope.
27 + // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
28 + // category picker so the merchant can choose what to sync. The sync is
29 + // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
30 + // catalogue. Above the cap a category selection is required.
31 + const CATEGORY_SELECT_THRESHOLD = 2000;
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;
18 37
19 38 /**
20 39 * Get the API endpoint based on testing mode
21 40 * @return string
@@ -26,8 +45,19 @@
26 45
27 46 public function __construct() {
28 47 // Read testing mode from global constant (defined in onwebchat.php)
29 48 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
49 +
50 + // One-time migration to the "short + full" default. The Description Mode
51 + // selector was hidden in the UI before this version, so a stored
52 + // 'short_fallback_full' was the hidden form field's value, never a real
53 + // merchant choice. An explicit 'short_only' is left untouched.
54 + if (!get_option('onwebchat_wc_desc_mode_migrated')) {
55 + if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') {
56 + update_option('onwebchat_wc_sync_mode', 'short_plus_full');
57 + }
58 + update_option('onwebchat_wc_desc_mode_migrated', 1);
59 + }
30 60 // Initialize settings
31 61 add_action('admin_init', array($this, 'register_settings'));
32 62
33 63 // Show authentication error notice globally (not just on WooCommerce tab)
@@ -35,9 +65,31 @@
35 65
36 66 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
37 67 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
38 68 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
39 -
69 +
70 + // Lightweight availability hook: fires whenever a product's stock STATUS flips
71 + // (including order-driven stock reductions that may not trigger a full product save).
72 + // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
73 + // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
74 + // parent product's stock status from its variations and fires this action for the
75 + // parent, which is the entity synced to onWebChat.
76 + add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);
77 +
78 + // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale
79 + // prices via direct meta updates, NOT through a product save, so
80 + // woocommerce_update_product never fires and the AI would keep quoting the
81 + // pre-sale price. These two actions receive the affected product/variation
82 + // IDs right after the cron applies or removes the sale prices.
83 + add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1);
84 + add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1);
85 +
86 + // Variation price edits (the Variations tab saves via AJAX without always
87 + // re-saving the parent post). Collect the parent IDs and sync each parent
88 + // once on shutdown, so the synced price range follows variation changes.
89 + add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1);
90 + add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1);
91 +
40 92 // Handle product deletion (both trash and permanent delete)
41 93 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
42 94 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
43 95
@@ -45,8 +97,17 @@
45 97 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
46 98
47 99 // Admin AJAX handlers
48 100 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
101 + // Client-driven chunked bulk sync: the browser starts a run, then calls
102 + // the batch action repeatedly (one page per request) until it completes.
103 + // This replaces the single long request that timed out on large
104 + // catalogues and reported a false "sync failed" while products kept
105 + // syncing.
106 + add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync'));
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'));
49 110 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
50 111 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
51 112 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
52 113 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
@@ -63,10 +124,15 @@
63 124 if (!current_user_can('manage_options')) {
64 125 wp_send_json_error('Insufficient permissions');
65 126 }
66 127
67 - $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
68 - $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']) : '';
69 135
70 136 if (empty($email) || empty($password)) {
71 137 wp_send_json_error('Email and password are required');
72 138 }
@@ -119,8 +185,12 @@
119 185 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
120 186 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
121 187 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
122 188 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
189 + // Persisted sync scope: comma-separated product_cat term IDs. Empty means
190 + // the whole catalogue is in scope. Drives both bulk sync and ongoing
191 + // per-product auto-sync.
192 + register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
123 193 }
124 194
125 195 /**
126 196 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
@@ -145,15 +215,136 @@
145 215 // Check if product category is excluded
146 216 if ($this->is_product_excluded($product)) {
147 217 return;
148 218 }
149 -
219 +
220 + // Respect the merchant's sync scope. When a category selection is active
221 + // and this product belongs to none of the scoped categories, remove it
222 + // (it may have been moved out of a scoped category after being synced)
223 + // and stop. Deletes always remove, regardless of scope.
224 + if (!$this->product_in_scope($product)) {
225 + $this->send_product_delete($product_id);
226 + return;
227 + }
228 +
150 229 // Prepare and send product data
151 230 $product_data = $this->prepare_product_data($product);
152 231 $this->send_product_upsert($product_data, $product_id);
153 232 }
154 -
233 +
155 234 /**
235 + * Hook: WooCommerce's scheduled-sales cron started or ended sales on these
236 + * products. IDs can be simple products OR variations; variations are mapped to
237 + * their parent (the entity synced to onWebChat) and each product is re-pushed
238 + * once, so the AI immediately quotes the new (sale or regular) price.
239 + *
240 + * @param array $product_ids
241 + */
242 + public function on_scheduled_sales($product_ids) {
243 + if (!get_option('onwebchat_wc_sync_enabled', false)) {
244 + return;
245 + }
246 +
247 + $ids = array();
248 + foreach ((array) $product_ids as $product_id) {
249 + $product = wc_get_product($product_id);
250 + if (!$product) {
251 + continue;
252 + }
253 +
254 + $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
255 + if ($id > 0) {
256 + $ids[$id] = $id; // de-duplicate
257 + }
258 + }
259 +
260 + foreach ($ids as $id) {
261 + $this->on_product_update($id);
262 + }
263 + }
264 +
265 + /**
266 + * Parent product IDs whose variations changed in this request; flushed once on
267 + * shutdown so a save touching 30 variations pushes the parent a single time.
268 + */
269 + private $pending_variation_parents = array();
270 +
271 + /**
272 + * Hook: a variation was created/updated (Variations tab saves happen over AJAX
273 + * and don't always re-save the parent post, so woocommerce_update_product may
274 + * never fire). Queue the parent for one sync at the end of the request.
275 + *
276 + * @param int $variation_id
277 + */
278 + public function on_variation_update($variation_id) {
279 + if (!get_option('onwebchat_wc_sync_enabled', false)) {
280 + return;
281 + }
282 +
283 + $variation = wc_get_product($variation_id);
284 + if (!$variation || !$variation->is_type('variation')) {
285 + return;
286 + }
287 +
288 + $parent_id = (int) $variation->get_parent_id();
289 + if ($parent_id <= 0) {
290 + return;
291 + }
292 +
293 + if (empty($this->pending_variation_parents)) {
294 + add_action('shutdown', array($this, 'flush_variation_parent_syncs'));
295 + }
296 +
297 + $this->pending_variation_parents[$parent_id] = $parent_id;
298 + }
299 +
300 + /**
301 + * Shutdown: sync every parent whose variations changed in this request.
302 + */
303 + public function flush_variation_parent_syncs() {
304 + $parent_ids = $this->pending_variation_parents;
305 + $this->pending_variation_parents = array();
306 +
307 + foreach ($parent_ids as $parent_id) {
308 + $this->on_product_update($parent_id);
309 + }
310 + }
311 +
312 + /**
313 + * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
314 + * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
315 + * is treated as available since the store still accepts orders.
316 + *
317 + * @param int $product_id
318 + * @param string $status 'instock' | 'outofstock' | 'onbackorder'
319 + * @param WC_Product|null $product
320 + */
321 + public function on_stock_status_change($product_id, $status, $product = null) {
322 + if (!get_option('onwebchat_wc_sync_enabled', false)) {
323 + return;
324 + }
325 +
326 + if (!$product || !is_object($product)) {
327 + $product = wc_get_product($product_id);
328 + }
329 + if (!$product) {
330 + return;
331 + }
332 +
333 + // Only products that would actually be synced: published, not excluded, in scope.
334 + // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
335 + if ($product->get_status() !== 'publish') {
336 + return;
337 + }
338 + if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
339 + return;
340 + }
341 +
342 + $in_stock = ($status !== 'outofstock');
343 + $this->send_product_stock($product_id, $in_stock);
344 + }
345 +
346 + /**
156 347 * Hook: Product trash (when moved to trash)
157 348 */
158 349 public function on_product_trash($post_id) {
159 350 // Check if it's a product
@@ -199,36 +390,334 @@
199 390 if (in_array($cat_id, $excluded_categories)) {
200 391 return true;
201 392 }
202 393 }
203 -
394 +
204 395 return false;
205 396 }
206 -
397 +
207 398 /**
399 + * Get the saved sync scope as an array of product_cat term IDs.
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.
403 + */
404 + private function get_sync_scope() {
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;
414 + if ($raw === '') {
415 + return array();
416 + }
417 +
418 + $ids = array();
419 + foreach (explode(',', $raw) as $id) {
420 + $id = (int) trim($id);
421 + if ($id > 0) {
422 + $ids[$id] = $id; // de-duplicate
423 + }
424 + }
425 +
426 + return array_values($ids);
427 + }
428 +
429 + /**
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.
436 + */
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) {
479 + $clean = array();
480 + foreach ((array) $category_ids as $id) {
481 + $id = (int) $id;
482 + if ($id > 0) {
483 + $clean[$id] = $id;
484 + }
485 + }
486 +
487 + update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
488 + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0');
489 + }
490 +
491 + /**
492 + * Is the product within the current sync scope?
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
496 + * product is in scope when any of its categories is a scoped category OR a
497 + * descendant of one. This mirrors the bulk sync tax query
498 + * (include_children = true).
499 + */
500 + private function product_in_scope($product) {
501 + $scope = $this->get_sync_scope();
502 + if (empty($scope)) {
503 + return $this->is_scope_all();
504 + }
505 +
506 + foreach ($product->get_category_ids() as $cat_id) {
507 + $cat_id = (int) $cat_id;
508 + if (in_array($cat_id, $scope, true)) {
509 + return true;
510 + }
511 + // Walk up to the root: a scoped ancestor puts the product in scope.
512 + foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
513 + if (in_array((int) $ancestor_id, $scope, true)) {
514 + return true;
515 + }
516 + }
517 + }
518 +
519 + return false;
520 + }
521 +
522 + /**
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.
625 + * Uses found_posts so we do not load every ID into memory.
626 + */
627 + private function count_products_in_scope($category_ids, $exclude = array()) {
628 + $args = array(
629 + 'post_type' => 'product',
630 + 'post_status' => 'publish',
631 + 'posts_per_page' => 1,
632 + 'fields' => 'ids',
633 + 'no_found_rows' => false,
634 + );
635 +
636 + $tax_query = $this->build_scope_tax_query($category_ids, $exclude);
637 + if ($tax_query !== null) {
638 + $args['tax_query'] = $tax_query;
639 + }
640 +
641 + $query = new WP_Query($args);
642 + return (int) $query->found_posts;
643 + }
644 +
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 + /**
667 + * Turn HTML entities into real characters. Product text is often stored
668 + * double-encoded ("&amp;quot;" for a quote), where a single pass still
669 + * leaves "&quot;" in the text the bot is trained on, so decode until the
670 + * string stops changing (3 passes is far more than any real content needs).
671 + * Always call this AFTER strip_tags: decoding first could turn text like
672 + * "price &lt; 100 and &gt; 50" into something strip_tags eats as a tag.
673 + */
674 + private function decode_entities($value) {
675 + $value = (string) $value;
676 +
677 + for ($i = 0; $i < 3; $i++) {
678 + $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
679 + if ($decoded === $value) {
680 + break;
681 + }
682 + $value = $decoded;
683 + }
684 +
685 + // html_entity_decode turns &nbsp; into a non-breaking space; make it a
686 + // plain space so the text does not carry invisible oddities.
687 + return str_replace("\xC2\xA0", ' ', $value);
688 + }
689 +
690 + /**
208 691 * Prepare product data for sync
209 692 */
210 693 private function prepare_product_data($product) {
211 - $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full');
694 + $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full');
212 695
213 696 // Get description based on sync mode
214 697 $description = '';
215 - $short_description = strip_tags($product->get_short_description());
698 + $short_description = $this->decode_entities(strip_tags($product->get_short_description()));
216 699
217 700 if ($sync_mode === 'short_only') {
218 701 $description = $short_description;
702 + } else if ($sync_mode === 'short_plus_full') {
703 + // Send both texts: the short description first, then the full one.
704 + $full_description = $this->decode_entities(strip_tags($product->get_description()));
705 + $parts = array_filter(array(trim($short_description), trim($full_description)));
706 + $description = implode("\n\n", $parts);
219 707 } else if ($sync_mode === 'short_fallback_full') {
220 708 if (!empty($short_description)) {
221 709 $description = $short_description;
222 710 } else {
223 - // Fallback to first 60-80 words of full description
224 - $full_description = strip_tags($product->get_description());
225 - $words = str_word_count($full_description, 2);
226 - $word_array = array_keys($words);
711 + // Fallback to the first 200 words of the full description.
712 + // Split with a Unicode-aware regex: str_word_count() does not
713 + // recognize non-latin (e.g. Greek) words, so the old word cut
714 + // was unreliable on multibyte text.
715 + $full_description = $this->decode_entities(strip_tags($product->get_description()));
716 + $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);
227 717
228 - if (count($word_array) > 80) {
229 - $end_pos = $word_array[79];
230 - $description = substr($full_description, 0, $end_pos) . '...';
718 + if (is_array($words) && count($words) > 200) {
719 + $description = implode(' ', array_slice($words, 0, 200)) . '...';
231 720 } else {
232 721 $description = $full_description;
233 722 }
234 723 }
@@ -233,11 +722,15 @@
233 722 }
234 723 }
235 724 }
236 725
237 - // Enforce max length
238 - if (strlen($description) > $this->max_description_length) {
239 - $description = substr($description, 0, $this->max_description_length) . '...';
726 + // Enforce max length by characters, not bytes: a byte-based substr()
727 + // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
728 + $max_length = ($sync_mode === 'short_plus_full')
729 + ? $this->max_description_length_combined
730 + : $this->max_description_length;
731 + if (mb_strlen($description, 'UTF-8') > $max_length) {
732 + $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...';
240 733 }
241 734
242 735 $sku = $product->get_sku();
243 736 $categories = $this->get_product_category_names($product);
@@ -246,9 +739,11 @@
246 739 // Structured fields. The server rebuilds the embedding text from these,
247 740 // so there is no need to send a pre-formatted "text" blob.
248 741 $data = array(
249 742 'product_id' => $product->get_id(),
250 - '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()),
251 746 'short_description' => trim($description),
252 747 'url' => $url,
253 748 'sku' => $sku,
254 749 'categories' => $categories,
@@ -254,23 +749,39 @@
254 749 'categories' => $categories,
255 750 'currency' => get_woocommerce_currency(),
256 751 );
257 752
258 - // Price (always sent). Variable products carry a min/max range.
259 - $data['price'] = $product->get_price();
753 + // Price, as the customer sees it in the shop. get_price() returns the value
754 + // as entered in admin, which excludes tax on shops that enter net prices but
755 + // display gross ones, so the AI would quote a price the visitor never sees.
756 + // wc_get_price_to_display() applies the shop's tax display settings.
757 + $raw_price = $product->get_price();
758 + if ($raw_price !== '') {
759 + $data['price'] = wc_get_price_to_display($product);
260 760
761 + // When the shop displays taxed prices, also send the untaxed price so the
762 + // AI can quote both.
763 + if (wc_tax_enabled()) {
764 + $price_excl_tax = wc_get_price_excluding_tax($product);
765 + if ((float) $price_excl_tax !== (float) $data['price']) {
766 + $data['price_excl_tax'] = $price_excl_tax;
767 + }
768 + }
769 + }
770 +
261 771 if ($product->is_type('variable')) {
262 - // Raw min/max prices, consistent with get_price() used for simple products.
263 - $data['price_min'] = $product->get_variation_price('min', false);
264 - $data['price_max'] = $product->get_variation_price('max', false);
772 + // Display min/max prices, consistent with the display price used for
773 + // simple products.
774 + $data['price_min'] = $product->get_variation_price('min', true);
775 + $data['price_max'] = $product->get_variation_price('max', true);
265 776 } else {
266 777 $regular_price = $product->get_regular_price();
267 778 if ($regular_price !== '') {
268 - $data['regular_price'] = $regular_price;
779 + $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price));
269 780 }
270 781 // Only advertise a sale price while the sale is actually active.
271 - if ($product->is_on_sale()) {
272 - $data['sale_price'] = $product->get_sale_price();
782 + if ($product->is_on_sale() && $product->get_sale_price() !== '') {
783 + $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price()));
273 784 }
274 785 }
275 786
276 787 // Stock availability
@@ -306,10 +817,79 @@
306 817 $data['rating'] = $rating;
307 818 $data['review_count'] = (int) $product->get_review_count();
308 819 }
309 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 +
310 826 return $data;
311 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 + }
312 892
313 893 /**
314 894 * Get product category names
315 895 */
@@ -319,9 +899,9 @@
319 899
320 900 foreach ($category_ids as $cat_id) {
321 901 $term = get_term($cat_id, 'product_cat');
322 902 if ($term && !is_wp_error($term)) {
323 - $categories[] = $term->name;
903 + $categories[] = $this->decode_entities($term->name);
324 904 }
325 905 }
326 906
327 907 return $categories;
@@ -340,9 +920,9 @@
340 920 }
341 921
342 922 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
343 923 if (!is_wp_error($terms) && !empty($terms)) {
344 - return $terms[0];
924 + return $this->decode_entities($terms[0]);
345 925 }
346 926 }
347 927
348 928 return '';
@@ -390,16 +970,21 @@
390 970 if (is_wp_error($tags) || empty($tags)) {
391 971 return array();
392 972 }
393 973
394 - return $tags;
974 + return array_map(array($this, 'decode_entities'), $tags);
395 975 }
396 976
397 977 /**
398 978 * Send batch of products to API (optimized)
399 - * @param array $products - Array of product data
979 + * @param array $products - Array of product data
980 + * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run)
981 + * @param int $sync_done - Products pushed so far in the run, including this batch
982 + *
983 + * When $sync_total is > 0 the batch is tagged with the run total/progress so
984 + * the server can relay a live progress bar to open dashboards.
400 985 */
401 - private function send_product_batch($products) {
986 + private function send_product_batch($products, $sync_total = 0, $sync_done = 0) {
402 987 $chatId = get_option('onwebchat_plugin_option');
403 988 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
404 989
405 990 if (empty($chatId)) {
@@ -426,8 +1011,16 @@
426 1011 'site_id' => $chatIdKey,
427 1012 'site_url' => get_site_url(),
428 1013 'products' => $products
429 1014 );
1015 +
1016 + // Tag bulk-run batches with the run total + progress so the server can
1017 + // relay a live progress bar to open dashboards. Omitted for incremental
1018 + // single-product syncs (which pass no total).
1019 + if ((int) $sync_total > 0) {
1020 + $payload['sync_total'] = (int) $sync_total;
1021 + $payload['sync_done'] = min((int) $sync_done, (int) $sync_total);
1022 + }
430 1023
431 1024 // Generate authentication headers (same as send_authenticated_request)
432 1025 $timestamp = time();
433 1026 $nonce = base64_encode(random_bytes(16));
@@ -439,9 +1032,12 @@
439 1032
440 1033 // Send request
441 1034 $request_args = array(
442 1035 'method' => 'POST',
443 - 'timeout' => 30, // Longer timeout for batch operations
1036 + // A batch whose descriptions are summarized for the first time costs
1037 + // the server one model call per oversized product, so it needs far
1038 + // more than the 30s that is plenty for every other endpoint.
1039 + 'timeout' => 180,
444 1040 'headers' => array(
445 1041 'Content-Type' => 'application/json',
446 1042 'X-OWC-SiteId' => $chatIdKey,
447 1043 'X-OWC-Timestamp' => $timestamp,
@@ -627,10 +1223,71 @@
627 1223 );
628 1224
629 1225 $this->send_authenticated_request($endpoint, $payload, $product_id);
630 1226 }
631 -
1227 +
632 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 + /**
1265 + * Send a lightweight availability update to onWebChat (no re-embed on the server).
1266 + */
1267 + private function send_product_stock($product_id, $in_stock) {
1268 + $chatId = get_option('onwebchat_plugin_option');
1269 + $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1270 +
1271 + if (empty($chatId)) {
1272 + return false;
1273 + }
1274 +
1275 + // Extract key part (before first slash if present)
1276 + $chatIdKey = explode('/', $chatId)[0];
1277 +
1278 + $endpoint = $this->get_api_endpoint() . '/product/stock';
1279 + $payload = array(
1280 + 'site_id' => $chatIdKey, // Use key part only
1281 + 'site_url' => get_site_url(),
1282 + 'product_id' => $product_id,
1283 + 'in_stock' => (bool) $in_stock,
1284 + );
1285 +
1286 + $this->send_authenticated_request($endpoint, $payload, $product_id);
1287 + }
1288 +
1289 + /**
633 1290 * Get cached secret from local options
634 1291 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
635 1292 */
636 1293 private function get_secret($force_refresh = false) {
@@ -691,10 +1348,12 @@
691 1348 $status_code = wp_remote_retrieve_response_code($response);
692 1349 $response_body_raw = wp_remote_retrieve_body($response);
693 1350 $body = json_decode($response_body_raw, true);
694 1351
695 - // Log response for debugging
696 - error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($response_body_raw, 0, 500));
1352 + // Log response for debugging. Redact the secret so it never lands in server/debug logs
1353 + // (a successful response body contains the HMAC secret).
1354 + $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
1355 + error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
697 1356
698 1357 // Handle specific HTTP status codes
699 1358 if ($status_code === 401) {
700 1359 return array('success' => false, 'error' => 'Invalid email or password');
@@ -711,8 +1370,17 @@
711 1370 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
712 1371 return array('success' => false, 'error' => 'Server response missing secret');
713 1372 }
714 1373 update_option('onwebchat_wc_sync_secret', $secret);
1374 +
1375 + // Enable AI order-status lookup by default on connect and register our
1376 + // callback URL with onWebChat (best effort; the merchant can toggle it off).
1377 + update_option('onwebchat_wc_order_lookup_enabled', true);
1378 + global $onwebchat_wc_orders;
1379 + if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
1380 + $onwebchat_wc_orders->push_order_lookup_config(true);
1381 + }
1382 +
715 1383 return array('success' => true, 'secret' => $secret);
716 1384 }
717 1385
718 1386 // Extract error message from various possible response formats
@@ -730,8 +1398,12 @@
730 1398 } elseif (!empty($response_body_raw)) {
731 1399 // If body is not JSON or empty, use raw response (truncated)
732 1400 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
733 1401 }
1402 +
1403 + // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
1404 + // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
1405 + $error_message = sanitize_text_field($error_message);
734 1406
735 1407 // Include status code in error message if not already included
736 1408 if ($status_code && strpos($error_message, 'HTTP') === false) {
737 1409 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
@@ -843,9 +1515,13 @@
843 1515 * AJAX: Start bulk sync
844 1516 */
845 1517 public function ajax_sync_existing_products() {
846 1518 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
847 -
1519 +
1520 + // This path can walk the whole catalogue in one request, and each batch
1521 + // waits for the server (which may summarize descriptions with a model).
1522 + @set_time_limit(0);
1523 +
848 1524 if (!current_user_can('manage_options')) {
849 1525 wp_send_json_error('Insufficient permissions');
850 1526 }
851 1527
@@ -855,42 +1531,105 @@
855 1531 }
856 1532
857 1533 // Rate limiting: prevent syncing more than once every 5 minutes
858 1534 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
859 - $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
860 1536 $time_since_last_sync = time() - $last_sync_time;
861 1537
862 1538 if ($time_since_last_sync < $cooldown_period) {
863 - $wait_time = $cooldown_period - $time_since_last_sync;
864 - $minutes = ceil($wait_time / 60);
865 - 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.');
866 1541 }
867 1542
1543 + // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1544 + $category_ids = array();
1545 + if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1546 + foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1547 + $id = (int) trim($id);
1548 + if ($id > 0) {
1549 + $category_ids[] = $id;
1550 + }
1551 + }
1552 + }
1553 +
1554 + // Above the hard cap a category selection is required: refuse an
1555 + // unrestricted "sync all" when the catalogue is larger than the cap.
1556 + $published_total = $this->count_products_in_scope(array());
1557 + if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1558 + wp_send_json_error(sprintf(
1559 + 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1560 + number_format_i18n($published_total),
1561 + number_format_i18n(self::MAX_SYNC_PRODUCTS)
1562 + ));
1563 + }
1564 +
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();
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 +
868 1612 // Store the current sync start time
869 1613 update_option('onwebchat_wc_last_sync_start', time());
870 -
1614 +
871 1615 // Reset bulk sync progress
872 1616 update_option('onwebchat_wc_bulk_page', 0);
873 1617 update_option('onwebchat_wc_bulk_done', 0);
874 -
875 - // Count total products
876 - $args = array(
877 - 'post_type' => 'product',
878 - 'post_status' => 'publish',
879 - 'posts_per_page' => -1,
880 - 'fields' => 'ids',
881 - );
882 -
883 - $products = get_posts($args);
884 - $total = count($products);
885 -
1618 +
1619 + // Count the products THIS run will push, capped at the hard limit.
1620 + $total = $this->count_products_in_scope($run_terms, $run_exclude);
1621 + if ($total > self::MAX_SYNC_PRODUCTS) {
1622 + $total = self::MAX_SYNC_PRODUCTS;
1623 + }
1624 +
886 1625 update_option('onwebchat_wc_bulk_total', $total);
887 1626 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
888 1627 update_option('onwebchat_wc_bulk_in_progress', true);
889 -
1628 +
890 1629 // Process sync directly instead of using unreliable WP Cron
891 - $sync_result = $this->do_bulk_sync_all();
892 -
1630 + $sync_result = $this->do_bulk_sync_all($category_ids);
1631 +
893 1632 wp_send_json_success(array(
894 1633 'message' => 'Bulk sync completed',
895 1634 'total' => $total,
896 1635 'result' => $sync_result
@@ -897,16 +1636,244 @@
897 1636 ));
898 1637 }
899 1638
900 1639 /**
901 - * Process all products in bulk sync directly (not via cron)
1640 + * AJAX: begin a client-driven bulk sync.
1641 + *
1642 + * Sets up the progress state and returns the total number of products to
1643 + * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page
1644 + * per request) until the run reports it is complete. Because each request is
1645 + * short, the whole sync no longer rides on a single request that outran the
1646 + * web server timeout and reported a false failure while products kept
1647 + * syncing.
902 1648 */
903 - private function do_bulk_sync_all() {
1649 + public function ajax_start_bulk_sync() {
1650 + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1651 +
1652 + if (!current_user_can('manage_options')) {
1653 + wp_send_json_error('Insufficient permissions');
1654 + }
1655 +
1656 + if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1657 + wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1658 + }
1659 +
1660 + // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1661 + $category_ids = array();
1662 + if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1663 + foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1664 + $id = (int) trim($id);
1665 + if ($id > 0) {
1666 + $category_ids[] = $id;
1667 + }
1668 + }
1669 + }
1670 +
1671 + // Above the hard cap a category selection is required: refuse an
1672 + // unrestricted "sync all" when the catalogue is larger than the cap.
1673 + $published_total = $this->count_products_in_scope(array());
1674 + if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1675 + wp_send_json_error(sprintf(
1676 + 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1677 + number_format_i18n($published_total),
1678 + number_format_i18n(self::MAX_SYNC_PRODUCTS)
1679 + ));
1680 + }
1681 +
1682 + // Remember the merchant's choice so ongoing auto-sync stays within it:
1683 + // selected categories become the sync scope; an unrestricted "sync all"
1684 + // puts the whole catalogue in scope.
1685 + $this->save_sync_scope($category_ids, empty($category_ids));
1686 + $this->enable_auto_sync_for_run();
1687 +
1688 + // Count total products within scope, capped at the hard limit.
1689 + $total = $this->count_products_in_scope($category_ids);
1690 + if ($total > self::MAX_SYNC_PRODUCTS) {
1691 + $total = self::MAX_SYNC_PRODUCTS;
1692 + }
1693 +
1694 + // Reset progress state for a fresh run.
1695 + update_option('onwebchat_wc_last_sync_start', time());
1696 + update_option('onwebchat_wc_bulk_page', 0);
1697 + update_option('onwebchat_wc_bulk_done', 0);
1698 + update_option('onwebchat_wc_bulk_total', $total);
1699 + update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0));
1700 + // Only enter the "in progress" state when there is actually something to
1701 + // sync, so a 0-product start (empty scope) can't leave the store stuck at
1702 + // "a sync is already in progress".
1703 + update_option('onwebchat_wc_bulk_in_progress', $total > 0);
1704 +
1705 + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true));
1706 + }
1707 +
1708 + /**
1709 + * AJAX: process the next page of the in-progress bulk sync and report progress.
1710 + */
1711 + public function ajax_sync_next_batch() {
1712 + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1713 +
1714 + // One page of products waits for the server, which may summarize long
1715 + // descriptions with a model: that outlives a default max_execution_time.
1716 + @set_time_limit(0);
1717 +
1718 + if (!current_user_can('manage_options')) {
1719 + wp_send_json_error('Insufficient permissions');
1720 + }
1721 +
1722 + wp_send_json_success($this->sync_next_page());
1723 + }
1724 +
1725 + /**
1726 + * Process exactly one page (batch_size products) of the in-progress bulk
1727 + * sync, advancing the persisted progress. The browser calls this once per
1728 + * request (via ajax_sync_next_batch) until it reports the run is complete,
1729 + * so no single request has to stay open for the whole catalogue.
1730 + *
1731 + * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does
1732 + * NOT schedule a follow-up cron event: the browser drives the loop. Stats
1733 + * are accumulated in an option across pages so the completion notification
1734 + * (which drives the dashboard notice) carries the full run totals.
1735 + *
1736 + * @return array Progress snapshot: in_progress, complete, done, total, stats.
1737 + */
1738 + private function sync_next_page() {
1739 + $total = (int) get_option('onwebchat_wc_bulk_total', 0);
1740 +
1741 + if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1742 + return array(
1743 + 'in_progress' => false,
1744 + 'complete' => true,
1745 + 'done' => (int) get_option('onwebchat_wc_bulk_done', 0),
1746 + 'total' => $total,
1747 + 'stats' => $this->get_bulk_stats(),
1748 + );
1749 + }
1750 +
1751 + $page = (int) get_option('onwebchat_wc_bulk_page', 0);
1752 + $done = (int) get_option('onwebchat_wc_bulk_done', 0);
1753 + $stats = $this->get_bulk_stats();
1754 +
1755 + $args = array(
1756 + 'post_type' => 'product',
1757 + 'post_status' => 'publish',
1758 + 'posts_per_page' => $this->batch_size,
1759 + 'paged' => $page + 1,
1760 + 'orderby' => 'ID',
1761 + 'order' => 'ASC',
1762 + );
1763 +
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;
1776 + }
1777 +
1778 + $query = new WP_Query($args);
1779 + $complete = false;
1780 +
1781 + if ($query->have_posts()) {
1782 + $products_batch = array();
1783 + foreach ($query->posts as $post) {
1784 + $product = wc_get_product($post->ID);
1785 + if ($product && !$this->is_product_excluded($product)) {
1786 + $products_batch[] = $this->prepare_product_data($product);
1787 + }
1788 + }
1789 +
1790 + $batch_done = 0;
1791 + if (!empty($products_batch)) {
1792 + // Tag with run total + running progress so the dashboard bar advances.
1793 + $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch));
1794 + if ($result && isset($result['stats'])) {
1795 + $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped'];
1796 + $stats['created'] += (int) $result['stats']['created'];
1797 + $stats['updated'] += (int) $result['stats']['updated'];
1798 + $stats['skipped'] += (int) $result['stats']['skipped'];
1799 + $stats['errors'] += (int) $result['stats']['errors'];
1800 + } else {
1801 + // Batch failed outright: count the products as errors so the
1802 + // summary reflects reality rather than silently skipping them.
1803 + $batch_done = count($products_batch);
1804 + $stats['errors'] += count($products_batch);
1805 + }
1806 + }
1807 +
1808 + $done += $batch_done;
1809 + $page += 1;
1810 +
1811 + update_option('onwebchat_wc_bulk_page', $page);
1812 + update_option('onwebchat_wc_bulk_done', $done);
1813 + update_option('onwebchat_wc_bulk_stats', $stats);
1814 +
1815 + // Stop once we have covered the counted total or reached the hard cap.
1816 + if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) {
1817 + $complete = true;
1818 + }
1819 + } else {
1820 + // No more products in scope.
1821 + $complete = true;
1822 + }
1823 +
1824 + wp_reset_postdata();
1825 +
1826 + if ($complete) {
1827 + $this->send_sync_completion_notification($stats);
1828 + update_option('onwebchat_wc_bulk_in_progress', false);
1829 + update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1830 + // Show 100% when the counted total was reached; otherwise leave the
1831 + // real processed figure (e.g. an early empty page or the hard cap).
1832 + if ($total > 0 && $done >= $total) {
1833 + $done = $total;
1834 + }
1835 + update_option('onwebchat_wc_bulk_done', $done);
1836 + }
1837 +
1838 + return array(
1839 + 'in_progress' => !$complete,
1840 + 'complete' => $complete,
1841 + 'done' => $done,
1842 + 'total' => $total,
1843 + 'stats' => $stats,
1844 + );
1845 + }
1846 +
1847 + /**
1848 + * Read the accumulated bulk-sync stats option, normalised to the four keys.
1849 + */
1850 + private function get_bulk_stats() {
1851 + $stats = get_option('onwebchat_wc_bulk_stats', array());
1852 + if (!is_array($stats)) {
1853 + $stats = array();
1854 + }
1855 + return array(
1856 + 'created' => isset($stats['created']) ? (int) $stats['created'] : 0,
1857 + 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0,
1858 + 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0,
1859 + 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0,
1860 + );
1861 + }
1862 +
1863 + /**
1864 + * Process all products in bulk sync directly (not via cron).
1865 + *
1866 + * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1867 + */
1868 + private function do_bulk_sync_all($category_ids = array()) {
904 1869 $total = get_option('onwebchat_wc_bulk_total', 0);
905 1870 $page = 0;
906 1871 $total_done = 0;
1872 + $considered = 0; // products fetched so far, used to enforce the hard cap
907 1873 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
908 -
1874 + $max = self::MAX_SYNC_PRODUCTS;
1875 +
909 1876 // Process all products in batches
910 1877 while (true) {
911 1878 $args = array(
912 1879 'post_type' => 'product',
@@ -915,24 +1882,41 @@
915 1882 'paged' => $page + 1,
916 1883 'orderby' => 'ID',
917 1884 'order' => 'ASC',
918 1885 );
919 -
1886 +
1887 + // Restrict to the chosen categories and their subtrees, to match the
1888 + // per-product scope check and the counts shown in the picker.
1889 + if (!empty($category_ids)) {
1890 + $args['tax_query'] = array(array(
1891 + 'taxonomy' => 'product_cat',
1892 + 'field' => 'term_id',
1893 + 'terms' => array_map('intval', $category_ids),
1894 + 'include_children' => true,
1895 + ));
1896 + }
1897 +
920 1898 $query = new WP_Query($args);
921 -
1899 +
922 1900 if (!$query->have_posts()) {
923 1901 break;
924 1902 }
925 -
926 - // Collect products in this batch
1903 +
1904 + // Collect products in this batch, honoring the hard cap.
927 1905 $products_batch = array();
1906 + $reached_cap = false;
928 1907 foreach ($query->posts as $post) {
1908 + if ($considered >= $max) {
1909 + $reached_cap = true;
1910 + break;
1911 + }
1912 + $considered++;
929 1913 $product = wc_get_product($post->ID);
930 1914 if ($product && !$this->is_product_excluded($product)) {
931 1915 $products_batch[] = $this->prepare_product_data($product);
932 1916 }
933 1917 }
934 -
1918 +
935 1919 // Send batch
936 1920 if (!empty($products_batch)) {
937 1921 $result = $this->send_product_batch($products_batch);
938 1922 if ($result && isset($result['stats'])) {
@@ -944,23 +1928,33 @@
944 1928 } else {
945 1929 // Fallback
946 1930 $total_done += count($products_batch);
947 1931 }
948 -
1932 +
949 1933 // Update progress after each batch so AJAX polling can see it
950 1934 update_option('onwebchat_wc_bulk_done', $total_done);
951 -
952 - // Wait 4 seconds before next batch
953 - sleep(4);
1935 +
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);
954 1942 }
955 -
1943 +
956 1944 wp_reset_postdata();
957 1945 $page++;
958 -
959 - // Safety check - don't loop forever
960 - if ($page > 100) {
1946 +
1947 + // Stop once the hard cap is reached.
1948 + if ($reached_cap || $considered >= $max) {
961 1949 break;
962 1950 }
1951 +
1952 + // Safety check - don't loop forever. The cap allows up to
1953 + // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1954 + if ($page > ($max / $this->batch_size) + 10) {
1955 + break;
1956 + }
963 1957 }
964 1958
965 1959 // Send completion notification to Angular dashboard with total stats
966 1960 $this->send_sync_completion_notification($all_stats);
@@ -992,9 +1986,9 @@
992 1986 $done = get_option('onwebchat_wc_bulk_done', 0);
993 1987 $total = get_option('onwebchat_wc_bulk_total', 0);
994 1988
995 1989 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
996 -
1990 +
997 1991 // Get batch of products
998 1992 $args = array(
999 1993 'post_type' => 'product',
1000 1994 'post_status' => 'publish',
@@ -1002,9 +1996,21 @@
1002 1996 'paged' => $page + 1,
1003 1997 'orderby' => 'ID',
1004 1998 'order' => 'ASC',
1005 1999 );
1006 -
2000 +
2001 + // Restrict to the saved sync scope and its subcategories, consistent
2002 + // with the synchronous bulk sync path.
2003 + $scope = $this->get_sync_scope();
2004 + if (!empty($scope)) {
2005 + $args['tax_query'] = array(array(
2006 + 'taxonomy' => 'product_cat',
2007 + 'field' => 'term_id',
2008 + 'terms' => array_map('intval', $scope),
2009 + 'include_children' => true,
2010 + ));
2011 + }
2012 +
1007 2013 $query = new WP_Query($args);
1008 2014
1009 2015 if ($query->have_posts()) {
1010 2016 // Collect all products in this batch
@@ -1162,8 +2168,199 @@
1162 2168 'total' => $total,
1163 2169 );
1164 2170 }
1165 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 +
1166 2363 /**
1167 2364 * AJAX: Manually process batch (for debugging)
1168 2365 */
1169 2366 public function ajax_manual_process_batch() {