| @@ -11,9 +11,16 @@ | ||
| 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 = 1500; | |
| 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; |
| 18 | 25 | |
| 19 | 26 | // Large-catalogue sync scope. |
| @@ -22,8 +29,12 @@ | ||
| 22 | 29 | // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded |
| 23 | 30 | // catalogue. Above the cap a category selection is required. |
| 24 | 31 | const CATEGORY_SELECT_THRESHOLD = 2000; |
| 25 | 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; | |
| 26 | 37 | |
| 27 | 38 | /** |
| 28 | 39 | * Get the API endpoint based on testing mode |
| 29 | 40 | * @return string |
| @@ -34,8 +45,19 @@ | ||
| 34 | 45 | |
| 35 | 46 | public function __construct() { |
| 36 | 47 | // Read testing mode from global constant (defined in onwebchat.php) |
| 37 | 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 | + } | |
| 38 | 60 | // Initialize settings |
| 39 | 61 | add_action('admin_init', array($this, 'register_settings')); |
| 40 | 62 | |
| 41 | 63 | // Show authentication error notice globally (not just on WooCommerce tab) |
| @@ -52,8 +74,22 @@ | ||
| 52 | 74 | // parent product's stock status from its variations and fires this action for the |
| 53 | 75 | // parent, which is the entity synced to onWebChat. |
| 54 | 76 | add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3); |
| 55 | 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 | + | |
| 56 | 92 | // Handle product deletion (both trash and permanent delete) |
| 57 | 93 | add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1); |
| 58 | 94 | add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2); |
| 59 | 95 | |
| @@ -68,8 +104,10 @@ | ||
| 68 | 104 | // catalogues and reported a false "sync failed" while products kept |
| 69 | 105 | // syncing. |
| 70 | 106 | add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync')); |
| 71 | 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')); | |
| 72 | 110 | add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret')); |
| 73 | 111 | add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status')); |
| 74 | 112 | add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce')); |
| 75 | 113 | add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch')); |
| @@ -86,10 +124,15 @@ | ||
| 86 | 124 | if (!current_user_can('manage_options')) { |
| 87 | 125 | wp_send_json_error('Insufficient permissions'); |
| 88 | 126 | } |
| 89 | 127 | |
| 90 | - $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; | |
| 91 | - $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']) : ''; | |
| 92 | 135 | |
| 93 | 136 | if (empty($email) || empty($password)) { |
| 94 | 137 | wp_send_json_error('Email and password are required'); |
| 95 | 138 | } |
| @@ -188,8 +231,86 @@ | ||
| 188 | 231 | $this->send_product_upsert($product_data, $product_id); |
| 189 | 232 | } |
| 190 | 233 | |
| 191 | 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 | + /** | |
| 192 | 313 | * Hook: product stock STATUS changed (in stock / out of stock / on backorder). |
| 193 | 314 | * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder" |
| 194 | 315 | * is treated as available since the store still accepts orders. |
| 195 | 316 | * |
| @@ -275,12 +396,22 @@ | ||
| 275 | 396 | } |
| 276 | 397 | |
| 277 | 398 | /** |
| 278 | 399 | * Get the saved sync scope as an array of product_cat term IDs. |
| 279 | - * 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. | |
| 280 | 403 | */ |
| 281 | 404 | private function get_sync_scope() { |
| 282 | - $raw = (string) get_option('onwebchat_wc_sync_categories', ''); | |
| 405 | + return $this->parse_id_list(get_option('onwebchat_wc_sync_categories', '')); | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** | |
| 409 | + * Comma-separated ids (as stored in options and posted by the picker) to a | |
| 410 | + * de-duplicated array of positive ints. | |
| 411 | + */ | |
| 412 | + private function parse_id_list($raw) { | |
| 413 | + $raw = (string) $raw; | |
| 283 | 414 | if ($raw === '') { |
| 284 | 415 | return array(); |
| 285 | 416 | } |
| 286 | 417 | |
| @@ -295,11 +426,57 @@ | ||
| 295 | 426 | return array_values($ids); |
| 296 | 427 | } |
| 297 | 428 | |
| 298 | 429 | /** |
| 299 | - * 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. | |
| 300 | 436 | */ |
| 301 | - 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) { | |
| 302 | 479 | $clean = array(); |
| 303 | 480 | foreach ((array) $category_ids as $id) { |
| 304 | 481 | $id = (int) $id; |
| 305 | 482 | if ($id > 0) { |
| @@ -307,14 +484,16 @@ | ||
| 307 | 484 | } |
| 308 | 485 | } |
| 309 | 486 | |
| 310 | 487 | update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean))); |
| 488 | + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0'); | |
| 311 | 489 | } |
| 312 | 490 | |
| 313 | 491 | /** |
| 314 | 492 | * Is the product within the current sync scope? |
| 315 | - * No scope set means everything is in scope. The picker only offers | |
| 316 | - * top-level categories, and selecting one covers its whole subtree, so a | |
| 493 | + * With no categories saved it comes down to what the empty list means: the | |
| 494 | + * whole catalogue (any product qualifies) or nothing at all. The picker offers the whole | |
| 495 | + * category tree and selecting a category covers its whole subtree, so a | |
| 317 | 496 | * product is in scope when any of its categories is a scoped category OR a |
| 318 | 497 | * descendant of one. This mirrors the bulk sync tax query |
| 319 | 498 | * (include_children = true). |
| 320 | 499 | */ |
| @@ -320,9 +499,9 @@ | ||
| 320 | 499 | */ |
| 321 | 500 | private function product_in_scope($product) { |
| 322 | 501 | $scope = $this->get_sync_scope(); |
| 323 | 502 | if (empty($scope)) { |
| 324 | - return true; | |
| 503 | + return $this->is_scope_all(); | |
| 325 | 504 | } |
| 326 | 505 | |
| 327 | 506 | foreach ($product->get_category_ids() as $cat_id) { |
| 328 | 507 | $cat_id = (int) $cat_id; |
| @@ -340,12 +519,113 @@ | ||
| 340 | 519 | return false; |
| 341 | 520 | } |
| 342 | 521 | |
| 343 | 522 | /** |
| 344 | - * Count published products within the given scope (empty = whole catalogue). | |
| 523 | + * Is this category already covered by the given scope? A scope covers a | |
| 524 | + * category when it holds the category itself or any of its ancestors, | |
| 525 | + * because selecting a category always includes its whole subtree. | |
| 526 | + */ | |
| 527 | + private function scope_covers($scope, $category_id) { | |
| 528 | + $category_id = (int) $category_id; | |
| 529 | + if (in_array($category_id, $scope, true)) { | |
| 530 | + return true; | |
| 531 | + } | |
| 532 | + | |
| 533 | + foreach (get_ancestors($category_id, 'product_cat', 'taxonomy') as $ancestor_id) { | |
| 534 | + if (in_array((int) $ancestor_id, $scope, true)) { | |
| 535 | + return true; | |
| 536 | + } | |
| 537 | + } | |
| 538 | + | |
| 539 | + return false; | |
| 540 | + } | |
| 541 | + | |
| 542 | + /** | |
| 543 | + * Which of the submitted categories are NOT yet covered by the saved scope. | |
| 544 | + * These are the only ones a sync has to push: everything already in scope is | |
| 545 | + * in the training data already. | |
| 546 | + */ | |
| 547 | + private function categories_added($submitted, $saved_scope) { | |
| 548 | + if (empty($saved_scope)) { | |
| 549 | + return array(); // whole catalogue already in scope, nothing is new | |
| 550 | + } | |
| 551 | + | |
| 552 | + $added = array(); | |
| 553 | + foreach ($submitted as $category_id) { | |
| 554 | + $category_id = (int) $category_id; | |
| 555 | + if ($category_id > 0 && !$this->scope_covers($saved_scope, $category_id)) { | |
| 556 | + $added[$category_id] = $category_id; | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + return array_values($added); | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * Which of the saved categories the merchant just unticked. Used to offer an | |
| 565 | + * explicit removal: unticking alone never drops anything (see | |
| 566 | + * ajax_scope_remove_start), because the saved scope only grows on sync. | |
| 567 | + */ | |
| 568 | + private function categories_removed($submitted, $saved_scope) { | |
| 569 | + if (empty($saved_scope)) { | |
| 570 | + return array(); | |
| 571 | + } | |
| 572 | + | |
| 573 | + $removed = array(); | |
| 574 | + foreach ($saved_scope as $category_id) { | |
| 575 | + $category_id = (int) $category_id; | |
| 576 | + if ($category_id > 0 && !$this->scope_covers($submitted, $category_id)) { | |
| 577 | + $removed[$category_id] = $category_id; | |
| 578 | + } | |
| 579 | + } | |
| 580 | + | |
| 581 | + return array_values($removed); | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** | |
| 585 | + * tax_query for "products inside $terms but not inside $exclude", both | |
| 586 | + * including their subtrees. Empty $terms means the whole catalogue. | |
| 587 | + * Returns null when no restriction applies at all. | |
| 588 | + */ | |
| 589 | + private function build_scope_tax_query($terms, $exclude = array()) { | |
| 590 | + $clauses = array(); | |
| 591 | + | |
| 592 | + if (!empty($terms)) { | |
| 593 | + $clauses[] = array( | |
| 594 | + 'taxonomy' => 'product_cat', | |
| 595 | + 'field' => 'term_id', | |
| 596 | + 'terms' => array_map('intval', $terms), | |
| 597 | + 'include_children' => true, | |
| 598 | + ); | |
| 599 | + } | |
| 600 | + | |
| 601 | + if (!empty($exclude)) { | |
| 602 | + $clauses[] = array( | |
| 603 | + 'taxonomy' => 'product_cat', | |
| 604 | + 'field' => 'term_id', | |
| 605 | + 'terms' => array_map('intval', $exclude), | |
| 606 | + 'include_children' => true, | |
| 607 | + 'operator' => 'NOT IN', | |
| 608 | + ); | |
| 609 | + } | |
| 610 | + | |
| 611 | + if (empty($clauses)) { | |
| 612 | + return null; | |
| 613 | + } | |
| 614 | + | |
| 615 | + if (count($clauses) > 1) { | |
| 616 | + $clauses['relation'] = 'AND'; | |
| 617 | + } | |
| 618 | + | |
| 619 | + return $clauses; | |
| 620 | + } | |
| 621 | + | |
| 622 | + /** | |
| 623 | + * Count published products within the given scope (empty = whole catalogue), | |
| 624 | + * optionally excluding everything inside $exclude and its subtrees. | |
| 345 | 625 | * Uses found_posts so we do not load every ID into memory. |
| 346 | 626 | */ |
| 347 | - private function count_products_in_scope($category_ids) { | |
| 627 | + private function count_products_in_scope($category_ids, $exclude = array()) { | |
| 348 | 628 | $args = array( |
| 349 | 629 | 'post_type' => 'product', |
| 350 | 630 | 'post_status' => 'publish', |
| 351 | 631 | 'posts_per_page' => 1, |
| @@ -352,15 +632,11 @@ | ||
| 352 | 632 | 'fields' => 'ids', |
| 353 | 633 | 'no_found_rows' => false, |
| 354 | 634 | ); |
| 355 | 635 | |
| 356 | - if (!empty($category_ids)) { | |
| 357 | - $args['tax_query'] = array(array( | |
| 358 | - 'taxonomy' => 'product_cat', | |
| 359 | - 'field' => 'term_id', | |
| 360 | - 'terms' => array_map('intval', $category_ids), | |
| 361 | - 'include_children' => true, | |
| 362 | - )); | |
| 636 | + $tax_query = $this->build_scope_tax_query($category_ids, $exclude); | |
| 637 | + if ($tax_query !== null) { | |
| 638 | + $args['tax_query'] = $tax_query; | |
| 363 | 639 | } |
| 364 | 640 | |
| 365 | 641 | $query = new WP_Query($args); |
| 366 | 642 | return (int) $query->found_posts; |
| @@ -366,19 +642,69 @@ | ||
| 366 | 642 | return (int) $query->found_posts; |
| 367 | 643 | } |
| 368 | 644 | |
| 369 | 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 ("&quot;" for a quote), where a single pass still | |
| 669 | + * leaves """ 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 < 100 and > 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 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 | + /** | |
| 370 | 691 | * Prepare product data for sync |
| 371 | 692 | */ |
| 372 | 693 | private function prepare_product_data($product) { |
| 373 | - $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full'); | |
| 694 | + $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full'); | |
| 374 | 695 | |
| 375 | 696 | // Get description based on sync mode |
| 376 | 697 | $description = ''; |
| 377 | - $short_description = strip_tags($product->get_short_description()); | |
| 698 | + $short_description = $this->decode_entities(strip_tags($product->get_short_description())); | |
| 378 | 699 | |
| 379 | 700 | if ($sync_mode === 'short_only') { |
| 380 | 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); | |
| 381 | 707 | } else if ($sync_mode === 'short_fallback_full') { |
| 382 | 708 | if (!empty($short_description)) { |
| 383 | 709 | $description = $short_description; |
| 384 | 710 | } else { |
| @@ -385,9 +711,9 @@ | ||
| 385 | 711 | // Fallback to the first 200 words of the full description. |
| 386 | 712 | // Split with a Unicode-aware regex: str_word_count() does not |
| 387 | 713 | // recognize non-latin (e.g. Greek) words, so the old word cut |
| 388 | 714 | // was unreliable on multibyte text. |
| 389 | - $full_description = strip_tags($product->get_description()); | |
| 715 | + $full_description = $this->decode_entities(strip_tags($product->get_description())); | |
| 390 | 716 | $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY); |
| 391 | 717 | |
| 392 | 718 | if (is_array($words) && count($words) > 200) { |
| 393 | 719 | $description = implode(' ', array_slice($words, 0, 200)) . '...'; |
| @@ -398,10 +724,13 @@ | ||
| 398 | 724 | } |
| 399 | 725 | |
| 400 | 726 | // Enforce max length by characters, not bytes: a byte-based substr() |
| 401 | 727 | // can cut a multibyte UTF-8 character (e.g. Greek text) in half. |
| 402 | - if (mb_strlen($description, 'UTF-8') > $this->max_description_length) { | |
| 403 | - $description = mb_substr($description, 0, $this->max_description_length, 'UTF-8') . '...'; | |
| 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') . '...'; | |
| 404 | 733 | } |
| 405 | 734 | |
| 406 | 735 | $sku = $product->get_sku(); |
| 407 | 736 | $categories = $this->get_product_category_names($product); |
| @@ -410,9 +739,11 @@ | ||
| 410 | 739 | // Structured fields. The server rebuilds the embedding text from these, |
| 411 | 740 | // so there is no need to send a pre-formatted "text" blob. |
| 412 | 741 | $data = array( |
| 413 | 742 | 'product_id' => $product->get_id(), |
| 414 | - '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()), | |
| 415 | 746 | 'short_description' => trim($description), |
| 416 | 747 | 'url' => $url, |
| 417 | 748 | 'sku' => $sku, |
| 418 | 749 | 'categories' => $categories, |
| @@ -418,23 +749,39 @@ | ||
| 418 | 749 | 'categories' => $categories, |
| 419 | 750 | 'currency' => get_woocommerce_currency(), |
| 420 | 751 | ); |
| 421 | 752 | |
| 422 | - // Price (always sent). Variable products carry a min/max range. | |
| 423 | - $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); | |
| 424 | 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 | + | |
| 425 | 771 | if ($product->is_type('variable')) { |
| 426 | - // Raw min/max prices, consistent with get_price() used for simple products. | |
| 427 | - $data['price_min'] = $product->get_variation_price('min', false); | |
| 428 | - $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); | |
| 429 | 776 | } else { |
| 430 | 777 | $regular_price = $product->get_regular_price(); |
| 431 | 778 | if ($regular_price !== '') { |
| 432 | - $data['regular_price'] = $regular_price; | |
| 779 | + $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price)); | |
| 433 | 780 | } |
| 434 | 781 | // Only advertise a sale price while the sale is actually active. |
| 435 | - if ($product->is_on_sale()) { | |
| 436 | - $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())); | |
| 437 | 784 | } |
| 438 | 785 | } |
| 439 | 786 | |
| 440 | 787 | // Stock availability |
| @@ -470,10 +817,79 @@ | ||
| 470 | 817 | $data['rating'] = $rating; |
| 471 | 818 | $data['review_count'] = (int) $product->get_review_count(); |
| 472 | 819 | } |
| 473 | 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 | + | |
| 474 | 826 | return $data; |
| 475 | 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 | + } | |
| 476 | 892 | |
| 477 | 893 | /** |
| 478 | 894 | * Get product category names |
| 479 | 895 | */ |
| @@ -483,9 +899,9 @@ | ||
| 483 | 899 | |
| 484 | 900 | foreach ($category_ids as $cat_id) { |
| 485 | 901 | $term = get_term($cat_id, 'product_cat'); |
| 486 | 902 | if ($term && !is_wp_error($term)) { |
| 487 | - $categories[] = $term->name; | |
| 903 | + $categories[] = $this->decode_entities($term->name); | |
| 488 | 904 | } |
| 489 | 905 | } |
| 490 | 906 | |
| 491 | 907 | return $categories; |
| @@ -504,9 +920,9 @@ | ||
| 504 | 920 | } |
| 505 | 921 | |
| 506 | 922 | $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); |
| 507 | 923 | if (!is_wp_error($terms) && !empty($terms)) { |
| 508 | - return $terms[0]; | |
| 924 | + return $this->decode_entities($terms[0]); | |
| 509 | 925 | } |
| 510 | 926 | } |
| 511 | 927 | |
| 512 | 928 | return ''; |
| @@ -554,9 +970,9 @@ | ||
| 554 | 970 | if (is_wp_error($tags) || empty($tags)) { |
| 555 | 971 | return array(); |
| 556 | 972 | } |
| 557 | 973 | |
| 558 | - return $tags; | |
| 974 | + return array_map(array($this, 'decode_entities'), $tags); | |
| 559 | 975 | } |
| 560 | 976 | |
| 561 | 977 | /** |
| 562 | 978 | * Send batch of products to API (optimized) |
| @@ -616,9 +1032,12 @@ | ||
| 616 | 1032 | |
| 617 | 1033 | // Send request |
| 618 | 1034 | $request_args = array( |
| 619 | 1035 | 'method' => 'POST', |
| 620 | - '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, | |
| 621 | 1040 | 'headers' => array( |
| 622 | 1041 | 'Content-Type' => 'application/json', |
| 623 | 1042 | 'X-OWC-SiteId' => $chatIdKey, |
| 624 | 1043 | 'X-OWC-Timestamp' => $timestamp, |
| @@ -806,8 +1225,44 @@ | ||
| 806 | 1225 | $this->send_authenticated_request($endpoint, $payload, $product_id); |
| 807 | 1226 | } |
| 808 | 1227 | |
| 809 | 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 | + /** | |
| 810 | 1265 | * Send a lightweight availability update to onWebChat (no re-embed on the server). |
| 811 | 1266 | */ |
| 812 | 1267 | private function send_product_stock($product_id, $in_stock) { |
| 813 | 1268 | $chatId = get_option('onwebchat_plugin_option'); |
| @@ -1060,9 +1515,13 @@ | ||
| 1060 | 1515 | * AJAX: Start bulk sync |
| 1061 | 1516 | */ |
| 1062 | 1517 | public function ajax_sync_existing_products() { |
| 1063 | 1518 | check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); |
| 1064 | - | |
| 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 | + | |
| 1065 | 1524 | if (!current_user_can('manage_options')) { |
| 1066 | 1525 | wp_send_json_error('Insufficient permissions'); |
| 1067 | 1526 | } |
| 1068 | 1527 | |
| @@ -1072,15 +1531,14 @@ | ||
| 1072 | 1531 | } |
| 1073 | 1532 | |
| 1074 | 1533 | // Rate limiting: prevent syncing more than once every 5 minutes |
| 1075 | 1534 | $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0); |
| 1076 | - $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 | |
| 1077 | 1536 | $time_since_last_sync = time() - $last_sync_time; |
| 1078 | 1537 | |
| 1079 | 1538 | if ($time_since_last_sync < $cooldown_period) { |
| 1080 | - $wait_time = $cooldown_period - $time_since_last_sync; | |
| 1081 | - $minutes = ceil($wait_time / 60); | |
| 1082 | - 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.'); | |
| 1083 | 1541 | } |
| 1084 | 1542 | |
| 1085 | 1543 | // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. |
| 1086 | 1544 | $category_ids = array(); |
| @@ -1103,13 +1561,55 @@ | ||
| 1103 | 1561 | number_format_i18n(self::MAX_SYNC_PRODUCTS) |
| 1104 | 1562 | )); |
| 1105 | 1563 | } |
| 1106 | 1564 | |
| 1107 | - // Remember the merchant's choice so ongoing auto-sync stays within it: | |
| 1108 | - // selected categories become the sync scope; an unrestricted "sync all" | |
| 1109 | - // clears the scope (the whole catalogue is in scope again). | |
| 1110 | - $this->save_sync_scope($category_ids); | |
| 1565 | + // The saved scope only ever GROWS on a sync. Ticking more categories adds | |
| 1566 | + // them to what the bot knows; unticking never silently drops products, | |
| 1567 | + // removal is its own explicit, confirmed action (ajax_scope_remove_*). | |
| 1568 | + // An empty selection means the whole catalogue, which covers everything, | |
| 1569 | + // so it clears the scope. | |
| 1570 | + // | |
| 1571 | + // $run_terms / $run_exclude are what THIS run pushes, which is not the | |
| 1572 | + // same as the scope: when categories are added to an existing scope only | |
| 1573 | + // the added ones are pushed, so adding one subcategory to a 10,000 | |
| 1574 | + // product scope no longer re-sends all 10,000. | |
| 1575 | + $saved_scope = $this->get_sync_scope(); | |
| 1111 | 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 | + | |
| 1112 | 1612 | // Store the current sync start time |
| 1113 | 1613 | update_option('onwebchat_wc_last_sync_start', time()); |
| 1114 | 1614 | |
| 1115 | 1615 | // Reset bulk sync progress |
| @@ -1115,10 +1615,10 @@ | ||
| 1115 | 1615 | // Reset bulk sync progress |
| 1116 | 1616 | update_option('onwebchat_wc_bulk_page', 0); |
| 1117 | 1617 | update_option('onwebchat_wc_bulk_done', 0); |
| 1118 | 1618 | |
| 1119 | - // Count total products within scope, capped at the hard limit. | |
| 1120 | - $total = $this->count_products_in_scope($category_ids); | |
| 1619 | + // Count the products THIS run will push, capped at the hard limit. | |
| 1620 | + $total = $this->count_products_in_scope($run_terms, $run_exclude); | |
| 1121 | 1621 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| 1122 | 1622 | $total = self::MAX_SYNC_PRODUCTS; |
| 1123 | 1623 | } |
| 1124 | 1624 | |
| @@ -1180,10 +1680,11 @@ | ||
| 1180 | 1680 | } |
| 1181 | 1681 | |
| 1182 | 1682 | // Remember the merchant's choice so ongoing auto-sync stays within it: |
| 1183 | 1683 | // selected categories become the sync scope; an unrestricted "sync all" |
| 1184 | - // clears the scope (the whole catalogue is in scope again). | |
| 1185 | - $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(); | |
| 1186 | 1687 | |
| 1187 | 1688 | // Count total products within scope, capped at the hard limit. |
| 1188 | 1689 | $total = $this->count_products_in_scope($category_ids); |
| 1189 | 1690 | if ($total > self::MAX_SYNC_PRODUCTS) { |
| @@ -1200,9 +1701,9 @@ | ||
| 1200 | 1701 | // sync, so a 0-product start (empty scope) can't leave the store stuck at |
| 1201 | 1702 | // "a sync is already in progress". |
| 1202 | 1703 | update_option('onwebchat_wc_bulk_in_progress', $total > 0); |
| 1203 | 1704 | |
| 1204 | - wp_send_json_success(array('total' => $total)); | |
| 1705 | + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); | |
| 1205 | 1706 | } |
| 1206 | 1707 | |
| 1207 | 1708 | /** |
| 1208 | 1709 | * AJAX: process the next page of the in-progress bulk sync and report progress. |
| @@ -1209,8 +1710,12 @@ | ||
| 1209 | 1710 | */ |
| 1210 | 1711 | public function ajax_sync_next_batch() { |
| 1211 | 1712 | check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); |
| 1212 | 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 | + | |
| 1213 | 1718 | if (!current_user_can('manage_options')) { |
| 1214 | 1719 | wp_send_json_error('Insufficient permissions'); |
| 1215 | 1720 | } |
| 1216 | 1721 | |
| @@ -1255,18 +1760,20 @@ | ||
| 1255 | 1760 | 'orderby' => 'ID', |
| 1256 | 1761 | 'order' => 'ASC', |
| 1257 | 1762 | ); |
| 1258 | 1763 | |
| 1259 | - // Restrict to the saved sync scope and its subcategories, consistent | |
| 1260 | - // with the per-product scope check and the counts shown in the picker. | |
| 1261 | - $scope = $this->get_sync_scope(); | |
| 1262 | - if (!empty($scope)) { | |
| 1263 | - $args['tax_query'] = array(array( | |
| 1264 | - 'taxonomy' => 'product_cat', | |
| 1265 | - 'field' => 'term_id', | |
| 1266 | - 'terms' => array_map('intval', $scope), | |
| 1267 | - 'include_children' => true, | |
| 1268 | - )); | |
| 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; | |
| 1269 | 1776 | } |
| 1270 | 1777 | |
| 1271 | 1778 | $query = new WP_Query($args); |
| 1272 | 1779 | $complete = false; |
| @@ -1376,10 +1883,10 @@ | ||
| 1376 | 1883 | 'orderby' => 'ID', |
| 1377 | 1884 | 'order' => 'ASC', |
| 1378 | 1885 | ); |
| 1379 | 1886 | |
| 1380 | - // Restrict to the chosen top-level categories and their subtrees, to | |
| 1381 | - // match the per-product scope check and the counts shown in the picker. | |
| 1887 | + // Restrict to the chosen categories and their subtrees, to match the | |
| 1888 | + // per-product scope check and the counts shown in the picker. | |
| 1382 | 1889 | if (!empty($category_ids)) { |
| 1383 | 1890 | $args['tax_query'] = array(array( |
| 1384 | 1891 | 'taxonomy' => 'product_cat', |
| 1385 | 1892 | 'field' => 'term_id', |
| @@ -1425,10 +1932,14 @@ | ||
| 1425 | 1932 | |
| 1426 | 1933 | // Update progress after each batch so AJAX polling can see it |
| 1427 | 1934 | update_option('onwebchat_wc_bulk_done', $total_done); |
| 1428 | 1935 | |
| 1429 | - // Wait 4 seconds before next batch | |
| 1430 | - sleep(4); | |
| 1936 | + // Breathe between batches so a long run cannot walk into the | |
| 1937 | + // server's product-sync rate limit (150 requests per 5 minutes | |
| 1938 | + // per IP). One second is plenty: each batch already costs a | |
| 1939 | + // synchronous HTTP call of its own, so the real cycle time is | |
| 1940 | + // seconds even when nothing needs summarizing. | |
| 1941 | + sleep(1); | |
| 1431 | 1942 | } |
| 1432 | 1943 | |
| 1433 | 1944 | wp_reset_postdata(); |
| 1434 | 1945 | $page++; |
| @@ -1657,8 +2168,199 @@ | ||
| 1657 | 2168 | 'total' => $total, |
| 1658 | 2169 | ); |
| 1659 | 2170 | } |
| 1660 | 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 | + | |
| 1661 | 2363 | /** |
| 1662 | 2364 | * AJAX: Manually process batch (for debugging) |
| 1663 | 2365 | */ |
| 1664 | 2366 | public function ajax_manual_process_batch() { |