get_settings(); if (empty($s['enabled'])) { return; } add_filter('option_botiga-modules', array($this, 'disable_botiga_variations_gallery')); add_filter('default_option_botiga-modules', array($this, 'disable_botiga_variations_gallery')); add_filter('woocommerce_available_variation', array($this, 'add_variation_gallery_data'), 10, 3); add_action('wp_enqueue_scripts', array($this, 'register_scripts')); add_action('admin_enqueue_scripts', array($this, 'admin_enqueue')); add_action('woocommerce_product_after_variable_attributes', array($this, 'add_variation_gallery_field'), 10, 3); add_action('woocommerce_save_product_variation', array($this, 'save_variation_gallery'), 10, 2); add_action('woocommerce_process_product_meta', array($this, 'save_variation_galleries_on_product_save'), 10, 2); add_action('admin_init', array($this, 'maybe_unhook_wc_classic_variation_gallery'), 50); } public function maybe_unhook_wc_classic_variation_gallery(): void { if (!class_exists('\\Automattic\\WooCommerce\\Internal\\VariationGallery\\Package')) { return; } $owned = class_exists('AIEO_Variation_Gallery_Fallback') ? (bool) AIEO_Variation_Gallery_Fallback::feature_enabled() : true; if (!$owned) { return; } if (!function_exists('wc_get_container')) { return; } try { $picker = wc_get_container()->get(\Automattic\WooCommerce\Internal\VariationGallery\ClassicVariationGalleryAdmin::class); } catch (\Throwable $e) { return; } remove_action('woocommerce_variation_after_upload_image', array($picker, 'render_variation_gallery_field'), 10); remove_action('woocommerce_admin_process_variation_object', array($picker, 'persist_variation_gallery_field'), 10); } public function get_settings() { $stored = get_option(self::OPTION_KEY, array()); if (!is_array($stored)) { $stored = array(); } return array_merge(array('enabled' => false), $stored); } public function ajax_save() { AIEO_DMM::verify_ajax_request(); update_option(self::OPTION_KEY, array( 'enabled' => !empty($_POST['enabled']), ), false); wp_send_json_success(array('message' => __('Variations Gallery settings saved.', 'ai-eshop-optimizer'))); } public function disable_botiga_variations_gallery($modules) { if (is_array($modules)) { $modules['variations-gallery'] = false; } return $modules; } public function register_scripts() { $rel_path = 'assets/js/product-display/variations-gallery.js'; $abs_path = defined('AIEO_DIR') ? AIEO_DIR . $rel_path : plugin_dir_path(AIEO_FILE) . $rel_path; $mtime = file_exists($abs_path) ? (string) filemtime($abs_path) : ''; $version = $mtime !== '' ? $mtime : (defined('AIEO_VERSION') ? AIEO_VERSION : '1.0'); wp_register_script( 'aieo-variations-gallery', plugins_url($rel_path, AIEO_FILE), array('jquery'), $version, true ); $wc_owns = class_exists('AIEO_Variation_Gallery_Fallback') ? (bool) AIEO_Variation_Gallery_Fallback::feature_enabled() : false; wp_add_inline_script( 'aieo-variations-gallery', 'window.aieoVariationsGalleryCfg=' . wp_json_encode(array('wcVariationGallery' => $wc_owns)) . ';', 'before' ); } public function admin_enqueue($hook) { if (!in_array($hook, array('post.php','post-new.php'), true)) { return; } $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || $screen->post_type !== 'product') { return; } $version = defined('AIEO_VERSION') ? AIEO_VERSION : '1.0'; wp_enqueue_media(); wp_enqueue_style( 'aieo-variations-gallery-admin', plugins_url('assets/css/product-display/variations-gallery-admin.css', AIEO_FILE), array(), $version ); wp_enqueue_script( 'aieo-variations-gallery-admin', plugins_url('assets/js/product-display/variations-gallery-admin.js', AIEO_FILE), array('jquery', 'wp-mediaelement'), $version, true ); wp_localize_script('aieo-variations-gallery-admin', 'omPPEVariationsGallery', array( 'placeholderVideoUrl' => plugins_url('assets/images/product-display/video-placeholder.svg', AIEO_FILE), 'editText' => esc_attr__('Edit', 'ai-eshop-optimizer'), 'addToGallery' => esc_attr__('Add to gallery', 'ai-eshop-optimizer'), )); } public function add_variation_gallery_field($loop, $variation_data, $variation) { $attachment_ids = $this->read_variation_gallery_entries((int) $variation->ID); $placeholder = plugins_url('assets/images/product-display/video-placeholder.svg', AIEO_FILE); echo ''; } public static function resolve_image_ids(int $variation_id, string $context = ''): array { $filtered = apply_filters('aieo_variation_gallery_image_ids', null, $variation_id, $context); if (is_array($filtered)) { return array_values(array_filter(array_map('intval', $filtered))); } $canonical_csv = (string) get_post_meta($variation_id, '_product_image_gallery', true); if ($canonical_csv !== '') { $ids = array_values(array_filter(array_map('intval', explode(',', $canonical_csv)))); if (!empty($ids)) { return $ids; } } $legacy = get_post_meta($variation_id, 'botiga_variation_gallery', true); if (is_array($legacy)) { $ids = array(); foreach ($legacy as $entry) { if (is_numeric($entry) && (int) $entry > 0 && !wp_attachment_is('video', (int) $entry)) { $ids[] = (int) $entry; } } return $ids; } return array(); } private function read_variation_gallery_entries(int $variation_id): array { $images = self::resolve_image_ids($variation_id, 'admin'); $videos = get_post_meta($variation_id, '_aieo_variation_gallery_videos', true); if (empty($images) && empty($videos)) { $legacy = get_post_meta($variation_id, 'botiga_variation_gallery', true); return is_array($legacy) ? array_values($legacy) : array(); } $videos = is_array($videos) ? $videos : array(); $videos_by_pos = array(); foreach ($videos as $v) { if (!is_array($v) || !isset($v['position'])) { continue; } $videos_by_pos[(int) $v['position']] = $v; } $total = count($images) + count($videos_by_pos); $result = array(); $img_cursor = 0; for ($i = 0; $i < $total; $i++) { if (isset($videos_by_pos[$i])) { $v = $videos_by_pos[$i]; if (($v['type'] ?? '') === 'attachment' && !empty($v['id'])) { $result[] = (string) $v['id']; } elseif (in_array(($v['type'] ?? ''), array('youtube', 'vimeo'), true) && !empty($v['url'])) { $result[] = wp_json_encode(array( 'url' => (string) $v['url'], 'aspect_ratio' => isset($v['aspect_ratio']) ? (string) $v['aspect_ratio'] : '16-9', )); } } elseif ($img_cursor < count($images)) { $result[] = (string) $images[$img_cursor++]; } } while ($img_cursor < count($images)) { $result[] = (string) $images[$img_cursor++]; } return $result; } public function save_variation_gallery($variation_id, $i) { if (!empty($_POST['botiga_variation_gallery'][$i]) && is_array($_POST['botiga_variation_gallery'][$i])) { $entries = array_map('sanitize_text_field', wp_unslash($_POST['botiga_variation_gallery'][$i])); $this->persist_variation_gallery((int) $variation_id, $entries); } else { $this->clear_variation_gallery((int) $variation_id); } } public function save_variation_galleries_on_product_save($post_id, $post = null) { if (empty($_POST['botiga_variation_gallery']) || !is_array($_POST['botiga_variation_gallery'])) { return; } $variation_ids = isset($_POST['variable_post_id']) ? array_map('absint', wp_unslash($_POST['variable_post_id'])) : array(); if (empty($variation_ids)) { return; } foreach (wp_unslash($_POST['botiga_variation_gallery']) as $loop_index => $gallery_data) { if (!isset($variation_ids[$loop_index])) { continue; } $vid = (int) $variation_ids[$loop_index]; if (!empty($gallery_data) && is_array($gallery_data)) { $entries = array_map('sanitize_text_field', $gallery_data); $this->persist_variation_gallery($vid, $entries); } else { $this->clear_variation_gallery($vid); } } } private function persist_variation_gallery(int $variation_id, array $entries): void { $image_ids = array(); $videos = array(); foreach ($entries as $position => $entry) { if (!class_exists('AIEO_WC_Compat_Migration')) { continue; } $classified = AIEO_WC_Compat_Migration::classify_gallery_entry($entry); if ($classified === null) { continue; } if ($classified['kind'] === 'image') { $image_ids[] = $classified['id']; } else { $videos[] = array_merge($classified, array('position' => (int) $position)); } } if (!empty($image_ids)) { update_post_meta($variation_id, '_product_image_gallery', implode(',', $image_ids)); $existing_thumb = (int) get_post_meta($variation_id, '_thumbnail_id', true); if (!$existing_thumb || !wp_attachment_is_image($existing_thumb)) { update_post_meta($variation_id, '_thumbnail_id', $image_ids[0]); } } else { delete_post_meta($variation_id, '_product_image_gallery'); } if (!empty($videos)) { update_post_meta($variation_id, '_aieo_variation_gallery_videos', $videos); } else { delete_post_meta($variation_id, '_aieo_variation_gallery_videos'); } update_post_meta($variation_id, '_wc_variation_gallery_legacy_fallback_disabled', 'yes'); delete_post_meta($variation_id, 'botiga_variation_gallery'); } private function clear_variation_gallery(int $variation_id): void { delete_post_meta($variation_id, '_product_image_gallery'); delete_post_meta($variation_id, '_aieo_variation_gallery_videos'); delete_post_meta($variation_id, 'botiga_variation_gallery'); update_post_meta($variation_id, '_wc_variation_gallery_legacy_fallback_disabled', 'yes'); } public function add_variation_gallery_data($variation_details, $variable_product, $variation) { if (!is_singular('product')) { return $variation_details; } $gallery = $this->read_variation_gallery_entries((int) $variation->get_id()); if (empty($gallery)) { return $variation_details; } wp_enqueue_script('aieo-variations-gallery'); $variation_thumb = get_post_meta($variation->get_id(), '_thumbnail_id', true); if (!empty($variation_thumb) && !in_array((int) $variation_thumb, array_map('intval', $gallery), true)) { $gallery = array_merge(array($variation_thumb), $gallery); } $placeholder = plugins_url('assets/images/product-display/video-placeholder.svg', AIEO_FILE); foreach ($gallery as $attachment_id) { if (is_numeric($attachment_id) && wp_attachment_is('video', (int) $attachment_id)) { $variation_details['variation_gallery_images'][] = array( 'poster' => $placeholder, 'url' => wp_get_attachment_url((int) $attachment_id), 'type' => 'video', ); } elseif (is_string($attachment_id) && preg_match('(youtube|youtu\.be)', $attachment_id)) { $obj = json_decode($attachment_id); if ($obj && isset($obj->url)) { preg_match('#(?<=v=)[a-zA-Z0-9-]+(?=&)|(?<=v\/)[^&\n]+|(?<=v=)[^&\n]+|(?<=youtu\.be/|youtube\.com/embed/)[^&\n]+#', $obj->url, $m); $variation_details['variation_gallery_images'][] = array( 'poster' => $placeholder, 'url' => $attachment_id, 'video_id' => !empty($m[0]) ? $m[0] : '', 'type' => 'embed', 'provider' => 'youtube', 'aspect_ratio' => isset($obj->aspect_ratio) ? $obj->aspect_ratio : '16-9', ); } } elseif (is_string($attachment_id) && preg_match('(vimeo)', $attachment_id)) { $obj = json_decode($attachment_id); if ($obj && isset($obj->url)) { preg_match('#(?<=vimeo\.com/video/|vimeo\.com/)[^&\n]+#', $obj->url, $m); $variation_details['variation_gallery_images'][] = array( 'poster' => $placeholder, 'url' => $attachment_id, 'video_id' => !empty($m[0]) ? $m[0] : '', 'type' => 'embed', 'provider' => 'vimeo', 'aspect_ratio' => isset($obj->aspect_ratio) ? $obj->aspect_ratio : '16-9', ); } } else { $att = wc_get_product_attachment_props((int) $attachment_id); if (!empty($att['src'])) { $variation_details['variation_gallery_images'][] = $att; } } } return $variation_details; } public function render_subtab() { $s = $this->get_settings(); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $form_id = 'aieo-card-variations-gallery-overview-form'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => 'variations-gallery-overview', 'icon' => 'layers', 'title' => __('Gallery picker and image swap', 'ai-eshop-optimizer'), 'description' => __('Per-variation image galleries — switching attributes (e.g. colour) swaps the gallery images for that variation. Per-variation gallery picker appears under each variation row on the product edit screen → Variations tab. Same `botiga_variation_gallery` meta key as the legacy plugin + Botiga theme so existing data carries over unchanged.', 'ai-eshop-optimizer'), 'col' => 1, 'footer' => \Rooster\Admin\States::save_button($form_id), )); echo $open; $field = \Rooster\Admin\Ui::field_row(array( 'id' => 'aieo-vg-enabled', 'label' => __('Enable', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::toggle('enabled', !empty($s['enabled']), array('id' => 'aieo-vg-enabled', 'hidden' => false, 'label' => __('Show per-variation gallery picker on product edit + swap on the frontend', 'ai-eshop-optimizer'))), )); echo AIEO_DMM_UI::card_form('aieo_dmm_variations_gallery_save', 'variations_gallery', $form_id, '
' . $field . '
'); echo $close; $this->render_migration_card($nonce, $ajax); } private function render_migration_card($nonce, $ajax) { if (!class_exists('AIEO_WC_Compat_Migration')) { return; } $status = $this->migration_status_snapshot(); list($mig_open, $mig_close) = AIEO_DMM_UI::card(array( 'anchor' => 'variations-gallery-wc-10-9-compatibility-migration', 'icon' => 'refresh-cw', 'title' => __('WC 10.9 compatibility migration', 'ai-eshop-optimizer'), 'description' => __('Moves legacy variation galleries from botiga_variation_gallery → WC 10.9 canonical _product_image_gallery, and Rooster One swatch term metas → canonical color meta + sidecars. Idempotent: rows already attempted are skipped via a per-row marker. Safe to interrupt and resume.', 'ai-eshop-optimizer'), 'col' => 2, )); echo $mig_open; $as_pending = (int) $status['as_pending']; $panel_rows = array( array( 'id' => 'variations_remaining', 'label' => __('Variations remaining', 'ai-eshop-optimizer'), 'html' => '' . esc_html(number_format_i18n((int) $status['variations_remaining'])) . '', ), array( 'id' => 'terms_remaining', 'label' => __('Swatch terms remaining', 'ai-eshop-optimizer'), 'html' => '' . esc_html(number_format_i18n((int) $status['terms_remaining'])) . '', ), array( 'id' => 'as_pending', 'label' => __('Action Scheduler pending', 'ai-eshop-optimizer'), 'html' => '' . esc_html(number_format_i18n($as_pending)) . '' . ($as_pending > 0 ? ' ' : ''), ), ); echo \Rooster\Admin\Ui::facts($panel_rows, array('class' => 'aieo-dmm-vg-mig-status')); $job = array( 'id' => 'aieo-dmm-vg-mig-job', 'state' => $status['completed_at'] ? 'done' : 'partly', 'run' => __('Run all batches', 'ai-eshop-optimizer'), 'run_attrs' => array('id' => 'aieo-dmm-vg-mig-runall'), 'result' => $status['completed_at'] ? __('Completed', 'ai-eshop-optimizer') : __('Stopped — not yet complete', 'ai-eshop-optimizer'), 'facts' => $status['completed_at'] ? array(gmdate('Y-m-d H:i:s', (int) $status['completed_at']) . ' UTC') : array(), ); echo \Rooster\Admin\States::job($job); echo '
'; echo ' '; echo ''; echo '
'; $reset_ask = \Rooster\Admin\States::confirm( 'aieo-dmm-vg-mig-reset-dialog', 'reset', __('Reset the migration?', 'ai-eshop-optimizer'), '

' . esc_html__('Reset will: clear the completion flag, delete the per-row "_aieo_variation_migrated" + "_aieo_swatch_term_migrated" markers from every product/term. The next "Run all" will reprocess every legacy row from scratch. Continue?', 'ai-eshop-optimizer') . '

' ); echo $reset_ask; if ($as_pending > 0) { $clear_ask = \Rooster\Admin\States::confirm( 'aieo-dmm-vg-mig-clear-as-dialog', 'reset', __('Stop the migration in the background?', 'ai-eshop-optimizer'), '

' . esc_html(sprintf( _n( 'Deletes the %s pending Action Scheduler job of this migration, so it no longer runs in the background. Run all batches here finishes it.', 'Deletes the %s pending Action Scheduler jobs of this migration, so it no longer runs in the background. Run all batches here finishes it.', $as_pending, 'ai-eshop-optimizer' ), number_format_i18n($as_pending) )) . '

', array('confirm' => __('Delete pending AS jobs', 'ai-eshop-optimizer')) ); echo $clear_ask; } ?> get_var($wpdb->prepare( "SELECT COUNT(DISTINCT legacy.post_id) FROM {$wpdb->postmeta} AS legacy INNER JOIN {$wpdb->posts} AS posts ON posts.ID = legacy.post_id AND posts.post_type = 'product_variation' LEFT JOIN {$wpdb->postmeta} AS canonical ON canonical.post_id = legacy.post_id AND canonical.meta_key = '_product_image_gallery' AND canonical.meta_value <> '' LEFT JOIN {$wpdb->postmeta} AS attempted ON attempted.post_id = legacy.post_id AND attempted.meta_key = %s AND attempted.meta_value = 'yes' WHERE legacy.meta_key = 'botiga_variation_gallery' AND legacy.meta_value <> '' AND canonical.post_id IS NULL AND attempted.post_id IS NULL", $migrated_meta )); $terms_remaining = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(DISTINCT tm.term_id) FROM {$wpdb->termmeta} AS tm INNER JOIN {$wpdb->term_taxonomy} AS tt ON tt.term_id = tm.term_id LEFT JOIN {$wpdb->termmeta} AS canonical ON canonical.term_id = tm.term_id AND canonical.meta_key = 'color' AND canonical.meta_value <> '' LEFT JOIN {$wpdb->termmeta} AS sidecar_type ON sidecar_type.term_id = tm.term_id AND sidecar_type.meta_key = '_aieo_swatch_color_type' LEFT JOIN {$wpdb->termmeta} AS sidecar_image ON sidecar_image.term_id = tm.term_id AND sidecar_image.meta_key = '_aieo_swatch_image_id' LEFT JOIN {$wpdb->termmeta} AS attempted ON attempted.term_id = tm.term_id AND attempted.meta_key = %s AND attempted.meta_value = 'yes' WHERE tm.meta_key IN ('product_attribute_color', 'product_attribute_image') AND tm.meta_value <> '' AND tt.taxonomy LIKE %s AND canonical.term_id IS NULL AND sidecar_type.term_id IS NULL AND sidecar_image.term_id IS NULL AND attempted.term_id IS NULL", $term_migrated_meta, $wpdb->esc_like('pa_') . '%' )); $as_pending = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}actionscheduler_actions WHERE hook = %s AND status = 'pending'", AIEO_WC_Compat_Migration::UPDATE_HOOK )); $completed_raw = get_option(AIEO_WC_Compat_Migration::COMPLETED_OPTION); $completed_at = is_numeric($completed_raw) ? (int) $completed_raw : ($completed_raw ? (int) strtotime((string) $completed_raw) : 0); return array( 'variations_remaining' => $variations_remaining, 'terms_remaining' => $terms_remaining, 'as_pending' => $as_pending, 'completed_at' => $completed_at, 'completed_at_formatted' => $completed_at ? gmdate('Y-m-d H:i:s', $completed_at) . ' UTC' : '', ); } public function ajax_migration_status() { AIEO_DMM::verify_ajax_request(); wp_send_json_success($this->migration_status_snapshot()); } public function ajax_migration_run_batch() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_WC_Compat_Migration')) { wp_send_json_error(array('message' => __('Migration class not loaded.', 'ai-eshop-optimizer'))); } $had_completed = get_option(AIEO_WC_Compat_Migration::COMPLETED_OPTION); if ($had_completed) { delete_option(AIEO_WC_Compat_Migration::COMPLETED_OPTION); } $more = AIEO_WC_Compat_Migration::run(); $snap = $this->migration_status_snapshot(); wp_send_json_success(array_merge($snap, array('more' => (bool) $more))); } public function ajax_migration_reset() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_WC_Compat_Migration')) { wp_send_json_error(array('message' => __('Migration class not loaded.', 'ai-eshop-optimizer'))); } global $wpdb; delete_option(AIEO_WC_Compat_Migration::COMPLETED_OPTION); $migrated_meta = AIEO_WC_Compat_Migration::MIGRATED_META; $term_migrated_meta = AIEO_WC_Compat_Migration::TERM_MIGRATED_META; $pm_deleted = (int) $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", $migrated_meta )); $tm_deleted = (int) $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->termmeta} WHERE meta_key = %s", $term_migrated_meta )); wp_send_json_success(array( 'message' => sprintf( __('Reset done. Cleared completion flag + %1$d postmeta + %2$d termmeta markers.', 'ai-eshop-optimizer'), $pm_deleted, $tm_deleted ), )); } public function ajax_migration_clear_as() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_WC_Compat_Migration')) { wp_send_json_error(array('message' => __('Migration class not loaded.', 'ai-eshop-optimizer'))); } global $wpdb; $deleted = (int) $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->prefix}actionscheduler_actions WHERE hook = %s AND status IN ('pending','in-progress')", AIEO_WC_Compat_Migration::UPDATE_HOOK )); wp_send_json_success(array( 'message' => sprintf( __('Deleted %d pending/in-progress AS jobs.', 'ai-eshop-optimizer'), $deleted ), 'deleted' => $deleted, )); } }