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 +552 -118 3.7.23.10.0 View file →
@@ -13,9 +13,9 @@
13 13 onwebchat_handle_woocommerce_actions();
14 14
15 15 // Get current settings
16 16 $sync_enabled = get_option('onwebchat_wc_sync_enabled', false);
17 - $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full');
17 + $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full');
18 18 $secret = get_option('onwebchat_wc_sync_secret', '');
19 19 $order_lookup_enabled = get_option('onwebchat_wc_order_lookup_enabled', false);
20 20
21 21 // Get sync status
@@ -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>
@@ -180,14 +193,17 @@
180 193 </p>
181 194 </td>
182 195 </tr>
183 196
184 - <tr style="display: none;">
197 + <tr>
185 198 <th scope="row">
186 199 <label for="onwebchat_wc_sync_mode">Description Mode</label>
187 200 </th>
188 201 <td>
189 202 <select id="onwebchat_wc_sync_mode" name="onwebchat_wc_sync_mode" style="width: 350px;">
203 + <option value="short_plus_full" <?php selected($sync_mode, 'short_plus_full'); ?>>
204 + Short + full description (default)
205 + </option>
190 206 <option value="short_only" <?php selected($sync_mode, 'short_only'); ?>>
191 207 Short description only
192 208 </option>
193 209 <option value="short_fallback_full" <?php selected($sync_mode, 'short_fallback_full'); ?>>
@@ -194,9 +210,11 @@
194 210 Short description (fallback to full)
195 211 </option>
196 212 </select>
197 213 <p class="description">
198 - Choose how product descriptions are synced for AI training
214 + Choose how product descriptions are synced for AI training.
215 + The default sends both texts, so the AI bot also learns the
216 + detailed description (intended use, compatibility, specs).
199 217 </p>
200 218 </td>
201 219 </tr>
202 220
@@ -317,9 +335,9 @@
317 335 $owc_threshold = OnWebChat_WooCommerce_Sync::CATEGORY_SELECT_THRESHOLD;
318 336 $owc_max = OnWebChat_WooCommerce_Sync::MAX_SYNC_PRODUCTS;
319 337 } else {
320 338 $owc_threshold = 2000;
321 - $owc_max = 9000;
339 + $owc_max = 15000;
322 340 }
323 341
324 342 $owc_counts = wp_count_posts('product');
325 343 $owc_published_count = (is_object($owc_counts) && isset($owc_counts->publish)) ? (int) $owc_counts->publish : 0;
@@ -347,69 +365,146 @@
347 365 if (is_wp_error($owc_categories)) {
348 366 $owc_categories = array();
349 367 }
350 368
351 - // Build the category tree. The picker only offers top-level categories, and
352 - // selecting one covers its whole subtree, so each row shows a distinct product
353 - // count spanning the category and all of its descendants.
354 - $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
355 374 foreach ($owc_categories as $owc_term) {
356 - $owc_children[(int) $owc_term->parent][] = $owc_term;
375 + $owc_term_by_id[(int) $owc_term->term_id] = $owc_term;
357 376 }
358 377
359 - // Distinct published products in a category and its whole subtree. Counting
360 - // distinct posts (WP_Query found_posts with include_children) avoids the
361 - // over-count you get from summing per-term counts when a product is assigned
362 - // to both a parent and a child category.
363 - if (!function_exists('owc_subtree_product_count')) {
364 - function owc_subtree_product_count($term_id) {
365 - $query = new WP_Query(array(
366 - 'post_type' => 'product',
367 - 'post_status' => 'publish',
368 - 'posts_per_page' => 1,
369 - 'fields' => 'ids',
370 - 'no_found_rows' => false,
371 - 'tax_query' => array(array(
372 - 'taxonomy' => 'product_cat',
373 - 'field' => 'term_id',
374 - 'terms' => (int) $term_id,
375 - 'include_children' => true,
376 - )),
377 - ));
378 - 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
379 384 }
385 + $owc_parent_of[(int) $owc_term->term_id] = $owc_pid;
386 + $owc_children[$owc_pid][] = $owc_term;
380 387 }
381 388
382 - // Renderable top-level categories (parent 0) that actually cover products.
383 - $owc_top_rows = array();
384 - $owc_top_level = isset($owc_children[0]) ? $owc_children[0] : array();
385 - foreach ($owc_top_level as $owc_term) {
386 - $owc_count = owc_subtree_product_count((int) $owc_term->term_id);
387 - if ($owc_count > 0) {
388 - $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();
389 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);
390 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);
391 480 ?>
392 481
393 - <?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>
394 489 <div id="owc-category-picker" style="background: #f9f9f9; padding: 20px; border-radius: 8px; max-width: 800px; margin-top: 15px;">
395 490 <p style="margin: 0 0 10px 0;"><strong>Choose what to sync</strong></p>
396 491 <p id="owc-category-intro" style="margin: 0 0 12px 0; color: #555;">
397 492 <?php if ($owc_over_max): ?>
398 493 <?php printf(
399 - '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.',
400 495 esc_html(number_format_i18n($owc_published_count)),
401 496 esc_html(number_format_i18n($owc_max))
402 497 ); ?>
403 498 <?php else: ?>
404 499 <?php printf(
405 - '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.',
406 501 esc_html(number_format_i18n($owc_published_count))
407 502 ); ?>
408 503 <?php endif; ?>
409 504 </p>
410 505 <p style="margin: 0 0 10px 0;">
411 - <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;">
412 507 </p>
413 508 <p style="margin: 0 0 8px 0;">
414 509 <label><input type="checkbox" id="owc-category-all"> <strong>Select all</strong></label>
415 510 <span id="owc-category-selected" style="margin-left: 12px; color: #666;"></span>
@@ -414,16 +509,23 @@
414 509 <label><input type="checkbox" id="owc-category-all"> <strong>Select all</strong></label>
415 510 <span id="owc-category-selected" style="margin-left: 12px; color: #666;"></span>
416 511 </p>
417 512 <div id="owc-category-list" style="max-height: 280px; overflow: auto; border: 1px solid #ddd; border-radius: 4px; background: #fff; padding: 8px 12px;">
418 - <?php foreach ($owc_top_rows as $owc_row): $owc_term = $owc_row['term']; ?>
419 - <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;">
420 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' : ''; ?>>
421 - <?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']); ?>
422 517 <span style="color: #999;">(<?php echo (int) $owc_row['count']; ?>)</span>
423 518 </label>
424 519 <?php endforeach; ?>
425 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>
426 528 </div>
427 529 <?php endif; ?>
428 530
429 531 <div id="onwebchat_sync_status_display" style="background: #f9f9f9; padding: 20px; border-radius: 8px; max-width: 800px; margin-top: 15px;">
@@ -447,26 +549,54 @@
447 549 <p style="margin: 0; color: #666;">
448 550 No bulk sync has been performed yet.
449 551 </p>
450 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; ?>
451 574
452 - <?php if (!$is_in_cooldown): ?>
453 575 <p style="margin-top: 15px;">
454 576 <button type="button"
455 577 id="onwebchat_sync_now"
456 578 class="button button-primary"
457 - <?php echo $sync_status['in_progress'] ? 'disabled' : ''; ?>>
458 - <?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 + ?>
459 590 </button>
460 591 </p>
461 - <?php endif; ?>
462 592
463 593 <p class="description onwebchat-sync-description" style="margin-top: 10px;">
464 594 <?php if ($is_in_cooldown): ?>
465 595 <?php if ($sync_enabled): ?>
466 - 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.
467 597 <?php else: ?>
468 - 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.
469 599 <?php endif; ?>
470 600 <?php else: ?>
471 601 Click to sync all existing products. This process runs in the background and may take several minutes depending on your product count.
472 602 <?php endif; ?>
@@ -500,66 +630,246 @@
500 630 </div>
501 631
502 632 <script type="text/javascript">
503 633 jQuery(document).ready(function($) {
504 - // Cooldown timer - reload page when cooldown expires
505 - <?php if ($is_in_cooldown): ?>
506 - var cooldownSeconds = <?php echo ($cooldown_period - $time_since_last_sync); ?>;
507 - setTimeout(function() {
508 - location.reload();
509 - }, cooldownSeconds * 1000);
510 - <?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 + }
511 657
512 658 // ---- Large-catalogue category picker ----
513 659 var owcOverMax = <?php echo (!empty($owc_over_max)) ? 'true' : 'false'; ?>;
514 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;
515 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.
516 681 function owcSelectedCategoryIds() {
517 682 var ids = [];
518 - $('.owc-category-cb:checked').each(function() {
519 - ids.push($(this).val());
683 + $('.owc-category-cb').each(function() {
684 + if (owcCatExplicit[String(this.value)]) {
685 + ids.push(String(this.value));
686 + }
520 687 });
521 688 return ids;
522 689 }
523 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 +
524 751 function owcUpdateSyncButtonLabel() {
525 752 var $btn = $('#onwebchat_sync_now');
526 753 if (!$btn.length || $btn.prop('disabled')) {
527 754 return;
528 755 }
529 - var label = (owcOverMax || owcSelectedCategoryIds().length > 0)
530 - ? 'Sync selected categories'
531 - : '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 + }
532 768 $btn.text(label);
533 769 }
534 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 +
535 788 function owcUpdateSelected() {
536 789 var selected = owcSelectedCategoryIds().length;
537 - var total = $('.owc-category-cb').length;
538 - $('#owc-category-selected').text(selected > 0 ? (selected + ' of ' + total + ' categories selected') : '');
539 - $('#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);
540 801 owcUpdateSyncButtonLabel();
802 + owcUpdateRemoveControls();
541 803 }
542 804
543 805 if (owcHasPicker) {
544 - $('.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 + });
545 819
546 - // 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.
547 832 $('#owc-category-all').on('change', function() {
548 - var checked = $(this).is(':checked');
549 - $('.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();
550 853 owcUpdateSelected();
551 854 });
552 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.
553 858 $('#owc-category-search').on('input', function() {
554 - var q = $(this).val().toLowerCase();
859 + var q = owcFold($(this).val().trim());
860 + owcFiltering = q !== '';
861 + $('#owc-category-list').toggleClass('owc-filtering', owcFiltering);
555 862 $('.owc-category-row').each(function() {
556 - var name = String($(this).data('name'));
557 - $(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');
558 867 });
559 868 });
560 869
561 870 // Reflect the saved scope (pre-ticked categories) and set the label.
871 + owcApplyTicks();
562 872 owcUpdateSelected();
563 873 }
564 874
565 875 // Sync enabled checkbox - save immediately via AJAX
@@ -647,79 +957,199 @@
647 957 return;
648 958 }
649 959
650 960 var $btn = $(this);
961 + var nonce = '<?php echo wp_create_nonce('onwebchat_wc_sync_nonce'); ?>';
651 962 $btn.prop('disabled', true).text('Syncing products...');
652 -
963 +
653 964 // Hide the "Last sync" info while syncing
654 965 $('#onwebchat_last_sync_info').hide();
655 966 $('#onwebchat_total_synced_info').hide();
656 -
967 +
657 968 // Show initial syncing message with progress bar
658 969 $('#onwebchat_sync_status_display').html(
659 - '<p style="margin: 0 0 10px 0;"><strong>⏳ Sync in progress:</strong> <span id="sync_progress_text">0 / ? products synced</span></p>' +
970 + '<p style="margin: 0 0 10px 0;"><strong>⏳ Sync in progress:</strong> <span id="sync_progress_text">Starting…</span></p>' +
660 971 '<div style="background: #fff; border: 1px solid #ddd; border-radius: 4px; height: 20px; overflow: hidden;">' +
661 972 '<div id="sync_progress_bar" style="background: #2271b1; height: 100%; width: 0%; transition: width 0.3s;"></div>' +
662 973 '</div>' +
663 - '<p style="margin: 10px 0 0 0; color: #666;">Please wait, this may take several minutes.</p>'
974 + '<p style="margin: 10px 0 0 0; color: #666;">Please keep this page open until the sync completes.</p>'
664 975 );
665 -
666 - // Start polling for progress updates
667 - var progressInterval = setInterval(function() {
976 +
977 + function owcSyncFail(msg) {
978 + alert('Error: ' + msg);
979 + $btn.prop('disabled', false);
980 + owcUpdateSyncButtonLabel();
981 + $('#onwebchat_sync_status_display').html('');
982 + }
983 +
984 + function owcSyncProgress(done, total) {
985 + var pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
986 + $('#sync_progress_text').text(done + ' / ' + total + ' products synced');
987 + $('#sync_progress_bar').css('width', pct + '%');
988 + }
989 +
990 + // Drive the sync one page per request until the run reports complete.
991 + // A single long request would outrun the server timeout on large
992 + // catalogues and report a false failure; this keeps every request
993 + // short and only reports success once the whole run is done.
994 + function owcRunBatch(total, attempt) {
668 995 $.ajax({
669 996 url: ajaxurl,
670 997 type: 'POST',
671 - data: {
672 - action: 'onwebchat_wc_get_sync_status',
673 - nonce: '<?php echo wp_create_nonce('onwebchat_wc_sync_nonce'); ?>'
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,
1008 + data: { action: 'onwebchat_wc_sync_batch', nonce: nonce },
1009 + success: function(resp) {
1010 + if (!resp || !resp.success) { owcSyncFail((resp && resp.data) || 'Sync failed'); return; }
1011 + var d = resp.data || {};
1012 + total = d.total || total;
1013 + owcSyncProgress(d.done || 0, total);
1014 +
1015 + if (d.in_progress) {
1016 + owcRunBatch(total, 0);
1017 + } else {
1018 + var s = d.stats || {};
1019 + alert('Sync completed!\n\n' +
1020 + 'Created: ' + (s.created || 0) + '\n' +
1021 + 'Updated: ' + (s.updated || 0) + '\n' +
1022 + 'Unchanged: ' + (s.skipped || 0) + '\n' +
1023 + 'Errors: ' + (s.errors || 0));
1024 + location.reload();
1025 + }
674 1026 },
675 - success: function(statusResponse) {
676 - if (statusResponse.success && statusResponse.data.in_progress) {
677 - var done = statusResponse.data.done || 0;
678 - var total = statusResponse.data.total || 0;
679 - var percentage = total > 0 ? Math.round((done / total) * 100) : 0;
680 -
681 - $('#sync_progress_text').text(done + ' / ' + total + ' products synced');
682 - $('#sync_progress_bar').css('width', percentage + '%');
1027 + error: function() {
1028 + // One page timing out is recoverable (progress is saved
1029 + // server-side): retry a few times before giving up.
1030 + if (attempt < 3) {
1031 + setTimeout(function() { owcRunBatch(total, attempt + 1); }, 3000);
1032 + } else {
1033 + owcSyncFail('A network error occurred. Some products may not have synced; please try again.');
683 1034 }
684 1035 }
685 1036 });
686 - }, 2000); // Poll every 2 seconds
687 -
1037 + }
1038 +
1039 + // Kick off the run, then let the browser drive the batches.
688 1040 $.ajax({
689 1041 url: ajaxurl,
690 1042 type: 'POST',
691 - timeout: 120000, // 2 minute timeout for large syncs
1043 + // Also processes the first page inline, so it needs the same
1044 + // headroom as owcRunBatch above.
1045 + timeout: 240000,
692 1046 data: {
693 - action: 'onwebchat_wc_sync_now',
1047 + action: 'onwebchat_wc_sync_start',
694 1048 categories: owcSelected.join(','),
695 - nonce: '<?php echo wp_create_nonce('onwebchat_wc_sync_nonce'); ?>'
1049 + nonce: nonce
696 1050 },
697 1051 success: function(response) {
698 - clearInterval(progressInterval); // Stop polling
699 -
700 - if (response.success) {
701 - var result = response.data.result;
702 - var stats = result.stats;
703 - var msg = 'Sync completed!\n\n' +
704 - 'Products processed: ' + result.done + '/' + response.data.total + '\n' +
705 - 'Created: ' + stats.created + '\n' +
706 - 'Updated: ' + stats.updated + '\n' +
707 - 'Unchanged: ' + stats.skipped + '\n' +
708 - 'Errors: ' + stats.errors;
709 - alert(msg);
1052 + if (!response || !response.success) { owcSyncFail((response && response.data) || 'Could not start sync'); return; }
1053 + var total = response.data.total || 0;
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 + }
1059 + if (total === 0) {
1060 + alert('No products found to sync.');
1061 + $btn.prop('disabled', false);
1062 + owcUpdateSyncButtonLabel();
1063 + $('#onwebchat_sync_status_display').html('');
1064 + return;
1065 + }
1066 + owcRunBatch(total, 0);
1067 + },
1068 + error: function() {
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) {
710 1137 location.reload();
711 - } else {
712 - alert('Error: ' + response.data);
1138 + return;
1139 + }
1140 +
1141 + if (!window.confirm(message)) {
713 1142 $btn.prop('disabled', false);
714 - owcUpdateSyncButtonLabel();
1143 + $progress.hide().text('');
1144 + return;
715 1145 }
1146 +
1147 + $progress.text('Removed 0 / ' + total + '...');
1148 + removeNextPage(total, 0);
716 1149 },
717 - error: function(xhr, status, error) {
718 - clearInterval(progressInterval); // Stop polling
719 - alert('An error occurred: ' + error + '. Please try again.');
720 - $btn.prop('disabled', false);
721 - owcUpdateSyncButtonLabel();
1150 + error: function() {
1151 + removeFail('Could not start the removal. Please try again.');
722 1152 }
723 1153 });
724 1154 });
725 1155
@@ -837,9 +1267,13 @@
837 1267 }
838 1268
839 1269 // Save settings
840 1270 $sync_enabled = isset($_POST["onwebchat_wc_sync_enabled"]) ? true : false;
841 - $sync_mode = isset($_POST["onwebchat_wc_sync_mode"]) ? sanitize_text_field($_POST["onwebchat_wc_sync_mode"]) : 'short_fallback_full';
1271 + $sync_mode = isset($_POST["onwebchat_wc_sync_mode"]) ? sanitize_text_field($_POST["onwebchat_wc_sync_mode"]) : 'short_plus_full';
1272 + $allowed_sync_modes = array('short_only', 'short_fallback_full', 'short_plus_full');
1273 + if (!in_array($sync_mode, $allowed_sync_modes, true)) {
1274 + $sync_mode = 'short_plus_full';
1275 + }
842 1276
843 1277 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
844 1278 update_option('onwebchat_wc_sync_mode', $sync_mode);
845 1279