db = $db; // Register AJAX handlers // Legacy single-shot move (v1.0+). Kept for back-compat; JS now uses // the batched flow added in v1.7.0. add_action('wp_ajax_studiou_wcpcm_move_products', array($this, 'handle_move_products_ajax')); // v1.7.0 — M2: batched product move. Mirrors the delete-products // pattern: start returns the list of product IDs in the source // category; chunk processes 25 IDs per AJAX call. Removes the // single-shot timeout on large source categories. add_action('wp_ajax_studiou_wcpcm_move_products_start', array($this, 'handle_move_products_start_ajax')); add_action('wp_ajax_studiou_wcpcm_move_products_chunk', array($this, 'handle_move_products_chunk_ajax')); add_action('wp_ajax_studiou_wcpcm_batch_description', array($this, 'handle_batch_description_ajax')); add_action('wp_ajax_studiou_wcpcm_delete_products', array($this, 'handle_delete_products_ajax')); add_action('wp_ajax_studiou_wcpcm_delete_products_batch', array($this, 'handle_delete_products_batch_ajax')); add_action('wp_ajax_studiou_wcpcm_clear_media', array($this, 'handle_clear_media_ajax')); add_action('wp_ajax_studiou_wcpcm_clear_media_batch', array($this, 'handle_clear_media_batch_ajax')); add_action('wp_ajax_studiou_wcpcm_remove_unassigned_media', array($this, 'handle_remove_unassigned_media_ajax')); add_action('wp_ajax_studiou_wcpcm_remove_unassigned_media_batch', array($this, 'handle_remove_unassigned_media_batch_ajax')); add_action('wp_ajax_studiou_wcpcm_upvp_get_attributes', array($this, 'handle_upvp_get_attributes_ajax')); add_action('wp_ajax_studiou_wcpcm_upvp_get_attribute_values', array($this, 'handle_upvp_get_attribute_values_ajax')); add_action('wp_ajax_studiou_wcpcm_upvp_count', array($this, 'handle_upvp_count_ajax')); add_action('wp_ajax_studiou_wcpcm_upvp_apply', array($this, 'handle_upvp_apply_ajax')); // Tabbed manipulations page - load a tab partial on demand add_action('wp_ajax_studiou_wcpcm_load_tab', array($this, 'handle_load_tab_ajax')); // Invalidate the cached category/media counts whenever categories or // product <-> category relationships change. add_action('created_term', array($this, 'bust_count_cache')); add_action('edited_term', array($this, 'bust_count_cache')); add_action('delete_term', array($this, 'bust_count_cache')); add_action('set_object_terms', array($this, 'bust_count_cache')); add_action('deleted_term_relationships', array($this, 'bust_count_cache')); // Add a test AJAX handler to verify AJAX is working add_action('wp_ajax_studiou_wcpcm_test', array($this, 'handle_test_ajax')); // Debug: Log that the handler is being registered if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: AJAX handlers registered - move_products, batch_description, delete_products, and clear_media'); } } /** * Invalidate the cached category / media-category count data. * * Hooked to term and term-relationship changes so the counts shown on the * manipulations tabs stay accurate. */ public function bust_count_cache() { delete_transient('studiou_wcpcm_category_counts'); delete_transient('studiou_wcpcm_media_cat_counts'); } /** * Handle AJAX request to render a Category Manipulations tab partial. * * Each tab partial fetches only its own data, so the manipulations page * shell can render instantly and heavy calculations run on demand. */ public function handle_load_tab_ajax() { // Clean any output buffers to prevent JSON corruption while (ob_get_level()) { ob_end_clean(); } // Check nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); return; } // Check permissions if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); return; } $tab = isset($_POST['tab']) ? sanitize_key($_POST['tab']) : ''; $allowed = array('move', 'batch-description', 'delete-products', 'clear-media', 'remove-unassigned', 'upvp', 'notes'); if (!in_array($tab, $allowed, true)) { wp_send_json_error(array( 'message' => __('Unknown tab', 'studiou-wc-product-cat-manage') )); return; } $partial = STUDIOU_WCPCM_PLUGIN_DIR . 'views/manipulations/tab-' . $tab . '.php'; if (!file_exists($partial)) { wp_send_json_error(array( 'message' => __('Tab content not found', 'studiou-wc-product-cat-manage') )); return; } // Made available to the partial; each partial fetches only what it needs. $manipulator = $this; try { ob_start(); include $partial; $html = ob_get_clean(); wp_send_json_success(array( 'tab' => $tab, 'html' => $html )); } catch (Exception $e) { if (ob_get_level()) { ob_end_clean(); } error_log('STUDIOU WC TABS: tab "' . $tab . '" render failed: ' . $e->getMessage()); wp_send_json_error(array('message' => $e->getMessage())); } } /** * Handle AJAX batch description request */ public function handle_batch_description_ajax() { // Clean all output buffers and prevent any output while (ob_get_level()) { ob_end_clean(); } // Start a new output buffer to catch any unwanted output ob_start(); // Suppress PHP errors/notices during AJAX to prevent JSON corruption $original_error_reporting = error_reporting(); error_reporting(0); // Enable error logging only if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Batch description AJAX handler called'); error_log('STUDIOU WC: POST data: ' . print_r($_POST, true)); } try { // Check nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); } // Check permissions if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); } // Validate input if (!isset($_POST['selected_categories']) || !isset($_POST['description'])) { wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); } $selected_categories = array_map('intval', $_POST['selected_categories']); $description = sanitize_textarea_field($_POST['description']); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Batch description operation started - Categories: ' . implode(', ', $selected_categories)); error_log('STUDIOU WC: Description: ' . $description); } // Validate categories if (empty($selected_categories)) { wp_send_json_error(array( 'message' => __('Please select at least one category', 'studiou-wc-product-cat-manage') )); } // Execute batch description operation $result = $this->update_categories_description($selected_categories, $description); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Batch description operation completed - Updated: ' . $result['updated_count'] . ' categories'); error_log('STUDIOU WC: Sending success response: ' . print_r($result, true)); } // Clean any output that might have been generated ob_clean(); // Return success response wp_send_json_success(array( 'message' => $result['message'], 'updated_count' => $result['updated_count'], 'failed_count' => isset($result['failed_count']) ? $result['failed_count'] : 0 )); } catch (Exception $e) { // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Batch description operation failed - ' . $e->getMessage()); error_log('STUDIOU WC: Exception trace: ' . $e->getTraceAsString()); } // Clean any output that might have been generated ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during batch description operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { // Restore original error reporting error_reporting($original_error_reporting); // End the output buffer ob_end_clean(); } } /** * Handle AJAX move products request */ public function handle_move_products_ajax() { // Clean all output buffers and prevent any output while (ob_get_level()) { ob_end_clean(); } // Start a new output buffer to catch any unwanted output ob_start(); // Suppress PHP errors/notices during AJAX to prevent JSON corruption $original_error_reporting = error_reporting(); error_reporting(0); // Enable error logging only if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: AJAX handler called'); error_log('STUDIOU WC: POST data: ' . print_r($_POST, true)); } try { // Check nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); } // Check permissions if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); } // Validate input if (!isset($_POST['source_category']) || !isset($_POST['target_category'])) { wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); } $source_category_id = intval($_POST['source_category']); $target_category_id = intval($_POST['target_category']); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Move operation started - Source: ' . $source_category_id . ', Target: ' . $target_category_id); } // Validate categories if ($source_category_id === $target_category_id) { wp_send_json_error(array( 'message' => __('Source and target categories must be different', 'studiou-wc-product-cat-manage') )); } if ($source_category_id <= 0 || $target_category_id <= 0) { wp_send_json_error(array( 'message' => __('Invalid category selection', 'studiou-wc-product-cat-manage') )); } // Execute move operation $result = $this->move_products_between_categories($source_category_id, $target_category_id); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Move operation completed - Moved: ' . $result['moved_count'] . ' products'); error_log('STUDIOU WC: Sending success response: ' . print_r($result, true)); } // Clean any output that might have been generated ob_clean(); // Return success response wp_send_json_success(array( 'message' => $result['message'], 'moved_count' => $result['moved_count'], 'source_count' => $result['source_count'], 'target_count' => $result['target_count'], 'failed_count' => isset($result['failed_count']) ? $result['failed_count'] : 0 )); } catch (Exception $e) { // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Move operation failed - ' . $e->getMessage()); error_log('STUDIOU WC: Exception trace: ' . $e->getTraceAsString()); } // Clean any output that might have been generated ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during move operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { // Restore original error reporting error_reporting($original_error_reporting); // End the output buffer ob_end_clean(); } } /** * v1.7.0 — M2: batched product move, step 1 (start). * * Returns the list of product IDs in the source category. JS chunks * them and calls handle_move_products_chunk_ajax in batches. */ public function handle_move_products_start_ajax() { while (ob_get_level()) { ob_end_clean(); } if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage'))); } if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'))); } $source = isset($_POST['source_category']) ? intval($_POST['source_category']) : 0; $target = isset($_POST['target_category']) ? intval($_POST['target_category']) : 0; if ($source <= 0 || $target <= 0) { wp_send_json_error(array('message' => __('Invalid category selection', 'studiou-wc-product-cat-manage'))); } if ($source === $target) { wp_send_json_error(array('message' => __('Source and target categories must be different', 'studiou-wc-product-cat-manage'))); } $source_term = get_term($source, 'product_cat'); $target_term = get_term($target, 'product_cat'); if (is_wp_error($source_term) || !$source_term || is_wp_error($target_term) || !$target_term) { wp_send_json_error(array('message' => __('Invalid category specified', 'studiou-wc-product-cat-manage'))); } $product_ids = $this->get_products_in_category($source); wp_send_json_success(array( 'product_ids' => array_values(array_map('intval', $product_ids)), 'total' => count($product_ids), 'source_name' => $source_term->name, 'target_name' => $target_term->name, )); } /** * v1.7.0 — M2: batched product move, step 2 (chunk). * * Moves the supplied product IDs from source to target category. * wp_set_post_terms is idempotent, so a re-run of an already-moved * chunk (e.g. after a network retry) is safe. */ public function handle_move_products_chunk_ajax() { while (ob_get_level()) { ob_end_clean(); } if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage'))); } if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'))); } $source = isset($_POST['source_category']) ? intval($_POST['source_category']) : 0; $target = isset($_POST['target_category']) ? intval($_POST['target_category']) : 0; $ids = isset($_POST['product_ids']) && is_array($_POST['product_ids']) ? array_values(array_map('intval', $_POST['product_ids'])) : array(); if ($source <= 0 || $target <= 0 || $source === $target || empty($ids)) { wp_send_json_error(array('message' => __('Invalid request', 'studiou-wc-product-cat-manage'))); } $moved = 0; $failed = 0; foreach ($ids as $pid) { $current = wp_get_post_terms($pid, 'product_cat', array('fields' => 'ids')); if (is_wp_error($current)) { $failed++; continue; } $new = array_diff($current, array($source)); $new[] = $target; $new = array_values(array_unique($new)); $r = wp_set_post_terms($pid, $new, 'product_cat'); if (is_wp_error($r)) { $failed++; } else { $moved++; } } wp_send_json_success(array( 'moved' => $moved, 'failed' => $failed, )); } /** * Move all products from source category to target category * * @param int $source_category_id Source category ID * @param int $target_category_id Target category ID * @return array Result with counts and message * @throws Exception On error */ public function move_products_between_categories($source_category_id, $target_category_id) { // Get source and target category terms $source_category = get_term($source_category_id, 'product_cat'); $target_category = get_term($target_category_id, 'product_cat'); if (is_wp_error($source_category) || is_wp_error($target_category)) { throw new Exception(__('Invalid category specified', 'studiou-wc-product-cat-manage')); } // Get initial count of products in source category using direct DB query $initial_source_count = $this->get_category_product_count($source_category_id); // Check if source category has any products if ($initial_source_count === 0) { $message = sprintf( __('No products found in source category "%s". Nothing to move.', 'studiou-wc-product-cat-manage'), $source_category->name ); return array( 'moved_count' => 0, 'source_count' => 0, 'target_count' => $this->get_category_product_count($target_category_id), 'message' => $message ); } // Get all products in source category using direct DB query $products = $this->get_products_in_category($source_category_id); $moved_count = 0; $failed_count = 0; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Found ' . count($products) . ' products in category ' . $source_category->name . ' for moving'); } // Move each product foreach ($products as $product_id) { // Get current categories for the product $current_categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'ids')); if (is_wp_error($current_categories)) { $failed_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Error getting categories for product ' . $product_id . ': ' . $current_categories->get_error_message()); } continue; } // Remove source category and add target category $new_categories = array_diff($current_categories, array($source_category_id)); $new_categories[] = $target_category_id; // Update product categories $result = wp_set_post_terms($product_id, $new_categories, 'product_cat'); if (!is_wp_error($result)) { $moved_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Successfully moved product ' . $product_id . ' from category ' . $source_category_id . ' to ' . $target_category_id); } } else { $failed_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Error moving product ' . $product_id . ': ' . $result->get_error_message()); } } } // Get updated counts using direct DB query $source_count = $this->get_category_product_count($source_category_id); $target_count = $this->get_category_product_count($target_category_id); // Generate detailed result message if ($moved_count === 0 && count($products) > 0) { $message = sprintf( __('Failed to move any products from "%s" to "%s". All %d products encountered errors during the move operation.', 'studiou-wc-product-cat-manage'), $source_category->name, $target_category->name, count($products) ); } else { $message = sprintf( __('Moved %d products from "%s" to "%s". Source category now has %d products, target category now has %d products.', 'studiou-wc-product-cat-manage'), $moved_count, $source_category->name, $target_category->name, $source_count, $target_count ); if ($failed_count > 0) { $message .= ' ' . sprintf( __('Note: %d products failed to move due to errors.', 'studiou-wc-product-cat-manage'), $failed_count ); } } return array( 'moved_count' => $moved_count, 'source_count' => $source_count, 'target_count' => $target_count, 'failed_count' => $failed_count, 'message' => $message ); } /** * Get product count for a category * * @param int $category_id Category ID * @return int Product count */ private function get_category_product_count($category_id) { global $wpdb; // Use direct database query for more accurate counting $sql = " SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = 'product_cat' AND p.post_type = 'product' AND p.post_status IN ('publish', 'private', 'draft') "; $count = $wpdb->get_var($wpdb->prepare($sql, $category_id)); if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Category ' . $category_id . ' product count (direct DB query): ' . intval($count)); } return intval($count); } /** * Get all products in a category * * @param int $category_id Category ID * @return array Array of product IDs */ private function get_products_in_category($category_id) { global $wpdb; // Use direct database query to get products $sql = " SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = 'product_cat' AND p.post_type = 'product' AND p.post_status IN ('publish', 'private', 'draft') "; $products = $wpdb->get_col($wpdb->prepare($sql, $category_id)); if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Found ' . count($products) . ' products in category ' . $category_id . ' (direct DB query)'); if (!empty($products)) { error_log('STUDIOU WC: Product IDs: ' . implode(', ', array_slice($products, 0, 10)) . (count($products) > 10 ? ' (showing first 10)' : '')); } } return $products ? array_map('intval', $products) : array(); } /** * Update descriptions for multiple categories * * @param array $category_ids Array of category IDs * @param string $description New description for categories * @return array Result with counts and message * @throws Exception On error */ public function update_categories_description($category_ids, $description) { $updated_count = 0; $failed_count = 0; $category_names = array(); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Starting batch description update for ' . count($category_ids) . ' categories'); } // Update each category foreach ($category_ids as $category_id) { // Get category term $category = get_term($category_id, 'product_cat'); if (is_wp_error($category) || !$category) { $failed_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Error getting category ' . $category_id . ': ' . ($category ? $category->get_error_message() : 'Category not found')); } continue; } // Update category description $result = wp_update_term($category_id, 'product_cat', array( 'description' => $description )); if (!is_wp_error($result)) { $updated_count++; $category_names[] = $category->name; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Successfully updated description for category ' . $category_id . ' (' . $category->name . ')'); } } else { $failed_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Error updating category ' . $category_id . ': ' . $result->get_error_message()); } } } // Generate result message if ($updated_count === 0) { $message = sprintf( __('Failed to update any category descriptions. %d categories encountered errors.', 'studiou-wc-product-cat-manage'), $failed_count ); } else { $message = sprintf( __('Successfully updated descriptions for %d categories: %s', 'studiou-wc-product-cat-manage'), $updated_count, implode(', ', array_slice($category_names, 0, 5)) . ($updated_count > 5 ? sprintf(__(', and %d more', 'studiou-wc-product-cat-manage'), $updated_count - 5) : '') ); if ($failed_count > 0) { $message .= ' ' . sprintf( __('Note: %d categories failed to update due to errors.', 'studiou-wc-product-cat-manage'), $failed_count ); } } return array( 'updated_count' => $updated_count, 'failed_count' => $failed_count, 'message' => $message ); } /** * Get all categories with product counts for select lists. * * Transient-cached wrapper around get_categories_with_counts_uncached(). * The N+1 per-category count queries are expensive on large stores, so the * result is cached for 5 minutes and busted on any category change. * * @return array Array of categories with ID, name, and product count */ public function get_categories_with_counts() { $data = get_transient('studiou_wcpcm_category_counts'); if (false === $data) { $data = $this->get_categories_with_counts_uncached(); set_transient('studiou_wcpcm_category_counts', $data, 5 * MINUTE_IN_SECONDS); } return $data; } /** * Get all categories with product counts for select lists (uncached). * * Queries the product_cat terms directly via $wpdb instead of get_terms() * on purpose: the WooCommerce Protected Categories plugin filters * get_terms() and hides protected categories in admin-ajax / front-end * contexts. Since v1.6.0 the manipulations tabs fetch this list inside an * admin-ajax request, so get_terms() would drop protected categories from * the dropdowns. Admins must be able to see and operate on every category, * protected ones included, so we bypass the term-query filters entirely. * * @return array Array of categories with ID, name, and product count */ public function get_categories_with_counts_uncached() { global $wpdb; // v1.7.1 — M9: replace the per-category get_category_product_count() // loop (N+1) with a single aggregate query. The naive // term_relationships count would over-count trashed/pending/ // auto-draft products because trashing a product does NOT remove // its term relationship — a category emptied via "Delete products // in category" could still display a non-zero count. So we JOIN // wp_posts and apply the SAME filters get_category_product_count() // uses (post_type='product', post_status IN publish/private/draft), // with the filters in the ON clause so zero-count categories // survive the LEFT JOIN. $sql = $wpdb->prepare(" SELECT t.term_id, t.name, COUNT(DISTINCT p.ID) AS cnt FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id LEFT JOIN {$wpdb->term_relationships} tr ON tr.term_taxonomy_id = tt.term_taxonomy_id LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND p.post_type = %s AND p.post_status IN ('publish','private','draft') WHERE tt.taxonomy = %s GROUP BY t.term_id, t.name ORDER BY t.name ASC ", 'product', 'product_cat'); $rows = $wpdb->get_results($sql); $categories_with_counts = array(); if (!empty($rows)) { foreach ($rows as $row) { $count = (int) $row->cnt; $categories_with_counts[] = array( 'id' => (int) $row->term_id, 'name' => $row->name, 'count' => $count, 'display_name' => sprintf('%s (%d products)', $row->name, $count), ); } } return $categories_with_counts; } /** * Handle AJAX delete products request (initial request to get product IDs) */ public function handle_delete_products_ajax() { // Clean all output buffers and prevent any output while (ob_get_level()) { ob_end_clean(); } // Start a new output buffer to catch any unwanted output ob_start(); // Suppress PHP errors/notices during AJAX to prevent JSON corruption $original_error_reporting = error_reporting(); error_reporting(0); // Enable error logging only if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Delete products AJAX handler called'); error_log('STUDIOU WC: POST data: ' . print_r($_POST, true)); } try { // Check nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); } // Check permissions if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); } // Validate input if (!isset($_POST['delete_categories'])) { wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); } $category_ids = array_map('intval', $_POST['delete_categories']); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Delete products operation started - Categories: ' . implode(', ', $category_ids)); } // Validate categories if (empty($category_ids)) { wp_send_json_error(array( 'message' => __('Please select at least one category', 'studiou-wc-product-cat-manage') )); } // Get all products from selected categories $product_ids = $this->get_products_from_categories($category_ids); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Found ' . count($product_ids) . ' products to delete'); } // Clean any output that might have been generated ob_clean(); // Return product IDs for batch processing wp_send_json_success(array( 'product_ids' => $product_ids, 'total_count' => count($product_ids), 'message' => sprintf( __('Found %d products to delete', 'studiou-wc-product-cat-manage'), count($product_ids) ) )); } catch (Exception $e) { // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Delete products operation failed - ' . $e->getMessage()); error_log('STUDIOU WC: Exception trace: ' . $e->getTraceAsString()); } // Clean any output that might have been generated ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during delete operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { // Restore original error reporting error_reporting($original_error_reporting); // End the output buffer ob_end_clean(); } } /** * Handle AJAX delete products batch request (processes products in batches) */ public function handle_delete_products_batch_ajax() { // Clean all output buffers and prevent any output while (ob_get_level()) { ob_end_clean(); } // Start a new output buffer to catch any unwanted output ob_start(); // Suppress PHP errors/notices during AJAX to prevent JSON corruption $original_error_reporting = error_reporting(); error_reporting(0); // Enable error logging only if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Delete products batch AJAX handler called'); error_log('STUDIOU WC: POST data: ' . print_r($_POST, true)); } try { // Check nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); } // Check permissions if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); } // Validate input if (!isset($_POST['product_ids'])) { wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); } $product_ids = array_map('intval', $_POST['product_ids']); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Processing batch of ' . count($product_ids) . ' products for deletion'); } // Delete products in this batch $result = $this->delete_products_batch($product_ids); // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Batch deletion completed - Deleted: ' . $result['deleted_count'] . ', Failed: ' . $result['failed_count']); } // Clean any output that might have been generated ob_clean(); // Return success response wp_send_json_success(array( 'deleted_count' => $result['deleted_count'], 'failed_count' => $result['failed_count'], 'failed_products' => $result['failed_products'] )); } catch (Exception $e) { // Debug logging if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Delete products batch operation failed - ' . $e->getMessage()); error_log('STUDIOU WC: Exception trace: ' . $e->getTraceAsString()); } // Clean any output that might have been generated ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during delete batch operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { // Restore original error reporting error_reporting($original_error_reporting); // End the output buffer ob_end_clean(); } } /** * Get all products from multiple categories * * @param array $category_ids Array of category IDs * @return array Array of unique product IDs (including parent products with variants) */ private function get_products_from_categories($category_ids) { global $wpdb; if (empty($category_ids)) { return array(); } $placeholders = implode(',', array_fill(0, count($category_ids), '%d')); // Get all products from selected categories (both simple and parent products) $sql = " SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id IN ($placeholders) AND tt.taxonomy = 'product_cat' AND p.post_type = 'product' AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'trash') "; $products = $wpdb->get_col($wpdb->prepare($sql, $category_ids)); if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Found ' . count($products) . ' products in categories ' . implode(', ', $category_ids)); } return $products ? array_map('intval', $products) : array(); } /** * Delete a batch of products and their variants * * @param array $product_ids Array of product IDs to delete * @return array Result with counts */ private function delete_products_batch($product_ids) { $deleted_count = 0; $failed_count = 0; $failed_products = array(); foreach ($product_ids as $product_id) { try { // Get the product object $product = wc_get_product($product_id); if (!$product) { $failed_count++; $failed_products[] = $product_id; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Product ' . $product_id . ' not found'); } continue; } // If it's a variable product, delete all variations first if ($product->is_type('variable')) { $variations = $product->get_children(); if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Deleting ' . count($variations) . ' variations for product ' . $product_id); } foreach ($variations as $variation_id) { $variation = wc_get_product($variation_id); if ($variation) { $variation->delete(true); // true = force delete permanently } } } // Delete the product permanently $result = $product->delete(true); // true = force delete permanently if ($result) { $deleted_count++; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Successfully deleted product ' . $product_id); } } else { $failed_count++; $failed_products[] = $product_id; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Failed to delete product ' . $product_id); } } } catch (Exception $e) { $failed_count++; $failed_products[] = $product_id; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC: Exception deleting product ' . $product_id . ': ' . $e->getMessage()); } } } return array( 'deleted_count' => $deleted_count, 'failed_count' => $failed_count, 'failed_products' => $failed_products ); } /** * Get all media categories with file counts for select lists. * * Transient-cached wrapper around get_media_categories_with_counts_uncached(). * * @return array Array of media categories with ID, name, and file count */ public function get_media_categories_with_counts() { $data = get_transient('studiou_wcpcm_media_cat_counts'); if (false === $data) { $data = $this->get_media_categories_with_counts_uncached(); set_transient('studiou_wcpcm_media_cat_counts', $data, 5 * MINUTE_IN_SECONDS); } return $data; } /** * Get all media categories with file counts for select lists (uncached). * * @return array Array of media categories with ID, name, and file count */ public function get_media_categories_with_counts_uncached() { $taxonomy = $this->get_media_category_taxonomy(); if (!$taxonomy) { return array(); } $args = array( 'taxonomy' => $taxonomy, 'orderby' => 'name', 'hide_empty' => false, ); $categories = get_terms($args); $categories_with_counts = array(); if (!is_wp_error($categories)) { foreach ($categories as $category) { $file_count = $this->get_media_category_file_count($category->term_id, $taxonomy); $categories_with_counts[] = array( 'id' => $category->term_id, 'name' => $category->name, 'count' => $file_count, 'display_name' => sprintf('%s (%d %s)', $category->name, $file_count, __('files', 'studiou-wc-product-cat-manage')) ); } } return $categories_with_counts; } /** * Get the media category taxonomy name * * Checks for common media category taxonomies used by popular plugins * * @return string|false Taxonomy name or false if none found */ private function get_media_category_taxonomy() { $possible_taxonomies = array( 'media_category', 'attachment_category', 'media-category', ); foreach ($possible_taxonomies as $taxonomy) { if (taxonomy_exists($taxonomy)) { error_log('STUDIOU WC MEDIA: Detected media taxonomy: ' . $taxonomy); return $taxonomy; } } // Try to find any taxonomy registered for attachments $attachment_taxonomies = get_object_taxonomies('attachment', 'names'); if (!empty($attachment_taxonomies)) { // Filter out built-in taxonomies $custom_taxonomies = array_filter($attachment_taxonomies, function($tax) { return !in_array($tax, array('post_tag', 'category', 'post_format')); }); if (!empty($custom_taxonomies)) { $found = reset($custom_taxonomies); error_log('STUDIOU WC MEDIA: Auto-detected attachment taxonomy: ' . $found); return $found; } } error_log('STUDIOU WC MEDIA: No media category taxonomy found. Registered attachment taxonomies: ' . implode(', ', $attachment_taxonomies ?? array())); return false; } /** * Get file count for a media category * * @param int $category_id Category term ID * @param string $taxonomy Taxonomy name * @return int File count */ private function get_media_category_file_count($category_id, $taxonomy) { global $wpdb; $sql = " SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = %s AND p.post_type = 'attachment' "; $count = $wpdb->get_var($wpdb->prepare($sql, $category_id, $taxonomy)); return intval($count); } /** * Get all media IDs from multiple media categories * * @param array $category_ids Array of category term IDs * @return array Array of unique attachment IDs */ private function get_media_from_categories($category_ids) { global $wpdb; $taxonomy = $this->get_media_category_taxonomy(); error_log('STUDIOU WC MEDIA: get_media_from_categories called - taxonomy: ' . ($taxonomy ?: 'false') . ', category_ids: ' . implode(', ', $category_ids)); if (!$taxonomy || empty($category_ids)) { error_log('STUDIOU WC MEDIA: No taxonomy or empty category_ids, returning empty'); return array(); } $placeholders = implode(',', array_fill(0, count($category_ids), '%d')); $sql = " SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id IN ($placeholders) AND tt.taxonomy = %s AND p.post_type = 'attachment' "; $params = array_merge($category_ids, array($taxonomy)); $media = $wpdb->get_col($wpdb->prepare($sql, ...$params)); error_log('STUDIOU WC MEDIA: Query found ' . ($media ? count($media) : 0) . ' media files'); return $media ? array_map('intval', $media) : array(); } /** * Delete a batch of media files permanently * * @param array $media_ids Array of attachment IDs to delete * @return array Result with counts */ private function delete_media_batch($media_ids) { global $wpdb; $deleted_count = 0; $failed_count = 0; $failed_media = array(); $affected_term_ids = array(); $taxonomy = $this->get_media_category_taxonomy(); // Ensure required file functions are loaded if (!function_exists('wp_delete_attachment')) { require_once ABSPATH . 'wp-admin/includes/post.php'; } if (!function_exists('wp_delete_file')) { require_once ABSPATH . 'wp-includes/functions.php'; } error_log('STUDIOU WC MEDIA: delete_media_batch called with ' . count($media_ids) . ' media IDs'); foreach ($media_ids as $media_id) { try { $attachment = get_post($media_id); if (!$attachment || $attachment->post_type !== 'attachment') { $failed_count++; $failed_media[] = $media_id; error_log('STUDIOU WC MEDIA: Media file ' . $media_id . ' not found or not an attachment'); continue; } // Collect affected term IDs before deletion for count update if ($taxonomy) { $terms = wp_get_object_terms($media_id, $taxonomy, array('fields' => 'ids')); if (!is_wp_error($terms)) { $affected_term_ids = array_merge($affected_term_ids, $terms); } } // Get file paths BEFORE deletion $file = get_attached_file($media_id); $meta = wp_get_attachment_metadata($media_id); $upload_dir = wp_get_upload_dir(); error_log('STUDIOU WC MEDIA: Deleting media ID ' . $media_id . ', file: ' . ($file ?: 'none')); // Attempt standard WordPress deletion wp_delete_attachment($media_id, true); // Verify the post was actually deleted $still_exists = $wpdb->get_var($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID = %d", $media_id )); if ($still_exists) { error_log('STUDIOU WC MEDIA: wp_delete_attachment did not delete post ' . $media_id . ', forcing manual deletion'); // Force manual deletion: remove term relationships $all_taxonomies = get_object_taxonomies('attachment'); if (!empty($all_taxonomies)) { wp_delete_object_term_relationships($media_id, $all_taxonomies); } // Remove all post meta $wpdb->delete($wpdb->postmeta, array('post_id' => $media_id)); // Remove the post itself $wpdb->delete($wpdb->posts, array('ID' => $media_id)); // Clean cache clean_post_cache($media_id); } // Delete physical files from disk if ($file && file_exists($file)) { @unlink($file); error_log('STUDIOU WC MEDIA: Deleted main file: ' . $file); } // Delete thumbnail/intermediate sizes if (!empty($meta['sizes']) && !empty($upload_dir['basedir'])) { $file_dir = !empty($file) ? trailingslashit(dirname($file)) : ''; foreach ($meta['sizes'] as $size => $sizeinfo) { if (!empty($sizeinfo['file'])) { $intermediate_file = $file_dir . $sizeinfo['file']; if (file_exists($intermediate_file)) { @unlink($intermediate_file); } } } } // Final verification $final_check = $wpdb->get_var($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID = %d", $media_id )); if (!$final_check) { $deleted_count++; error_log('STUDIOU WC MEDIA: Successfully deleted media file ' . $media_id); } else { $failed_count++; $failed_media[] = $media_id; error_log('STUDIOU WC MEDIA: FAILED to delete media file ' . $media_id . ' even after manual cleanup'); } } catch (Exception $e) { $failed_count++; $failed_media[] = $media_id; error_log('STUDIOU WC MEDIA: Exception deleting media file ' . $media_id . ': ' . $e->getMessage()); } } // Force update term counts for affected categories if ($taxonomy && !empty($affected_term_ids)) { $affected_term_ids = array_unique($affected_term_ids); wp_update_term_count_now($affected_term_ids, $taxonomy); error_log('STUDIOU WC MEDIA: Updated term counts for term IDs: ' . implode(', ', $affected_term_ids)); } error_log('STUDIOU WC MEDIA: Batch result - deleted: ' . $deleted_count . ', failed: ' . $failed_count); return array( 'deleted_count' => $deleted_count, 'failed_count' => $failed_count, 'failed_media' => $failed_media ); } /** * Handle AJAX clear media request (initial request to get media IDs) */ public function handle_clear_media_ajax() { while (ob_get_level()) { ob_end_clean(); } ob_start(); $original_error_reporting = error_reporting(); error_reporting(0); error_log('STUDIOU WC MEDIA: Clear media AJAX handler called'); error_log('STUDIOU WC MEDIA: POST data: ' . print_r($_POST, true)); try { if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { error_log('STUDIOU WC MEDIA: Nonce verification failed'); wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); return; } if (!current_user_can('manage_woocommerce')) { error_log('STUDIOU WC MEDIA: Permission check failed'); wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); return; } if (!isset($_POST['media_categories'])) { error_log('STUDIOU WC MEDIA: media_categories not in POST data'); wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); return; } $category_ids = array_map('intval', $_POST['media_categories']); if (empty($category_ids)) { error_log('STUDIOU WC MEDIA: Empty category_ids after intval mapping'); wp_send_json_error(array( 'message' => __('Please select at least one category', 'studiou-wc-product-cat-manage') )); return; } $media_ids = $this->get_media_from_categories($category_ids); error_log('STUDIOU WC MEDIA: Found ' . count($media_ids) . ' media files to delete from categories: ' . implode(', ', $category_ids)); ob_clean(); wp_send_json_success(array( 'media_ids' => $media_ids, 'total_count' => count($media_ids), 'message' => sprintf( __('Found %d media files to delete', 'studiou-wc-product-cat-manage'), count($media_ids) ) )); } catch (Exception $e) { error_log('STUDIOU WC MEDIA: Clear media operation failed - ' . $e->getMessage()); error_log('STUDIOU WC MEDIA: Exception trace: ' . $e->getTraceAsString()); ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during clear media operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * Handle AJAX clear media batch request (processes media in batches) */ public function handle_clear_media_batch_ajax() { while (ob_get_level()) { ob_end_clean(); } ob_start(); $original_error_reporting = error_reporting(); error_reporting(0); error_log('STUDIOU WC MEDIA: Clear media batch AJAX handler called'); try { if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { error_log('STUDIOU WC MEDIA: Batch nonce verification failed'); wp_send_json_error(array( 'message' => __('Security check failed', 'studiou-wc-product-cat-manage') )); return; } if (!current_user_can('manage_woocommerce')) { error_log('STUDIOU WC MEDIA: Batch permission check failed'); wp_send_json_error(array( 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage') )); return; } if (!isset($_POST['media_ids'])) { error_log('STUDIOU WC MEDIA: media_ids not in POST data'); wp_send_json_error(array( 'message' => __('Missing required parameters', 'studiou-wc-product-cat-manage') )); return; } $media_ids = array_map('intval', $_POST['media_ids']); error_log('STUDIOU WC MEDIA: Processing batch of ' . count($media_ids) . ' media files for deletion'); $result = $this->delete_media_batch($media_ids); error_log('STUDIOU WC MEDIA: Batch completed - Deleted: ' . $result['deleted_count'] . ', Failed: ' . $result['failed_count']); ob_clean(); wp_send_json_success(array( 'deleted_count' => $result['deleted_count'], 'failed_count' => $result['failed_count'], 'failed_media' => $result['failed_media'] )); } catch (Exception $e) { error_log('STUDIOU WC MEDIA: Clear media batch operation failed - ' . $e->getMessage()); error_log('STUDIOU WC MEDIA: Exception trace: ' . $e->getTraceAsString()); ob_clean(); wp_send_json_error(array( 'message' => sprintf( __('Error during clear media batch operation: %s', 'studiou-wc-product-cat-manage'), $e->getMessage() ) )); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * Get count of unassigned unused media files * * @return int Count of media files not in any category and not referenced */ public function get_unassigned_unused_media_count() { $ids = $this->get_unassigned_unused_media_ids(); return count($ids); } /** * Get IDs of media files that are not assigned to any media category * and not used as featured image, product gallery image, or attached to any post. * * @return array Array of attachment IDs */ public function get_unassigned_unused_media_ids() { global $wpdb; $taxonomy = $this->get_media_category_taxonomy(); // Base query: all attachments not used as featured image and not attached to a post $sql = " SELECT a.ID FROM {$wpdb->posts} a LEFT JOIN {$wpdb->postmeta} pm_thumb ON a.ID = CAST(pm_thumb.meta_value AS UNSIGNED) AND pm_thumb.meta_key = '_thumbnail_id' WHERE a.post_type = 'attachment' AND a.post_parent = 0 AND pm_thumb.meta_id IS NULL "; // If media category taxonomy exists, also exclude categorized media if ($taxonomy) { $sql .= $wpdb->prepare(" AND a.ID NOT IN ( SELECT tr.object_id FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy = %s ) ", $taxonomy); } $media_ids = $wpdb->get_col($sql); if (empty($media_ids)) { return array(); } $media_ids = array_map('intval', $media_ids); // Filter out media used in WooCommerce product galleries (_product_image_gallery) $gallery_meta = $wpdb->get_col( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_product_image_gallery' AND meta_value != ''" ); if (!empty($gallery_meta)) { $gallery_ids = array(); foreach ($gallery_meta as $gallery_value) { $ids = array_map('intval', array_filter(explode(',', $gallery_value))); $gallery_ids = array_merge($gallery_ids, $ids); } $gallery_ids = array_unique($gallery_ids); if (!empty($gallery_ids)) { $media_ids = array_diff($media_ids, $gallery_ids); } } // v1.6.2 — narrow blast radius. The base query above does not see // attachments referenced from term meta (WooCommerce product-category // images are stored as termmeta 'thumbnail_id'), nor customizer images // (custom logo, site icon, header image). Without these exclusions, // running "Remove Not Assigned Media" silently destroys category // artwork. NOTE: content-embedded, ACF/meta-field, and downloadable- // product images are still NOT detected — see the warning in // tab-remove-unassigned.php. The preview-list inversion (plan-02) is // what makes the operation actually safe. $extra_excluded = array(); $termmeta_ids = $wpdb->get_col( "SELECT DISTINCT meta_value FROM {$wpdb->termmeta} WHERE meta_key = 'thumbnail_id' AND meta_value > 0" ); foreach ($termmeta_ids as $tm_id) { $tm_id = (int) $tm_id; if ($tm_id) { $extra_excluded[$tm_id] = true; } } foreach (array( (int) get_theme_mod('custom_logo'), (int) get_option('site_icon'), ) as $cust_id) { if ($cust_id) { $extra_excluded[$cust_id] = true; } } $hdr = get_theme_mod('header_image_data'); if (is_object($hdr) && !empty($hdr->attachment_id)) { $extra_excluded[(int) $hdr->attachment_id] = true; } if (!empty($extra_excluded)) { $media_ids = array_diff($media_ids, array_keys($extra_excluded)); } error_log('STUDIOU WC MEDIA: Found ' . count($media_ids) . ' unassigned unused media files'); return array_values($media_ids); } /** * Handle AJAX remove unassigned media request (get IDs) */ public function handle_remove_unassigned_media_ajax() { while (ob_get_level()) { ob_end_clean(); } ob_start(); $original_error_reporting = error_reporting(); error_reporting(0); error_log('STUDIOU WC MEDIA: Remove unassigned media AJAX handler called'); try { if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage'))); return; } if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'))); return; } $media_ids = $this->get_unassigned_unused_media_ids(); error_log('STUDIOU WC MEDIA: Found ' . count($media_ids) . ' unassigned unused media files to delete'); ob_clean(); wp_send_json_success(array( 'media_ids' => $media_ids, 'total_count' => count($media_ids), 'message' => sprintf( __('Found %d unused uncategorized media files to delete', 'studiou-wc-product-cat-manage'), count($media_ids) ) )); } catch (Exception $e) { error_log('STUDIOU WC MEDIA: Remove unassigned media failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array( 'message' => sprintf(__('Error: %s', 'studiou-wc-product-cat-manage'), $e->getMessage()) )); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * Handle AJAX remove unassigned media batch request */ public function handle_remove_unassigned_media_batch_ajax() { while (ob_get_level()) { ob_end_clean(); } ob_start(); $original_error_reporting = error_reporting(); error_reporting(0); error_log('STUDIOU WC MEDIA: Remove unassigned media batch AJAX handler called'); try { if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage'))); return; } if (!current_user_can('manage_woocommerce')) { wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'))); return; } if (!isset($_POST['media_ids'])) { wp_send_json_error(array('message' => __('Missing required parameters', 'studiou-wc-product-cat-manage'))); return; } $media_ids = array_map('intval', $_POST['media_ids']); error_log('STUDIOU WC MEDIA: Processing batch of ' . count($media_ids) . ' unassigned media for deletion'); $result = $this->delete_media_batch($media_ids); error_log('STUDIOU WC MEDIA: Unassigned batch completed - Deleted: ' . $result['deleted_count'] . ', Failed: ' . $result['failed_count']); ob_clean(); wp_send_json_success(array( 'deleted_count' => $result['deleted_count'], 'failed_count' => $result['failed_count'], 'failed_media' => $result['failed_media'] )); } catch (Exception $e) { error_log('STUDIOU WC MEDIA: Unassigned media batch failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array( 'message' => sprintf(__('Error: %s', 'studiou-wc-product-cat-manage'), $e->getMessage()) )); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } // ======================================================================== // Update Product Variant Prices (UPVP) — v1.5.3 // Bulk-update regular_price on product_variation records by category + // attribute filter. Touches only product records; never order items, carts, // or historical order data. // ======================================================================== /** * Get list of attribute "heads" usable for variations. * * Source 1: every wc_get_attribute_taxonomies() entry (global attributes) * — key = 'pa_', is_taxonomy = true. * Source 2: distinct meta_key starting with 'attribute_' on product_variation * rows that are NOT global taxonomies (custom product-level * attributes flagged for variations) — key = '', * is_taxonomy = false. * * @return array [ ['key' => string, 'label' => string, 'is_taxonomy' => bool], ... ] */ public function get_variation_attribute_heads() { global $wpdb; $heads = array(); $seen = array(); // Source 1: global taxonomies. $taxonomies = function_exists('wc_get_attribute_taxonomies') ? wc_get_attribute_taxonomies() : array(); if (!empty($taxonomies)) { foreach ($taxonomies as $tax) { $key = 'pa_' . $tax->attribute_name; if (isset($seen[$key])) { continue; } $seen[$key] = true; $heads[] = array( 'key' => $key, 'label' => !empty($tax->attribute_label) ? $tax->attribute_label : $tax->attribute_name, 'is_taxonomy' => true, ); } } // Source 2: existing variation postmeta (attribute_*). $sql = " SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} v ON v.ID = pm.post_id WHERE v.post_type = 'product_variation' AND v.post_status IN ('publish','private') AND pm.meta_key LIKE %s "; $rows = $wpdb->get_col($wpdb->prepare($sql, $wpdb->esc_like('attribute_') . '%')); if (!empty($rows)) { foreach ($rows as $meta_key) { $slug = substr($meta_key, strlen('attribute_')); if ($slug === '' || isset($seen[$slug])) { continue; } $seen[$slug] = true; $heads[] = array( 'key' => $slug, 'label' => wc_attribute_label($slug), 'is_taxonomy' => (strpos($slug, 'pa_') === 0) || taxonomy_exists($slug), ); } } // Source 3: variable parent products' _product_attributes postmeta. // Catches attributes flagged "Used for variations" on the product's // Attributes tab, even when (a) the attribute is a custom non-global // entry, or (b) no variations have been generated yet. $sql_parents = " SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = 'product' AND p.post_status IN ('publish','private','draft') AND pm.meta_key = '_product_attributes' "; $serialized_rows = $wpdb->get_col($sql_parents); if (!empty($serialized_rows)) { foreach ($serialized_rows as $serialized) { $attrs = maybe_unserialize($serialized); if (!is_array($attrs)) { continue; } foreach ($attrs as $attr_key => $attr_data) { if (!is_array($attr_data) || empty($attr_data['is_variation'])) { continue; } $is_taxonomy = !empty($attr_data['is_taxonomy']); $name = isset($attr_data['name']) ? $attr_data['name'] : $attr_key; $key = $is_taxonomy ? $name : sanitize_title($name); if ($key === '' || isset($seen[$key])) { continue; } $seen[$key] = true; $heads[] = array( 'key' => $key, 'label' => $is_taxonomy ? wc_attribute_label($key) : $name, 'is_taxonomy' => $is_taxonomy, ); } } } // Sort alphabetically by label. usort($heads, function($a, $b) { return strcasecmp($a['label'], $b['label']); }); return $heads; } /** * Get distinct values for the given attribute key, scoped to existing * variations (and optionally to a set of parent product categories). * * For taxonomy attributes (pa_*) the meta_value is the term slug; we * resolve labels via get_term_by('slug', ..., $taxonomy). * * @param string $attribute_key E.g. 'pa_color' or 'size'. * @param int[] $category_ids Optional list of parent product_cat term IDs. * @return array [ ['value' => string, 'label' => string], ... ] */ public function get_variation_attribute_values($attribute_key, $category_ids = array()) { global $wpdb; $attribute_key = sanitize_text_field($attribute_key); if ($attribute_key === '') { return array(); } $meta_key = 'attribute_' . wc_sanitize_taxonomy_name($attribute_key); $params = array($meta_key); $cat_join = ''; $cat_where = ''; if (!empty($category_ids)) { $cat_ids = array_map('intval', $category_ids); $cat_ids = array_filter($cat_ids, function($v) { return $v > 0; }); if (!empty($cat_ids)) { $placeholders = implode(',', array_fill(0, count($cat_ids), '%d')); $cat_join = " INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id "; $cat_where = " AND tt.taxonomy = 'product_cat' AND tt.term_id IN ($placeholders) "; $params = array_merge($params, $cat_ids); } } $sql = " SELECT DISTINCT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} v ON v.ID = pm.post_id AND v.post_type = 'product_variation' AND v.post_status IN ('publish','private') INNER JOIN {$wpdb->posts} p ON p.ID = v.post_parent AND p.post_type = 'product' $cat_join WHERE pm.meta_key = %s AND pm.meta_value <> '' $cat_where "; $values = $wpdb->get_col($wpdb->prepare($sql, ...$params)); $is_taxonomy = (strpos($attribute_key, 'pa_') === 0) || taxonomy_exists($attribute_key); // Fallback: if SQL returned no values but the attribute exists on a // variable parent's _product_attributes (e.g. variations not generated // yet, or custom attribute), parse pipe-separated values from there. if (empty($values)) { $sanitized_key = $is_taxonomy ? (strpos($attribute_key, 'pa_') === 0 ? $attribute_key : 'pa_' . $attribute_key) : sanitize_title($attribute_key); $sql_parents = " SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = 'product' AND p.post_status IN ('publish','private','draft') AND pm.meta_key = '_product_attributes' "; $rows = $wpdb->get_col($sql_parents); $values = array(); if (!empty($rows)) { foreach ($rows as $serialized) { $attrs = maybe_unserialize($serialized); if (!is_array($attrs) || !isset($attrs[$sanitized_key])) { continue; } $entry = $attrs[$sanitized_key]; if (empty($entry['is_variation'])) { continue; } if ($is_taxonomy) { // Taxonomy custom values are stored as term IDs in 'value'. $term_ids = !empty($entry['value']) ? wp_parse_id_list($entry['value']) : array(); if (empty($term_ids)) { // Or the parent uses all terms — pull them from the taxonomy. $tax_name = $sanitized_key; $terms = get_terms(array('taxonomy' => $tax_name, 'hide_empty' => false)); if (!is_wp_error($terms)) { foreach ($terms as $t) { $values[] = $t->slug; } } } else { foreach ($term_ids as $tid) { $term = get_term($tid, $sanitized_key); if ($term && !is_wp_error($term)) { $values[] = $term->slug; } } } } else { $raw = isset($entry['value']) ? (string) $entry['value'] : ''; if ($raw !== '') { $parts = array_map('trim', explode('|', $raw)); foreach ($parts as $p) { if ($p !== '') { $values[] = $p; } } } } } $values = array_values(array_unique($values)); } } if (empty($values)) { return array(); } $result = array(); foreach ($values as $value) { $label = $value; if ($is_taxonomy) { $tax = (strpos($attribute_key, 'pa_') === 0) ? $attribute_key : 'pa_' . $attribute_key; $term = get_term_by('slug', $value, $tax); if ($term && !is_wp_error($term)) { $label = $term->name; } } $result[] = array( 'value' => $value, 'label' => $label, ); } usort($result, function($a, $b) { return strcasecmp($a['label'], $b['label']); }); return $result; } /** * Find variations matching the filter. * * @param int[] $category_ids * @param string $attribute_key * @param string $attribute_value * @return array ['count' => int, 'ids' => int[], 'sample' => array[]] */ public function find_matching_variations($category_ids, $attribute_key, $attribute_value) { global $wpdb; $category_ids = array_filter(array_map('intval', (array) $category_ids), function($v) { return $v > 0; }); $attribute_key = sanitize_text_field($attribute_key); $attribute_value = sanitize_text_field($attribute_value); if (empty($category_ids) || $attribute_key === '' || $attribute_value === '') { return array('count' => 0, 'ids' => array(), 'sample' => array()); } $meta_key = 'attribute_' . wc_sanitize_taxonomy_name($attribute_key); $placeholders = implode(',', array_fill(0, count($category_ids), '%d')); $sql = " SELECT DISTINCT v.ID FROM {$wpdb->posts} v INNER JOIN {$wpdb->posts} p ON p.ID = v.post_parent INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = v.ID INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE v.post_type = 'product_variation' AND v.post_status IN ('publish','private') AND p.post_type = 'product' AND tt.taxonomy = 'product_cat' AND tt.term_id IN ($placeholders) AND pm.meta_key = %s AND pm.meta_value = %s "; $params = array_merge($category_ids, array($meta_key, $attribute_value)); $ids = $wpdb->get_col($wpdb->prepare($sql, ...$params)); $ids = $ids ? array_map('intval', $ids) : array(); // Build a sample (up to 20). $sample = array(); $slice = array_slice($ids, 0, 20); foreach ($slice as $id) { $variation = wc_get_product($id); if (!$variation) { continue; } $parent_id = $variation->get_parent_id(); $parent_name = ''; if ($parent_id) { $parent = wc_get_product($parent_id); if ($parent) { $parent_name = $parent->get_name(); } } $sample[] = array( 'id' => $id, 'sku' => $variation->get_sku(), 'name' => $variation->get_name(), 'parent_name' => $parent_name, 'current_price' => $variation->get_regular_price(), ); } return array( 'count' => count($ids), 'ids' => $ids, 'sample' => $sample, ); } /** * Apply new regular price to a list of variation IDs. * * @param int[] $variation_ids * @param string $new_price * @return array ['updated','failed','failed_ids','message'] */ public function apply_variant_price($variation_ids, $new_price) { $variation_ids = array_filter(array_map('intval', (array) $variation_ids), function($v) { return $v > 0; }); // Validate price. if (!is_numeric($new_price) || (float) $new_price < 0) { return array( 'updated' => 0, 'failed' => 0, 'failed_ids' => array(), 'message' => __('Please enter a valid new price', 'studiou-wc-product-cat-manage'), 'error' => true, ); } $formatted = wc_format_decimal($new_price); $new_price_f = (float) $formatted; $updated = 0; $failed = 0; $failed_ids = array(); $parent_ids = array(); // v1.7.1 — L7: collect variations whose existing sale_price is higher // than the new regular_price. We don't auto-clamp (sale data is // operator-owned) but we surface the inconsistency so the operator // can fix it. Returned in the response so the UI can show it. $sale_warnings = array(); foreach ($variation_ids as $id) { try { $variation = wc_get_product($id); if (!$variation || !$variation->is_type('variation')) { $failed++; $failed_ids[] = $id; error_log('STUDIOU WC UPVP: Variation ' . $id . ' not found or not a variation'); continue; } $sale_price_raw = $variation->get_sale_price(); if ($sale_price_raw !== '' && $sale_price_raw !== null) { $sale_f = (float) $sale_price_raw; if ($sale_f > $new_price_f) { error_log(sprintf( 'STUDIOU WC UPVP: variation %d has sale_price %s greater than new regular_price %s — sale > regular after update; review needed', $id, $sale_price_raw, $formatted )); $sale_warnings[] = array( 'id' => $id, 'sale_price' => $sale_price_raw, 'new_regular' => $formatted, ); } } $variation->set_regular_price($formatted); $variation->save(); $updated++; $parent_id = $variation->get_parent_id(); if ($parent_id) { $parent_ids[$parent_id] = true; } } catch (Exception $e) { $failed++; $failed_ids[] = $id; error_log('STUDIOU WC UPVP: Exception updating variation ' . $id . ' - ' . $e->getMessage()); } } // Re-sync parent variable products so price-range cache reflects the new prices. if (!empty($parent_ids) && class_exists('WC_Product_Variable')) { foreach (array_keys($parent_ids) as $parent_id) { try { WC_Product_Variable::sync($parent_id); if (function_exists('wc_delete_product_transients')) { wc_delete_product_transients($parent_id); } } catch (Exception $e) { error_log('STUDIOU WC UPVP: Failed to sync parent ' . $parent_id . ' - ' . $e->getMessage()); } } } $message = sprintf( __('Successfully updated %d variations.', 'studiou-wc-product-cat-manage'), $updated ); if ($failed > 0) { $message .= ' ' . sprintf( __('Failed to update %d variations.', 'studiou-wc-product-cat-manage'), $failed ); } if (!empty($sale_warnings)) { $message .= ' ' . sprintf( /* translators: %d: number of variations whose existing sale_price is now greater than the new regular_price */ __('Warning: %d variations have a sale price greater than the new regular price (sale > regular). Review the variations and adjust the sale price.', 'studiou-wc-product-cat-manage'), count($sale_warnings) ); } return array( 'updated' => $updated, 'failed' => $failed, 'failed_ids' => $failed_ids, 'sale_warnings' => $sale_warnings, 'message' => $message, ); } /** * Common AJAX preflight: nonce + capability + output buffer setup. * Returns false on failure (after sending JSON error), true on success. */ private function upvp_preflight() { while (ob_get_level()) { ob_end_clean(); } ob_start(); if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) { ob_clean(); wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage'))); return false; } if (!current_user_can('manage_woocommerce')) { ob_clean(); wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'))); return false; } return true; } /** * AJAX: list variation attribute heads. */ public function handle_upvp_get_attributes_ajax() { if (!$this->upvp_preflight()) return; $original_error_reporting = error_reporting(); error_reporting(0); try { $heads = $this->get_variation_attribute_heads(); ob_clean(); wp_send_json_success(array('attributes' => $heads)); } catch (Exception $e) { error_log('STUDIOU WC UPVP: get_attributes failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array('message' => $e->getMessage())); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * AJAX: list values for the chosen attribute (scoped to selected categories). */ public function handle_upvp_get_attribute_values_ajax() { if (!$this->upvp_preflight()) return; $original_error_reporting = error_reporting(); error_reporting(0); try { $attribute_key = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : ''; $category_ids = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array(); if ($attribute_key === '') { ob_clean(); wp_send_json_error(array('message' => __('Please select an attribute', 'studiou-wc-product-cat-manage'))); return; } $values = $this->get_variation_attribute_values($attribute_key, $category_ids); ob_clean(); wp_send_json_success(array('values' => $values)); } catch (Exception $e) { error_log('STUDIOU WC UPVP: get_attribute_values failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array('message' => $e->getMessage())); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * AJAX: count + sample of variations matching the current filter. */ public function handle_upvp_count_ajax() { if (!$this->upvp_preflight()) return; $original_error_reporting = error_reporting(); error_reporting(0); try { $category_ids = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array(); $attribute_key = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : ''; $attribute_value = isset($_POST['attribute_value']) ? sanitize_text_field(wp_unslash($_POST['attribute_value'])) : ''; if (empty($category_ids)) { ob_clean(); wp_send_json_error(array('message' => __('Please select at least one category', 'studiou-wc-product-cat-manage'))); return; } if ($attribute_key === '') { ob_clean(); wp_send_json_error(array('message' => __('Please select an attribute', 'studiou-wc-product-cat-manage'))); return; } if ($attribute_value === '') { ob_clean(); wp_send_json_error(array('message' => __('Please select an attribute value', 'studiou-wc-product-cat-manage'))); return; } $result = $this->find_matching_variations($category_ids, $attribute_key, $attribute_value); ob_clean(); wp_send_json_success(array( 'count' => $result['count'], 'sample' => $result['sample'], )); } catch (Exception $e) { error_log('STUDIOU WC UPVP: count failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array('message' => $e->getMessage())); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } /** * AJAX: apply new regular price to all matching variations. */ public function handle_upvp_apply_ajax() { if (!$this->upvp_preflight()) return; $original_error_reporting = error_reporting(); error_reporting(0); try { $category_ids = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array(); $attribute_key = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : ''; $attribute_value = isset($_POST['attribute_value']) ? sanitize_text_field(wp_unslash($_POST['attribute_value'])) : ''; $new_price = isset($_POST['new_price']) ? sanitize_text_field(wp_unslash($_POST['new_price'])) : ''; if (empty($category_ids)) { ob_clean(); wp_send_json_error(array('message' => __('Please select at least one category', 'studiou-wc-product-cat-manage'))); return; } if ($attribute_key === '' || $attribute_value === '') { ob_clean(); wp_send_json_error(array('message' => __('Please select an attribute value', 'studiou-wc-product-cat-manage'))); return; } if ($new_price === '' || !is_numeric($new_price) || (float) $new_price < 0) { ob_clean(); wp_send_json_error(array('message' => __('Please enter a valid new price', 'studiou-wc-product-cat-manage'))); return; } $matching = $this->find_matching_variations($category_ids, $attribute_key, $attribute_value); if ($matching['count'] === 0) { ob_clean(); wp_send_json_error(array('message' => __('No variations match the current filter.', 'studiou-wc-product-cat-manage'))); return; } $result = $this->apply_variant_price($matching['ids'], $new_price); ob_clean(); wp_send_json_success(array( 'updated' => $result['updated'], 'failed' => $result['failed'], 'failed_ids' => $result['failed_ids'], 'message' => $result['message'], )); } catch (Exception $e) { error_log('STUDIOU WC UPVP: apply failed - ' . $e->getMessage()); ob_clean(); wp_send_json_error(array('message' => $e->getMessage())); } finally { error_reporting($original_error_reporting); ob_end_clean(); } } }