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 | admin/tabs/woocommerce.php +474 -72 3.9.13.10.0 View file →
@@ -26,13 +26,26 @@
26 26 'total' => get_option('onwebchat_wc_bulk_total', 0),
27 27 'last_sync_start' => get_option('onwebchat_wc_last_sync_start', 0),
28 28 );
29 29
30 + // What the AI training data currently covers (point 4 of the scope model):
31 + // the saved scope IS what was synced, because a sync only ever adds to it.
32 + global $onwebchat_wc_sync;
33 + $owc_scope_summary = (isset($onwebchat_wc_sync) && is_object($onwebchat_wc_sync) && method_exists($onwebchat_wc_sync, 'get_scope_summary'))
34 + ? $onwebchat_wc_sync->get_scope_summary()
35 + : null;
36 +
30 37 // Calculate cooldown status
31 - $cooldown_period = 5 * 60; // 5 minutes // 5min // 5 * 60 //also in the file woocommerce-sync.php
38 + // Short on purpose: it only guards against double-clicking the sync button.
39 + // Now that the selection is cumulative, syncing again straight after a run is
40 + // a normal thing to do (you just ticked another subcategory), so a long block
41 + // gets in the way. The button stays VISIBLE and disabled with a live
42 + // countdown, instead of disappearing as it used to.
43 + // Also in includes/woocommerce-sync.php.
44 + $cooldown_period = 30; // seconds
32 45 $time_since_last_sync = time() - $sync_status['last_sync_start'];
33 46 $is_in_cooldown = ($time_since_last_sync < $cooldown_period) && $sync_status['last_sync_start'] > 0;
34 - $cooldown_remaining = $is_in_cooldown ? ceil(($cooldown_period - $time_since_last_sync) / 60) : 0;
47 + $cooldown_remaining = $is_in_cooldown ? max(1, $cooldown_period - $time_since_last_sync) : 0;
35 48
36 49 ?>
37 50
38 51 <?php if (isset($_GET['wc_saved']) && $_GET['wc_saved'] == '1'): ?>
@@ -56,9 +69,9 @@
56 69 <p><strong>Solution:</strong> Please enter your onWebChat account credentials again in the "Connect WooCommerce Sync" section below and click "Connect WooCommerce Sync" button to reconnect.</p>
57 70 </div>
58 71 <?php endif; ?>
59 72
60 - <h2>WooCommerce Product Sync</h2>
73 + <h2>Product Sync for the AI Chatbot</h2>
61 74 <p>
62 75 Let your AI chatbot understand your WooCommerce products.
63 76 When enabled, products are automatically synced and kept up to date, allowing the AI to answer product-related questions accurately.
64 77 </p>
@@ -352,69 +365,146 @@
352 365 if (is_wp_error($owc_categories)) {
353 366 $owc_categories = array();
354 367 }
355 368
356 - // Build the category tree. The picker only offers top-level categories, and
357 - // selecting one covers its whole subtree, so each row shows a distinct product
358 - // count spanning the category and all of its descendants.
359 - $owc_children = array(); // parent_id => array of term objects
369 + // Build the whole category tree for the picker. Every category is offered
370 + // (subcategories indented under their parent, searchable by name or path),
371 + // and selecting one covers its whole subtree, so each row shows a distinct
372 + // product count spanning the category and all of its descendants.
373 + $owc_term_by_id = array(); // term_id => term object
360 374 foreach ($owc_categories as $owc_term) {
361 - $owc_children[(int) $owc_term->parent][] = $owc_term;
375 + $owc_term_by_id[(int) $owc_term->term_id] = $owc_term;
362 376 }
363 377
364 - // Distinct published products in a category and its whole subtree. Counting
365 - // distinct posts (WP_Query found_posts with include_children) avoids the
366 - // over-count you get from summing per-term counts when a product is assigned
367 - // to both a parent and a child category.
368 - if (!function_exists('owc_subtree_product_count')) {
369 - function owc_subtree_product_count($term_id) {
370 - $query = new WP_Query(array(
371 - 'post_type' => 'product',
372 - 'post_status' => 'publish',
373 - 'posts_per_page' => 1,
374 - 'fields' => 'ids',
375 - 'no_found_rows' => false,
376 - 'tax_query' => array(array(
377 - 'taxonomy' => 'product_cat',
378 - 'field' => 'term_id',
379 - 'terms' => (int) $term_id,
380 - 'include_children' => true,
381 - )),
382 - ));
383 - return (int) $query->found_posts;
378 + $owc_children = array(); // parent_id => array of term objects (name order, from get_terms)
379 + $owc_parent_of = array(); // term_id => parent term_id (0 for roots and orphans)
380 + foreach ($owc_categories as $owc_term) {
381 + $owc_pid = (int) $owc_term->parent;
382 + if (!isset($owc_term_by_id[$owc_pid])) {
383 + $owc_pid = 0; // orphan (parent missing): treat as a root
384 384 }
385 + $owc_parent_of[(int) $owc_term->term_id] = $owc_pid;
386 + $owc_children[$owc_pid][] = $owc_term;
385 387 }
386 388
387 - // Renderable top-level categories (parent 0) that actually cover products.
388 - $owc_top_rows = array();
389 - $owc_top_level = isset($owc_children[0]) ? $owc_children[0] : array();
390 - foreach ($owc_top_level as $owc_term) {
391 - $owc_count = owc_subtree_product_count((int) $owc_term->term_id);
392 - if ($owc_count > 0) {
393 - $owc_top_rows[] = array('term' => $owc_term, 'count' => $owc_count);
389 + // Distinct published products per category subtree, computed in ONE pass:
390 + // read every (product, category) pair of the published products and, for
391 + // each product, credit the category and each of its ancestors once. Counting
392 + // per product avoids the over-count you get from summing per-term counts when
393 + // a product sits in both a parent and a child category, and one query keeps
394 + // the page fast even with more than a thousand categories (a WP_Query per
395 + // category would not).
396 + $owc_counts = array(); // term_id => distinct published products in the subtree
397 + if (!empty($owc_categories)) {
398 + global $wpdb;
399 + $owc_pairs = $wpdb->get_results($wpdb->prepare(
400 + "SELECT tr.object_id, tt.term_id
401 + FROM {$wpdb->term_relationships} tr
402 + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
403 + INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
404 + WHERE tt.taxonomy = %s AND p.post_type = %s AND p.post_status = %s
405 + ORDER BY tr.object_id",
406 + 'product_cat', 'product', 'publish'
407 + ), ARRAY_N);
408 + if (!is_array($owc_pairs)) {
409 + $owc_pairs = array();
394 410 }
411 +
412 + $owc_current_product = 0;
413 + $owc_credited = array(); // categories already credited for the current product
414 + foreach ($owc_pairs as $owc_pair) {
415 + $owc_product_id = (int) $owc_pair[0];
416 + if ($owc_product_id !== $owc_current_product) {
417 + $owc_current_product = $owc_product_id;
418 + $owc_credited = array();
419 + }
420 + // Walk up to the root; stop at a category already credited for this
421 + // product (its ancestors were credited on that walk too).
422 + $owc_walk = (int) $owc_pair[1];
423 + $owc_hops = 0;
424 + while ($owc_walk > 0 && isset($owc_parent_of[$owc_walk]) && !isset($owc_credited[$owc_walk]) && $owc_hops++ < 100) {
425 + $owc_credited[$owc_walk] = true;
426 + $owc_counts[$owc_walk] = isset($owc_counts[$owc_walk]) ? $owc_counts[$owc_walk] + 1 : 1;
427 + $owc_walk = $owc_parent_of[$owc_walk];
428 + }
429 + }
430 + unset($owc_pairs, $owc_credited);
395 431 }
432 +
433 + // Rows in tree order (depth first, siblings by name), skipping categories
434 + // whose subtree holds no published product. Anything not reached from the
435 + // roots (e.g. a parent loop) is appended as a root so nothing is hidden.
436 + $owc_rows = array(); // each: term, name, count, level, parents (path prefix)
437 + $owc_visited = array();
438 + for ($owc_pass = 0; $owc_pass < 2; $owc_pass++) {
439 + if ($owc_pass === 0) {
440 + $owc_roots = isset($owc_children[0]) ? $owc_children[0] : array();
441 + } else {
442 + $owc_roots = array();
443 + foreach ($owc_categories as $owc_term) {
444 + if (!isset($owc_visited[(int) $owc_term->term_id])) {
445 + $owc_roots[] = $owc_term;
446 + }
447 + }
448 + }
449 + $owc_stack = array();
450 + foreach (array_reverse($owc_roots) as $owc_term) {
451 + $owc_stack[] = array($owc_term, 0, '');
452 + }
453 + while (!empty($owc_stack)) {
454 + list($owc_term, $owc_level, $owc_parents) = array_pop($owc_stack);
455 + $owc_id = (int) $owc_term->term_id;
456 + if (isset($owc_visited[$owc_id])) {
457 + continue;
458 + }
459 + $owc_visited[$owc_id] = true;
460 + $owc_count = isset($owc_counts[$owc_id]) ? $owc_counts[$owc_id] : 0;
461 + if ($owc_count === 0) {
462 + continue; // empty subtree (descendants are counted into their ancestors)
463 + }
464 + $owc_name = html_entity_decode((string) $owc_term->name, ENT_QUOTES, 'UTF-8');
465 + $owc_rows[] = array(
466 + 'term' => $owc_term,
467 + 'name' => $owc_name,
468 + 'count' => $owc_count,
469 + 'level' => $owc_level,
470 + 'parents' => $owc_parents,
471 + );
472 + if (!empty($owc_children[$owc_id])) {
473 + foreach (array_reverse($owc_children[$owc_id]) as $owc_child) {
474 + $owc_stack[] = array($owc_child, $owc_level + 1, $owc_parents . $owc_name . ' > ');
475 + }
476 + }
477 + }
478 + }
479 + unset($owc_visited, $owc_stack, $owc_term_by_id, $owc_children);
396 480 ?>
397 481
398 - <?php if ($owc_needs_selection && !empty($owc_top_rows)): ?>
482 + <?php if ($owc_needs_selection && !empty($owc_rows)): ?>
483 + <style>
484 + #owc-category-list .owc-category-row { display: block; padding-top: 3px; padding-bottom: 3px; }
485 + #owc-category-list .owc-cat-path { display: none; color: #999; }
486 + #owc-category-list.owc-filtering .owc-cat-path { display: inline; }
487 + #owc-category-list .owc-category-implied { color: #8c8f94; }
488 + </style>
399 489 <div id="owc-category-picker" style="background: #f9f9f9; padding: 20px; border-radius: 8px; max-width: 800px; margin-top: 15px;">
400 490 <p style="margin: 0 0 10px 0;"><strong>Choose what to sync</strong></p>
401 491 <p id="owc-category-intro" style="margin: 0 0 12px 0; color: #555;">
402 492 <?php if ($owc_over_max): ?>
403 493 <?php printf(
404 - 'Your store has %s products. onWebChat can sync up to %s, so please tick the categories you want to sync. Selecting a category includes all of its subcategories.',
494 + 'Your store has %s products. onWebChat can sync up to %s, so please tick the categories you want to sync. Selecting a category includes all of its subcategories. Type in the search box to find any category or subcategory.',
405 495 esc_html(number_format_i18n($owc_published_count)),
406 496 esc_html(number_format_i18n($owc_max))
407 497 ); ?>
408 498 <?php else: ?>
409 499 <?php printf(
410 - 'Your store has %s products. Leave every category unticked to sync the whole catalogue, including products without a category, or tick specific categories to sync only those. Selecting a category includes all of its subcategories.',
500 + 'Your store has %s products. Leave every category unticked to sync the whole catalogue, including products without a category, or tick specific categories to sync only those. Selecting a category includes all of its subcategories. Type in the search box to find any category or subcategory.',
411 501 esc_html(number_format_i18n($owc_published_count))
412 502 ); ?>
413 503 <?php endif; ?>
414 504 </p>
415 505 <p style="margin: 0 0 10px 0;">
416 - <input type="text" id="owc-category-search" placeholder="Search categories..." class="regular-text" style="max-width: 360px;">
506 + <input type="text" id="owc-category-search" placeholder="Search categories and subcategories..." class="regular-text" style="max-width: 360px;">
417 507 </p>
418 508 <p style="margin: 0 0 8px 0;">
419 509 <label><input type="checkbox" id="owc-category-all"> <strong>Select all</strong></label>
420 510 <span id="owc-category-selected" style="margin-left: 12px; color: #666;"></span>
@@ -419,16 +509,23 @@
419 509 <label><input type="checkbox" id="owc-category-all"> <strong>Select all</strong></label>
420 510 <span id="owc-category-selected" style="margin-left: 12px; color: #666;"></span>
421 511 </p>
422 512 <div id="owc-category-list" style="max-height: 280px; overflow: auto; border: 1px solid #ddd; border-radius: 4px; background: #fff; padding: 8px 12px;">
423 - <?php foreach ($owc_top_rows as $owc_row): $owc_term = $owc_row['term']; ?>
424 - <label class="owc-category-row" data-name="<?php echo esc_attr(strtolower($owc_term->name)); ?>" style="display: block; padding: 3px 0;">
513 + <?php foreach ($owc_rows as $owc_row): $owc_term = $owc_row['term']; $owc_indent = (int) $owc_row['level'] * 18; ?>
514 + <label class="owc-category-row" data-path="<?php echo esc_attr($owc_row['parents'] . $owc_row['name']); ?>" data-parent="<?php echo (int) $owc_parent_of[(int) $owc_term->term_id]; ?>" data-indent="<?php echo $owc_indent; ?>" style="padding-left: <?php echo $owc_indent; ?>px;">
425 515 <input type="checkbox" class="owc-category-cb" value="<?php echo (int) $owc_term->term_id; ?>" <?php echo in_array((int) $owc_term->term_id, $owc_saved_scope, true) ? 'checked' : ''; ?>>
426 - <?php echo esc_html($owc_term->name); ?>
516 + <?php if ($owc_row['parents'] !== ''): ?><span class="owc-cat-path"><?php echo esc_html($owc_row['parents']); ?></span><?php endif; ?><?php echo esc_html($owc_row['name']); ?>
427 517 <span style="color: #999;">(<?php echo (int) $owc_row['count']; ?>)</span>
428 518 </label>
429 519 <?php endforeach; ?>
430 520 </div>
521 + <p id="owc-scope-actions" style="margin: 12px 0 0 0;">
522 + <span id="owc-scope-removed-note" style="display: none; color: #b32d2e; margin-right: 10px;"></span>
523 + <button type="button" class="button button-secondary" id="owc-scope-remove" style="display: none;">
524 + Remove unticked categories from the AI training data
525 + </button>
526 + <span id="owc-scope-remove-progress" style="display: none; margin-left: 10px; color: #666;"></span>
527 + </p>
431 528 </div>
432 529 <?php endif; ?>
433 530
434 531 <div id="onwebchat_sync_status_display" style="background: #f9f9f9; padding: 20px; border-radius: 8px; max-width: 800px; margin-top: 15px;">
@@ -452,26 +549,54 @@
452 549 <p style="margin: 0; color: #666;">
453 550 No bulk sync has been performed yet.
454 551 </p>
455 552 <?php endif; ?>
553 +
554 + <?php if ($owc_scope_summary && !empty($owc_scope_summary['known'])): ?>
555 + <p id="onwebchat_scope_summary" style="margin: 10px 0 0 0; color: #666;">
556 + <strong>In your AI training data:</strong>
557 + <?php if ($owc_scope_summary['whole_catalogue']): ?>
558 + <?php printf(
559 + 'your whole catalogue (%s products)',
560 + esc_html(number_format_i18n($owc_scope_summary['products']))
561 + ); ?>
562 + <?php elseif (!empty($owc_scope_summary['nothing'])): ?>
563 + no products yet
564 + <?php else: ?>
565 + <?php printf(
566 + '%s %s, %s products',
567 + esc_html(number_format_i18n($owc_scope_summary['categories'])),
568 + esc_html($owc_scope_summary['categories'] === 1 ? 'category' : 'categories'),
569 + esc_html(number_format_i18n($owc_scope_summary['products']))
570 + ); ?>
571 + <?php endif; ?>
572 + </p>
573 + <?php endif; ?>
456 574
457 - <?php if (!$is_in_cooldown): ?>
458 575 <p style="margin-top: 15px;">
459 576 <button type="button"
460 577 id="onwebchat_sync_now"
461 578 class="button button-primary"
462 - <?php echo $sync_status['in_progress'] ? 'disabled' : ''; ?>>
463 - <?php echo $sync_status['in_progress'] ? 'Sync in Progress...' : 'Sync All Products Now'; ?>
579 + data-cooldown="<?php echo (int) $cooldown_remaining; ?>"
580 + <?php echo ($sync_status['in_progress'] || $is_in_cooldown) ? 'disabled' : ''; ?>>
581 + <?php
582 + if ($sync_status['in_progress']) {
583 + echo 'Sync in Progress...';
584 + } elseif ($is_in_cooldown) {
585 + printf('Just synced, wait %ds', (int) $cooldown_remaining);
586 + } else {
587 + echo 'Sync All Products Now';
588 + }
589 + ?>
464 590 </button>
465 591 </p>
466 - <?php endif; ?>
467 592
468 593 <p class="description onwebchat-sync-description" style="margin-top: 10px;">
469 594 <?php if ($is_in_cooldown): ?>
470 595 <?php if ($sync_enabled): ?>
471 - Bulk sync was completed recently. Your products are already up to date, and any new or updated products will be synced automatically.
596 + A sync has just finished. The button is available again in a moment, so you can sync another selection right away.
472 597 <?php else: ?>
473 - Bulk sync was completed recently. Your products are already up to date. Please enable "Automatically sync products to onWebChat for AI training" above to keep all your products synced automatically.
598 + A sync has just finished. Please enable "Automatically sync products to onWebChat for AI training" above to keep your products synced automatically.
474 599 <?php endif; ?>
475 600 <?php else: ?>
476 601 Click to sync all existing products. This process runs in the background and may take several minutes depending on your product count.
477 602 <?php endif; ?>
@@ -505,66 +630,246 @@
505 630 </div>
506 631
507 632 <script type="text/javascript">
508 633 jQuery(document).ready(function($) {
509 - // Cooldown timer - reload page when cooldown expires
510 - <?php if ($is_in_cooldown): ?>
511 - var cooldownSeconds = <?php echo ($cooldown_period - $time_since_last_sync); ?>;
512 - setTimeout(function() {
513 - location.reload();
514 - }, cooldownSeconds * 1000);
515 - <?php endif; ?>
634 + // Cooldown: count down on the button itself and enable it when it runs
635 + // out. The button used to be removed from the page entirely and only came
636 + // back on a reload, which read as "the sync button is gone".
637 + var $syncBtnCooldown = $('#onwebchat_sync_now');
638 + var owcCooldownLeft = parseInt($syncBtnCooldown.attr('data-cooldown'), 10) || 0;
639 + if (owcCooldownLeft > 0) {
640 + var owcCooldownTimer = setInterval(function() {
641 + owcCooldownLeft--;
642 + if (owcCooldownLeft > 0) {
643 + $syncBtnCooldown.text('Just synced, wait ' + owcCooldownLeft + 's');
644 + return;
645 + }
646 + clearInterval(owcCooldownTimer);
647 + $syncBtnCooldown.prop('disabled', false);
648 + $('.onwebchat-sync-description').text('Click to sync all existing products. This process runs in the background and may take several minutes depending on your product count.');
649 + // Restores "Sync All Products Now" / "Sync N new categories".
650 + if (typeof owcUpdateSyncButtonLabel === 'function') {
651 + owcUpdateSyncButtonLabel();
652 + } else {
653 + $syncBtnCooldown.text('Sync All Products Now');
654 + }
655 + }, 1000);
656 + }
516 657
517 658 // ---- Large-catalogue category picker ----
518 659 var owcOverMax = <?php echo (!empty($owc_over_max)) ? 'true' : 'false'; ?>;
519 660 var owcHasPicker = $('#owc-category-picker').length > 0;
661 + // The categories already in the AI training data. A sync only ever ADDS to
662 + // this, so it is also the list a removal is measured against.
663 + var owcSavedScope = <?php echo wp_json_encode(array_map('strval', $owc_saved_scope)); ?>;
664 + // Nothing in the training data yet: every ticked category is a first sync,
665 + // so the button should not talk about what is "new" or a "re-sync".
666 + var owcScopeNone = <?php echo (!empty($owc_scope_summary['nothing'])) ? 'true' : 'false'; ?>;
667 + var owcCatParent = {}; // category id => parent id ('0' for top-level categories)
668 + var owcCatExplicit = {}; // category id => true when ticked by the merchant
669 + var owcFiltering = false;
520 670
671 + // Lower-case and strip accents, so "camasi" also finds a category with diacritics.
672 + function owcFold(str) {
673 + str = String(str || '').toLowerCase();
674 + if (str.normalize) {
675 + str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
676 + }
677 + return str;
678 + }
679 +
680 + // Only explicit ticks are sent: a ticked category already covers its subtree.
521 681 function owcSelectedCategoryIds() {
522 682 var ids = [];
523 - $('.owc-category-cb:checked').each(function() {
524 - ids.push($(this).val());
683 + $('.owc-category-cb').each(function() {
684 + if (owcCatExplicit[String(this.value)]) {
685 + ids.push(String(this.value));
686 + }
525 687 });
526 688 return ids;
527 689 }
528 690
691 + function owcHasTickedAncestor(id) {
692 + var parent = owcCatParent[id], hops = 0;
693 + while (parent && parent !== '0' && hops++ < 100) {
694 + if (owcCatExplicit[parent]) {
695 + return true;
696 + }
697 + parent = owcCatParent[parent];
698 + }
699 + return false;
700 + }
701 +
702 + // Descendants of a ticked category show as ticked and disabled (implied)
703 + // and are handed back when the parent is unticked.
704 + function owcApplyTicks() {
705 + $('.owc-category-cb').each(function() {
706 + var id = String(this.value);
707 + var implied = owcHasTickedAncestor(id);
708 + this.disabled = implied;
709 + this.checked = implied || !!owcCatExplicit[id];
710 + $(this).closest('.owc-category-row').toggleClass('owc-category-implied', implied);
711 + });
712 + }
713 +
714 + // Is this category already in the training data, i.e. covered by the saved
715 + // scope itself or by one of its ancestors? Mirrors scope_covers() in PHP.
716 + function owcCoveredBy(list, id) {
717 + id = String(id);
718 + if (list.indexOf(id) !== -1) {
719 + return true;
720 + }
721 + var parent = owcCatParent[id], hops = 0;
722 + while (parent && parent !== '0' && hops++ < 100) {
723 + if (list.indexOf(parent) !== -1) {
724 + return true;
725 + }
726 + parent = owcCatParent[parent];
727 + }
728 + return false;
729 + }
730 +
731 + // Ticked categories that are not in the training data yet: the only ones a
732 + // sync has to push.
733 + function owcAddedCategories() {
734 + if (!owcSavedScope.length) {
735 + return []; // whole catalogue already covered
736 + }
737 + return owcSelectedCategoryIds().filter(function(id) {
738 + return !owcCoveredBy(owcSavedScope, id);
739 + });
740 + }
741 +
742 + // Synced categories the merchant just unticked. Nothing happens to them
743 + // until the removal button below is used.
744 + function owcRemovedCategories() {
745 + var selected = owcSelectedCategoryIds();
746 + return owcSavedScope.filter(function(id) {
747 + return !owcCoveredBy(selected, id);
748 + });
749 + }
750 +
529 751 function owcUpdateSyncButtonLabel() {
530 752 var $btn = $('#onwebchat_sync_now');
531 753 if (!$btn.length || $btn.prop('disabled')) {
532 754 return;
533 755 }
534 - var label = (owcOverMax || owcSelectedCategoryIds().length > 0)
535 - ? 'Sync selected categories'
536 - : 'Sync All Products Now';
756 + var selected = owcSelectedCategoryIds().length;
757 + var added = owcAddedCategories().length;
758 + var label;
759 + if (!selected && !owcOverMax) {
760 + label = 'Sync All Products Now';
761 + } else if (owcScopeNone) {
762 + label = 'Sync selected ' + (selected === 1 ? 'category' : 'categories');
763 + } else if (added > 0) {
764 + label = 'Sync ' + added + (added === 1 ? ' new category' : ' new categories');
765 + } else {
766 + label = 'Re-sync selected categories';
767 + }
537 768 $btn.text(label);
538 769 }
539 770
771 + // Show the removal offer only while synced categories are unticked.
772 + function owcUpdateRemoveControls() {
773 + var $btn = $('#owc-scope-remove');
774 + if (!$btn.length) {
775 + return;
776 + }
777 + var removed = owcRemovedCategories().length;
778 + var $note = $('#owc-scope-removed-note');
779 + if (removed > 0) {
780 + $note.text('Unticked categories keep their products in your AI training data until you remove them.').show();
781 + $btn.text('Remove unticked categories from the AI training data (' + removed + ')').show();
782 + } else {
783 + $note.hide();
784 + $btn.hide();
785 + }
786 + }
787 +
540 788 function owcUpdateSelected() {
541 789 var selected = owcSelectedCategoryIds().length;
542 - var total = $('.owc-category-cb').length;
543 - $('#owc-category-selected').text(selected > 0 ? (selected + ' of ' + total + ' categories selected') : '');
544 - $('#owc-category-all').prop('checked', total > 0 && selected === total);
790 + var $all = $('.owc-category-cb');
791 + var checked = $all.filter(':checked').length;
792 + var text = '';
793 + if (selected > 0) {
794 + text = selected + (selected === 1 ? ' category' : ' categories') + ' selected';
795 + if (checked > selected) {
796 + text += ' (' + checked + ' including subcategories)';
797 + }
798 + }
799 + $('#owc-category-selected').text(text);
800 + $('#owc-category-all').prop('checked', $all.length > 0 && checked === $all.length);
545 801 owcUpdateSyncButtonLabel();
802 + owcUpdateRemoveControls();
546 803 }
547 804
548 805 if (owcHasPicker) {
549 - $('.owc-category-cb').on('change', owcUpdateSelected);
806 + $('.owc-category-row').each(function() {
807 + var $row = $(this);
808 + var cb = $row.find('.owc-category-cb')[0];
809 + if (!cb) {
810 + return;
811 + }
812 + var id = String(cb.value);
813 + owcCatParent[id] = String($row.attr('data-parent') || '0');
814 + if (cb.checked) {
815 + owcCatExplicit[id] = true; // saved sync scope
816 + }
817 + $row.data('fold', owcFold($row.attr('data-path')));
818 + });
550 819
551 - // Select all toggles only the rows currently visible (respects search).
820 + $('.owc-category-cb').on('change', function() {
821 + if (this.checked) {
822 + owcCatExplicit[String(this.value)] = true;
823 + } else {
824 + delete owcCatExplicit[String(this.value)];
825 + }
826 + owcApplyTicks();
827 + owcUpdateSelected();
828 + });
829 +
830 + // Select all: without a search it ticks every top-level category (the
831 + // whole tree); while searching it toggles only the rows currently shown.
552 832 $('#owc-category-all').on('change', function() {
553 - var checked = $(this).is(':checked');
554 - $('.owc-category-row:visible').find('.owc-category-cb').prop('checked', checked);
833 + var checked = this.checked;
834 + if (owcFiltering) {
835 + $('.owc-category-row:visible .owc-category-cb').each(function() {
836 + if (checked) {
837 + owcCatExplicit[String(this.value)] = true;
838 + } else {
839 + delete owcCatExplicit[String(this.value)];
840 + }
841 + });
842 + } else {
843 + owcCatExplicit = {};
844 + if (checked) {
845 + $.each(owcCatParent, function(id, parent) {
846 + if (parent === '0') {
847 + owcCatExplicit[id] = true;
848 + }
849 + });
850 + }
851 + }
852 + owcApplyTicks();
555 853 owcUpdateSelected();
556 854 });
557 855
856 + // Search the whole tree by name or path ("shoes" also lists "Men > Shoes").
857 + // Matches are shown flat with their full path while a search is active.
558 858 $('#owc-category-search').on('input', function() {
559 - var q = $(this).val().toLowerCase();
859 + var q = owcFold($(this).val().trim());
860 + owcFiltering = q !== '';
861 + $('#owc-category-list').toggleClass('owc-filtering', owcFiltering);
560 862 $('.owc-category-row').each(function() {
561 - var name = String($(this).data('name'));
562 - $(this).toggle(name.indexOf(q) !== -1);
863 + var $row = $(this);
864 + var hit = !owcFiltering || String($row.data('fold')).indexOf(q) !== -1;
865 + $row.toggle(hit);
866 + $row.css('padding-left', owcFiltering ? '0' : (parseInt($row.attr('data-indent'), 10) || 0) + 'px');
563 867 });
564 868 });
565 869
566 870 // Reflect the saved scope (pre-ticked categories) and set the label.
871 + owcApplyTicks();
567 872 owcUpdateSelected();
568 873 }
569 874
570 875 // Sync enabled checkbox - save immediately via AJAX
@@ -689,9 +994,18 @@
689 994 function owcRunBatch(total, attempt) {
690 995 $.ajax({
691 996 url: ajaxurl,
692 997 type: 'POST',
693 - timeout: 120000,
998 + // MUST stay above the 180s the PHP side allows for its own
999 + // /product/batch call (see send_product_batch): on a first
1000 + // sync of long descriptions onWebChat summarizes every
1001 + // oversized product with a model call, so a page can take
1002 + // minutes. A browser timeout shorter than that aborts a
1003 + // request that is still running server-side, and the retry
1004 + // below then re-enters sync_next_page() concurrently, which
1005 + // double-counts the progress counters and can end the run
1006 + // early with products left unsynced.
1007 + timeout: 240000,
694 1008 data: { action: 'onwebchat_wc_sync_batch', nonce: nonce },
695 1009 success: function(resp) {
696 1010 if (!resp || !resp.success) { owcSyncFail((resp && resp.data) || 'Sync failed'); return; }
697 1011 var d = resp.data || {};
@@ -725,9 +1039,11 @@
725 1039 // Kick off the run, then let the browser drive the batches.
726 1040 $.ajax({
727 1041 url: ajaxurl,
728 1042 type: 'POST',
729 - timeout: 120000,
1043 + // Also processes the first page inline, so it needs the same
1044 + // headroom as owcRunBatch above.
1045 + timeout: 240000,
730 1046 data: {
731 1047 action: 'onwebchat_wc_sync_start',
732 1048 categories: owcSelected.join(','),
733 1049 nonce: nonce
@@ -735,8 +1051,12 @@
735 1051 success: function(response) {
736 1052 if (!response || !response.success) { owcSyncFail((response && response.data) || 'Could not start sync'); return; }
737 1053 var total = response.data.total || 0;
738 1054 owcSyncProgress(0, total);
1055 + // Starting a sync switches automatic sync on server-side; show it.
1056 + if (response.data.auto_sync_enabled) {
1057 + $('#onwebchat_wc_sync_enabled').prop('checked', true);
1058 + }
739 1059 if (total === 0) {
740 1060 alert('No products found to sync.');
741 1061 $btn.prop('disabled', false);
742 1062 owcUpdateSyncButtonLabel();
@@ -746,8 +1066,90 @@
746 1066 owcRunBatch(total, 0);
747 1067 },
748 1068 error: function() {
749 1069 owcSyncFail('Could not start sync. Please try again.');
1070 + }
1071 + });
1072 + });
1073 +
1074 + // Explicit removal of unticked categories (point 3 of the scope model).
1075 + // Unticking a category never deletes anything on its own; this button is
1076 + // the only way products leave the AI training data from here, and it says
1077 + // exactly how many are affected before doing it.
1078 + $('#owc-scope-remove').on('click', function() {
1079 + var $btn = $(this);
1080 + var $progress = $('#owc-scope-remove-progress');
1081 + var nonce = '<?php echo wp_create_nonce('onwebchat_wc_sync_nonce'); ?>';
1082 + var keep = owcSelectedCategoryIds().join(',');
1083 +
1084 + $btn.prop('disabled', true);
1085 + $progress.text('Checking...').show();
1086 +
1087 + function removeFail(message) {
1088 + $btn.prop('disabled', false);
1089 + $progress.hide().text('');
1090 + alert(message);
1091 + }
1092 +
1093 + function removeNextPage(total, done) {
1094 + $.ajax({
1095 + url: ajaxurl,
1096 + type: 'POST',
1097 + timeout: 240000,
1098 + data: { action: 'onwebchat_wc_scope_remove_batch', nonce: nonce },
1099 + success: function(resp) {
1100 + if (!resp || !resp.success) { removeFail((resp && resp.data) || 'Could not remove the products.'); return; }
1101 + var d = resp.data || {};
1102 + done = d.done || done;
1103 + total = d.total || total;
1104 + $progress.text('Removed ' + done + ' / ' + total + '...');
1105 + if (d.complete) {
1106 + $progress.text('Done. Reloading...');
1107 + location.reload();
1108 + return;
1109 + }
1110 + removeNextPage(total, done);
1111 + },
1112 + error: function() {
1113 + removeFail('A network error occurred while removing the products. Some may still be in your AI training data; please try again.');
1114 + }
1115 + });
1116 + }
1117 +
1118 + // Ask the server how much this affects, confirm, then run.
1119 + $.ajax({
1120 + url: ajaxurl,
1121 + type: 'POST',
1122 + timeout: 240000,
1123 + data: { action: 'onwebchat_wc_scope_remove_start', categories: keep, nonce: nonce },
1124 + success: function(resp) {
1125 + if (!resp || !resp.success) { removeFail((resp && resp.data) || 'Could not start the removal.'); return; }
1126 + var d = resp.data || {};
1127 + var total = d.total || 0;
1128 + var message = 'Remove ' + total + (total === 1 ? ' product' : ' products') +
1129 + ' of ' + d.categories + (d.categories === 1 ? ' category' : ' categories') +
1130 + ' from your AI training data?\n\nYour chatbot will stop answering questions about them.' +
1131 + ' You can sync them again at any time.';
1132 + if (d.disables_sync) {
1133 + message += '\n\nNothing is left ticked, so automatic product sync will also be switched off.';
1134 + }
1135 +
1136 + if (total === 0 || d.complete) {
1137 + location.reload();
1138 + return;
1139 + }
1140 +
1141 + if (!window.confirm(message)) {
1142 + $btn.prop('disabled', false);
1143 + $progress.hide().text('');
1144 + return;
1145 + }
1146 +
1147 + $progress.text('Removed 0 / ' + total + '...');
1148 + removeNextPage(total, 0);
1149 + },
1150 + error: function() {
1151 + removeFail('Could not start the removal. Please try again.');
750 1152 }
751 1153 });
752 1154 });
753 1155