attachment ID, so the same URL * appearing multiple times within one AJAX batch costs at most one DB lookup. * * @var array */ private static $url_cache = array(); /** * Constructor * * @param Studiou_WC_Product_Manage_Product_DB $db Database instance */ public function __construct($db) { $this->db = $db; } /** * v1.7.0 — Return (and lazily create) the guarded staging directory used * by both the product and category importers. Replaces the transient- * based staging that capped at max_allowed_packet (M4). * * @return string Absolute path with trailing slash. */ public static function get_staging_dir() { $upload_dir = wp_upload_dir(); $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-staging/'; if (!file_exists($dir)) { wp_mkdir_p($dir); } $idx = $dir . 'index.php'; if (!file_exists($idx)) { @file_put_contents($idx, ' count($rows), 'batch' => array_slice($rows, $offset, $batch_size), ); } /** * v1.7.0 — Delete a staged file (e.g. on completion). * * @param string $token */ public static function delete_stage($token) { if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) { return; } @unlink(self::get_staging_dir() . $token . '.json'); } /** * Parse CSV file and store data for batch processing * * @param string $file_path Path to CSV file * @return array Result with total count and transient key */ public function parse_csv_for_batch($file_path) { if (!file_exists($file_path)) { return array('error' => __('File not found', 'studiou-wc-product-cat-manage')); } // Open CSV file $handle = fopen($file_path, 'r'); if (!$handle) { return array('error' => __('Could not open file', 'studiou-wc-product-cat-manage')); } // M3 — strip UTF-8 BOM if present so the first header cell isn't // "\xEF\xBB\xBFID", which would make every row miss the ID key and // be treated as new (and then likely skipped by SKU-uniqueness). $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") { rewind($handle); } // Read header row $header = fgetcsv($handle, 0, ',', '"'); if (!$header) { fclose($handle); return array('error' => __('Invalid CSV format', 'studiou-wc-product-cat-manage')); } $header_count = count($header); // Read all data rows $rows = array(); $skipped_rows = array(); $row_number = 1; while (($row = fgetcsv($handle, 0, ',', '"')) !== false) { $row_number++; // Skip empty rows if (empty(array_filter($row))) { continue; } // H4 — guard array_combine: on PHP 8+ it throws ValueError when // the row width doesn't match the header. ValueError extends // Error (not Exception), so the surrounding catches miss it and // the whole import dies with a bare 500. Pad short rows (likely // trailing empty cells from Excel); skip and report over-long // rows (an unescaped comma we can't safely recover). $row_count = count($row); if ($row_count < $header_count) { $row = array_pad($row, $header_count, ''); } elseif ($row_count > $header_count) { $padded_for_failed = array_pad($row, $header_count, ''); $padded_for_failed = array_slice($padded_for_failed, 0, $header_count); $skipped_rows[] = array( 'row_number' => $row_number, 'data' => array_combine($header, $padded_for_failed), 'reason' => sprintf( /* translators: 1: row number, 2: actual column count, 3: expected column count */ __('Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped.', 'studiou-wc-product-cat-manage'), $row_number, $row_count, $header_count ), ); continue; } // Combine header with row data $data = array_combine($header, $row); $rows[] = array( 'row_number' => $row_number, 'data' => $data ); } fclose($handle); // M4 (guard) — set_transient returns false when the serialized // payload exceeds max_allowed_packet or storage fails for any // other reason. The downstream batch handler then reports // "Import data not found or expired", which is misleading — the // upload was fine, the store just couldn't take it. Surface a // clear, actionable message instead. The disk-staging rework that // actually removes this ceiling lands in Phase D. // v1.7.0 — M4 rework: stage rows on disk instead of in a transient. // The transient was a single wp_options row; large CSVs overflowed // max_allowed_packet and set_transient returned false, surfacing as // the misleading "Import data not found or expired" downstream. // Disk staging has no such ceiling. The staging file is guarded // (index.php + .htaccess), token-named, and pruned at 24h. $payload = array( 'rows' => $rows, 'skipped' => $skipped_rows, ); $token = self::stage_rows($payload, 'product'); if ($token === false) { return array('error' => __('Failed to stage import data on disk (write error).', 'studiou-wc-product-cat-manage')); } return array( // Legacy field name preserved so the JS keeps working without // changes; the value is now a disk-staging token, not a // transient key. process_batch reads from disk via the token. 'transient_key' => $token, 'total' => count($rows), ); } /** * Process batch of products * * @param string $transient_key Transient key with CSV data * @param int $offset Starting offset * @param int $batch_size Number of items to process * @return array Result with statistics */ public function process_batch($transient_key, $offset = 0, $batch_size = 5) { // v1.7.0 — read from disk-staging instead of the WP transient. // First load the full payload so we know total + skipped rows. $dir = self::get_staging_dir(); if (!preg_match('/^product-[a-f0-9]{32}$/', $transient_key)) { return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')); } $path = $dir . $transient_key . '.json'; if (!file_exists($path)) { return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')); } $raw = file_get_contents($path); if ($raw === false) { return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')); } $payload = json_decode($raw, true); if (!is_array($payload) || !isset($payload['rows'])) { return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')); } $rows = $payload['rows']; $skipped_at_parse = isset($payload['skipped']) ? $payload['skipped'] : array(); $result = array( 'success' => 0, 'failed' => 0, 'skipped' => 0, 'failed_items' => array(), 'messages' => array(), 'processed' => 0, 'total' => count($rows) ); // Get batch of rows to process $batch = array_slice($rows, $offset, $batch_size); // On the final batch, fold in any ragged-row skips from the parse // step (set by parse_csv_for_batch when row width != header width) // so they appear in the failed-CSV download and the messages list. $is_final_batch = ($offset + count($batch)) >= count($rows); if ($is_final_batch && !empty($skipped_at_parse)) { foreach ($skipped_at_parse as $skip) { $result['skipped']++; $result['messages'][] = $skip['reason']; $result['failed_items'][] = $skip['data']; } } foreach ($batch as $item) { $row_number = $item['row_number']; $data = $item['data']; // Process the product $process_result = $this->process_product_row($data, $row_number); if ($process_result['status'] === 'success') { $result['success']++; // Don't add success messages to log } elseif ($process_result['status'] === 'skipped') { $result['skipped']++; $result['messages'][] = sprintf( __('Row %d: SKIPPED - %s', 'studiou-wc-product-cat-manage'), $row_number, $process_result['message'] ); // Add to failed items for re-processing $result['failed_items'][] = $data; } else { $result['failed']++; $result['messages'][] = sprintf( __('Row %d: FAILED - %s', 'studiou-wc-product-cat-manage'), $row_number, $process_result['message'] ); // Add to failed items $result['failed_items'][] = $data; } } $result['processed'] = $offset + count($batch); // v1.7.0 — delete the staging file when the import is complete so // we don't leak token-named JSON files into uploads/. (Pruning at // 24h would catch them eventually, but cleaning at completion is // the precise time.) if ($is_final_batch) { self::delete_stage($transient_key); } return $result; } /** * Process single product row * * @param array $data Product data * @param int $row_number Row number for error reporting * @return array Result with status and message */ private function process_product_row($data, $row_number) { // Validate required fields if (!isset($data['Typ']) || empty($data['Typ'])) { return array( 'status' => 'skipped', 'message' => __('Missing product type', 'studiou-wc-product-cat-manage') ); } // Rule 3: Only allow "variable" or "variation" types $type = trim($data['Typ']); if (!in_array($type, array('variable', 'variation'))) { return array( 'status' => 'skipped', 'message' => sprintf( __('Invalid product type: %s (only "variable" or "variation" allowed)', 'studiou-wc-product-cat-manage'), $type ) ); } // Get product ID $product_id = isset($data['ID']) ? intval($data['ID']) : 0; $sku = isset($data['Katalogové číslo']) ? trim($data['Katalogové číslo']) : ''; // Rule 4: SKU must be unique if (empty($sku)) { return array( 'status' => 'skipped', 'message' => __('Missing SKU (Katalogové číslo)', 'studiou-wc-product-cat-manage') ); } if (!$this->db->is_sku_unique($sku, $product_id)) { return array( 'status' => 'skipped', 'message' => sprintf( __('SKU "%s" already exists', 'studiou-wc-product-cat-manage'), $sku ) ); } // Rule 5: Validate image URL if provided $image_url = isset($data['Obrázky']) ? trim($data['Obrázky']) : ''; if (!empty($image_url) && !$this->db->validate_image_url($image_url)) { return array( 'status' => 'skipped', 'message' => sprintf( __('Invalid image URL: %s', 'studiou-wc-product-cat-manage'), $image_url ) ); } // Process based on product type if ($type === 'variation') { return $this->process_variation($data, $product_id); } else { return $this->process_variable_product($data, $product_id); } } /** * Process variable product * * @param array $data Product data * @param int $product_id Product ID (0 for new product) * @return array Result with status and message */ private function process_variable_product($data, $product_id) { try { // Create or update product if ($product_id == 0) { $product = new WC_Product_Variable(); } else { $product = wc_get_product($product_id); if (!$product || !$product->is_type('variable')) { return array( 'status' => 'error', 'message' => sprintf( __('Product ID %d is not a variable product', 'studiou-wc-product-cat-manage'), $product_id ) ); } } // Set basic data $product->set_name(isset($data['Jméno']) ? $data['Jméno'] : ''); $product->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : ''); $product->set_short_description(isset($data['Krátký popis']) ? $data['Krátký popis'] : ''); $product->set_description(isset($data['Popis']) ? $data['Popis'] : ''); // v1.7.1 — M7: gate success-path logging behind WP_DEBUG so a // normal production import doesn't write dozens of lines per // row to error.log. Failure-path logs stay always-on (matches // the documented "Only logs failures" policy). $debug = defined('WP_DEBUG') && WP_DEBUG; if (isset($data['Kategorie']) && !empty($data['Kategorie'])) { $categories = array_map('trim', explode('|', $data['Kategorie'])); $category_ids = array(); if ($debug) { error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name()); error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']); } foreach ($categories as $category_name) { if (empty($category_name)) { continue; } $category_id = $this->find_category_by_name($category_name); if ($debug) { error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND')); } if ($category_id) { $category_ids[] = $category_id; } } if ($debug) { error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids)); } if (!empty($category_ids)) { $product->set_category_ids($category_ids); if ($debug) { error_log('STUDIOU WC IMPORT: Categories assigned to product via set_category_ids()'); } } else { // Always-on: a row that names categories but resolves zero is a real failure worth logging. error_log('STUDIOU WC IMPORT: WARNING - No valid category IDs found for: ' . $data['Kategorie']); } } else if ($debug) { error_log('STUDIOU WC IMPORT: No categories in CSV for product: ' . $product->get_name()); } // Handle image if (isset($data['Obrázky']) && !empty($data['Obrázky'])) { $image_id = $this->upload_image_from_url($data['Obrázky']); if ($image_id) { $product->set_image_id($image_id); } } // Set reviews enabled $reviews_enabled = isset($data['Povolit zákaznické recenze?']) && $data['Povolit zákaznické recenze?'] === '1'; $product->set_reviews_allowed($reviews_enabled); // v1.6.3 — Handle product attribute (taxonomy vs. custom). // // Pre-1.6.3 behaviour ignored the `Vlastnost 1 globální` column and // forced every attribute into a global pa_ taxonomy: it called // create_attribute_if_not_exists() unconditionally, set the // attribute name via wc_attribute_taxonomy_name(), and passed raw // name strings to set_options(). Result: custom (product-level) // attributes silently became global taxonomies; for variations, // raw names landed where slugs were required, so the storefront // couldn't match the customer's selection ("No matching variation"). // // Branch on the global flag and, for the taxonomy path, resolve // each value to a term (creating it if missing), pass term IDs to // set_options(), remember the slugs so process_variation() can // store the correct value, and link the terms via // wp_set_object_terms after the product save. // // Hand-built CSVs that omit `Vlastnost 1 globální` default to the // custom branch. // // LIVE-TEST GATE: §5.B.0 of review-00-plan-00.md asks for a real // storefront round-trip before deploy. Verify both the taxonomy // and custom paths there. $term_ids_for_link = array(); $taxonomy_for_link = ''; if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti'])) { $attribute_name = trim($data['Název 1 vlastnosti']); $attribute_values = isset($data['Hodnota(y) 1 vlastnosti']) ? $data['Hodnota(y) 1 vlastnosti'] : ''; $is_visible = isset($data['Vlastnost 1 viditelnost']) && $data['Vlastnost 1 viditelnost'] === '1'; $is_variation = isset($data['Vlastnost 1 varianta']) && $data['Vlastnost 1 varianta'] === '1'; $is_global = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1'; $values = array_map('trim', explode('|', $attribute_values)); $values = array_values(array_filter($values, function($v) { return $v !== ''; })); $attribute = new WC_Product_Attribute(); if ($is_global) { // Taxonomy attribute — register, resolve/create terms, // pass term IDs (not names) to set_options. $this->db->create_attribute_if_not_exists($attribute_name); $taxonomy = wc_attribute_taxonomy_name($attribute_name); if (!taxonomy_exists($taxonomy)) { register_taxonomy($taxonomy, 'product'); } $term_ids = array(); foreach ($values as $value) { $term = get_term_by('name', $value, $taxonomy); if (!$term) { $inserted = wp_insert_term($value, $taxonomy); if (is_wp_error($inserted)) { error_log('STUDIOU WC IMPORT: failed to insert term "' . $value . '" into ' . $taxonomy . ': ' . $inserted->get_error_message()); continue; } $term = get_term($inserted['term_id'], $taxonomy); } if ($term && !is_wp_error($term)) { $term_ids[] = (int) $term->term_id; } } $attribute->set_id(wc_attribute_taxonomy_id_by_name($attribute_name)); $attribute->set_name($taxonomy); $attribute->set_options($term_ids); $attribute->set_visible($is_visible); $attribute->set_variation($is_variation); $product->set_attributes(array($attribute)); // Defer term-relationship linkage until after save() so we // have a real product ID. $term_ids_for_link = $term_ids; $taxonomy_for_link = $taxonomy; } else { // Custom (product-level) attribute — no taxonomy, store // the raw display name and the raw values. $attribute->set_id(0); $attribute->set_name($attribute_name); $attribute->set_options($values); $attribute->set_visible($is_visible); $attribute->set_variation($is_variation); $product->set_attributes(array($attribute)); } } // Handle minimum/maximum quantity if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) { $product->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity'])); } if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) { $product->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity'])); } // Save product $saved_id = $product->save(); // Belt-and-braces: link the attribute terms to the freshly-saved // product. Modern WC CRUD typically does this on save() when // set_options() receives term IDs, but the link is cheap and // makes the import robust against WC internals changes. if (!empty($term_ids_for_link) && $taxonomy_for_link !== '' && $saved_id) { wp_set_object_terms($saved_id, $term_ids_for_link, $taxonomy_for_link, false); } $message = $product_id == 0 ? sprintf(__('Created variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name()) : sprintf(__('Updated variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name()); return array( 'status' => 'success', 'message' => $message ); } catch (Exception $e) { return array( 'status' => 'error', 'message' => $e->getMessage() ); } } /** * Process product variation * * @param array $data Product data * @param int $variation_id Variation ID (0 for new variation) * @return array Result with status and message */ private function process_variation($data, $variation_id) { try { // Rule 6: Must have parent reference if (!isset($data['Nadřazené']) || empty($data['Nadřazené'])) { return array( 'status' => 'skipped', 'message' => __('Missing parent product reference (Nadřazené)', 'studiou-wc-product-cat-manage') ); } // Rule 7: Must have regular price if (!isset($data['Běžná cena']) || empty($data['Běžná cena'])) { return array( 'status' => 'skipped', 'message' => __('Missing regular price (Běžná cena)', 'studiou-wc-product-cat-manage') ); } // Find parent product by SKU $parent_sku = trim($data['Nadřazené']); $parent_product = $this->db->get_product_by_sku($parent_sku); if (!$parent_product || !$parent_product->is_type('variable')) { return array( 'status' => 'skipped', 'message' => sprintf( __('Parent product with SKU "%s" not found or not variable', 'studiou-wc-product-cat-manage'), $parent_sku ) ); } // Create or update variation if ($variation_id == 0) { $variation = new WC_Product_Variation(); $variation->set_parent_id($parent_product->get_id()); } else { $variation = wc_get_product($variation_id); if (!$variation || !$variation->is_type('variation')) { return array( 'status' => 'error', 'message' => sprintf( __('Product ID %d is not a variation', 'studiou-wc-product-cat-manage'), $variation_id ) ); } } // Set basic data $variation->set_name(isset($data['Jméno']) ? $data['Jméno'] : ''); $variation->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : ''); $variation->set_description(isset($data['Popis']) ? $data['Popis'] : ''); $variation->set_regular_price($data['Běžná cena']); // Handle image if (isset($data['Obrázky']) && !empty($data['Obrázky'])) { $image_id = $this->upload_image_from_url($data['Obrázky']); if ($image_id) { $variation->set_image_id($image_id); } } // v1.6.3 — Variation attribute (taxonomy vs. custom). // // Pre-1.6.3 stored the raw value (e.g. "Red") under the taxonomy // meta key (e.g. attribute_pa_color). WC matches variations by // term slug ("red"), so the variation was unreachable on the // storefront. Now: for taxonomy attributes resolve the value to // its term slug; for custom attributes store the literal value // under attribute_. if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti']) && isset($data['Hodnota(y) 1 vlastnosti']) && !empty($data['Hodnota(y) 1 vlastnosti'])) { $attribute_name = trim($data['Název 1 vlastnosti']); $attribute_value = trim($data['Hodnota(y) 1 vlastnosti']); $is_global = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1'; if ($is_global) { $taxonomy = wc_attribute_taxonomy_name($attribute_name); $term = get_term_by('name', $attribute_value, $taxonomy); if (!$term) { // The variation row may arrive before its parent's // taxonomy terms exist (rare but possible on hand-built // CSVs). Create it so the variation is selectable. if (!taxonomy_exists($taxonomy)) { $this->db->create_attribute_if_not_exists($attribute_name); register_taxonomy($taxonomy, 'product'); } $inserted = wp_insert_term($attribute_value, $taxonomy); if (!is_wp_error($inserted)) { $term = get_term($inserted['term_id'], $taxonomy); } } $slug = ($term && !is_wp_error($term)) ? $term->slug : sanitize_title($attribute_value); $variation->set_attributes(array($taxonomy => $slug)); } else { $variation->set_attributes(array( sanitize_title($attribute_name) => $attribute_value, )); } } // Handle minimum/maximum quantity if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) { $variation->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity'])); } if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) { $variation->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity'])); } // Save variation $saved_id = $variation->save(); $message = $variation_id == 0 ? sprintf(__('Created variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name()) : sprintf(__('Updated variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name()); return array( 'status' => 'success', 'message' => $message ); } catch (Exception $e) { return array( 'status' => 'error', 'message' => $e->getMessage() ); } } /** * Find an attachment previously imported from this exact source URL. * * Uses get_posts (not a raw $wpdb query) so a row whose attachment post was * deleted but whose meta was somehow orphaned can't resolve to a missing post. * Orders by ID ascending so the earliest (canonical) attachment wins * deterministically if more than one ever carries the same source URL. * * @param string $url Trimmed source URL. * @return int Attachment ID, or 0 if none. */ private function find_attachment_by_source_url($url) { $ids = get_posts(array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_query' => array( array( 'key' => '_studiou_wcpcm_source_url', 'value' => $url, 'compare' => '=', ), ), )); return !empty($ids) ? (int) $ids[0] : 0; } /** * Upload an image from a URL, deduplicating by source URL. * * A given source URL is downloaded at most once: an in-request static cache * absorbs repeats within a batch, and a `_studiou_wcpcm_source_url` postmeta * lookup reuses attachments across batches and across imports. * * @param string $url Image URL (raw value from the Obrázky column). * @return int|null Attachment ID, or null on empty input / download failure. */ private function upload_image_from_url($url) { // Normalize the key. process_product_row() validates a *trimmed* URL but // the callers pass the raw cell, so trim here to keep the cache key, the // DB lookup, the download, and the stored postmeta all identical to the // value that was validated (and to collapse whitespace-only variants). $url = is_string($url) ? trim($url) : ''; if ($url === '') { return null; } // Layer 2 — in-request static cache. if (isset(self::$url_cache[$url])) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC IMPORT: image cache HIT (request) ' . $url . ' -> ' . self::$url_cache[$url]); } return self::$url_cache[$url]; } // Layer 1 — persistent postmeta lookup (across batches and re-imports). $existing = $this->find_attachment_by_source_url($url); if ($existing) { self::$url_cache[$url] = $existing; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC IMPORT: image cache HIT (db) ' . $url . ' -> ' . $existing); } return $existing; } // Layer 0 — fresh sideload. require_once(ABSPATH . 'wp-admin/includes/media.php'); require_once(ABSPATH . 'wp-admin/includes/file.php'); require_once(ABSPATH . 'wp-admin/includes/image.php'); // Suppress intermediate sizes for import speed. Use the stable // '__return_empty_array' callable (not a fresh closure) so remove_filter() // can actually match it, and detach it in finally so it can never leak // into later media operations in this request. add_filter('intermediate_image_sizes_advanced', '__return_empty_array'); try { // download_url()'s second arg sets the request timeout (30s); no // http_request_timeout filter is needed. $tmp = download_url($url, 30); if (is_wp_error($tmp)) { error_log('STUDIOU WC IMPORT: image download failed for ' . $url . ' - ' . $tmp->get_error_message()); return null; } // Derive a clean on-disk filename. parse_url() strips any query string // (img.jpg?v=2 -> img.jpg) so wp_unique_filename() doesn't fold the // query into the basename. Null-safe: parse_url() may return null/false // and basename(null) is deprecated on PHP 8.1+ (we require 8.2). $path = parse_url($url, PHP_URL_PATH); $name = (is_string($path) && $path !== '') ? basename($path) : ''; if ($name === '') { $name = 'image-' . md5($url) . '.jpg'; } $file_array = array( 'name' => $name, 'tmp_name' => $tmp, ); $id = media_handle_sideload($file_array, 0); if (is_wp_error($id)) { @unlink($tmp); error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message()); return null; } // Tag the attachment so future rows / batches / imports dedup on it. update_post_meta($id, '_studiou_wcpcm_source_url', $url); self::$url_cache[$url] = (int) $id; if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU WC IMPORT: image sideloaded ' . $url . ' -> ' . $id); } return (int) $id; } catch (Exception $e) { error_log('STUDIOU WC IMPORT: image upload exception for ' . $url . ' - ' . $e->getMessage()); return null; } finally { remove_filter('intermediate_image_sizes_advanced', '__return_empty_array'); } } /** * v1.7.1 — M8: per-request cache of the entire product_cat term list. * Populated lazily on first miss-path call to get_terms(); reused for * every subsequent row in the same AJAX batch. Resets naturally when * the request ends, so changes mid-import (e.g. categories we just * created) are picked up — wp_insert_term + cache append is handled * inline below. * * @var array|null Terms keyed by term_id, or null if not loaded. */ private static $all_product_cats_cache = null; /** * v1.7.1 — M8: thin alias of find_or_create_category_by_name() so * existing call sites keep compiling. The rename clarifies that this * method also CREATES the term as a side effect, which used to be * surprising for a function called "find". * * @param string $category_name * @return int|null */ private function find_category_by_name($category_name) { return $this->find_or_create_category_by_name($category_name); } /** * Find a category by name; create it if missing. * * @param string $category_name * @return int|null Term ID or null if creation failed. */ private function find_or_create_category_by_name($category_name) { $debug = defined('WP_DEBUG') && WP_DEBUG; $category_name = trim($category_name); if (empty($category_name)) { return null; } if ($debug) { error_log('STUDIOU WC IMPORT: find_or_create_category_by_name() called for: "' . $category_name . '"'); } // Method 1: Exact name match $term = get_term_by('name', $category_name, 'product_cat'); if ($term) { if ($debug) { error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id); } return $term->term_id; } // Method 2: Try by slug $slug = sanitize_title($category_name); if ($debug) { error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"'); } $term = get_term_by('slug', $slug, 'product_cat'); if ($term) { if ($debug) { error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id); } return $term->term_id; } // Method 3: Search case-insensitively through all categories. Pre-1.7.1 // this called get_terms() on EVERY miss-path row — O(N) categories per // miss, O(N*M) overall on a multi-row import. Now we load once per // request and reuse. if (self::$all_product_cats_cache === null) { $loaded = get_terms(array( 'taxonomy' => 'product_cat', 'hide_empty' => false, )); self::$all_product_cats_cache = is_array($loaded) ? $loaded : array(); } if ($debug) { error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count(self::$all_product_cats_cache) . ' categories case-insensitively'); } foreach (self::$all_product_cats_cache as $t) { if (strcasecmp($t->name, $category_name) === 0) { if ($debug) { error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"'); } return $t->term_id; } } // Not found — create it (always-on log because category creation is a real side effect). error_log('STUDIOU WC IMPORT: Category NOT FOUND: "' . $category_name . '" - Attempting to create it'); $new_term = wp_insert_term($category_name, 'product_cat', array('slug' => $slug)); if (is_wp_error($new_term)) { error_log('STUDIOU WC IMPORT: Failed to create category "' . $category_name . '": ' . $new_term->get_error_message()); if ($debug) { error_log('STUDIOU WC IMPORT: Total available categories: ' . count(self::$all_product_cats_cache)); $sample_cats = array_slice(self::$all_product_cats_cache, 0, 20); foreach ($sample_cats as $cat) { error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')'); } } return null; } $term_id = (int) $new_term['term_id']; // Append the new term to the cache so subsequent rows see it // without re-running get_terms(). $fresh = get_term($term_id, 'product_cat'); if ($fresh && !is_wp_error($fresh)) { self::$all_product_cats_cache[] = $fresh; } if ($debug) { error_log('STUDIOU WC IMPORT: Successfully created category "' . $category_name . '" with ID: ' . $term_id); } return $term_id; } /** * Generate CSV with failed items * * @param array $failed_items Array of failed product data * @return string CSV content */ public function generate_failed_csv($failed_items) { if (empty($failed_items)) { return ''; } // Define CSV header $header = array( 'ID', 'Typ', 'Katalogové číslo', 'Jméno', 'Nadřazené', 'Krátký popis', 'Popis', 'Název 1 vlastnosti', 'Hodnota(y) 1 vlastnosti', 'Kategorie', 'Obrázky', 'Běžná cena', 'Vlastnost 1 viditelnost', 'Vlastnost 1 globální', 'Vlastnost 1 varianta', 'Povolit zákaznické recenze?', 'Minimum Quantity', 'Maximum Quantity' ); // Start CSV output $csv = ''; $csv .= '"' . implode('","', $header) . '"' . "\n"; // Add failed items foreach ($failed_items as $item) { $row = array(); foreach ($header as $column) { $value = isset($item[$column]) ? $item[$column] : ''; $row[] = '"' . str_replace('"', '""', $value) . '"'; } $csv .= implode(',', $row) . "\n"; } return $csv; } }