| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042 |
- <?php
- /**
- * Product import class
- *
- * Handles product import from CSV files
- *
- * @since 1.4.0
- * @package Studiou_WC_Product_Cat_Manage
- * @subpackage Studiou_WC_Product_Cat_Manage/includes
- */
- // If this file is called directly, abort.
- if (!defined('WPINC')) {
- die;
- }
- class Studiou_WC_Product_Manage_Product_Import {
- /**
- * @var Studiou_WC_Product_Manage_Product_DB
- */
- private $db;
- /**
- * In-request cache of trimmed source URL => attachment ID, so the same URL
- * appearing multiple times within one AJAX batch costs at most one DB lookup.
- *
- * @var array<string,int>
- */
- 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, '<?php // Silence is golden.');
- }
- $ht = $dir . '.htaccess';
- if (!file_exists($ht)) {
- @file_put_contents($ht, "Deny from all\n");
- }
- return $dir;
- }
- /**
- * v1.7.0 — Prune staging files older than 24h.
- */
- public static function prune_staging() {
- $dir = self::get_staging_dir();
- $cutoff = time() - DAY_IN_SECONDS;
- $files = glob($dir . '*.json');
- if (!is_array($files)) { return; }
- foreach ($files as $f) {
- $mtime = @filemtime($f);
- if ($mtime !== false && $mtime < $cutoff) {
- @unlink($f);
- }
- }
- }
- /**
- * v1.7.0 — Stage an array of rows to a token-named JSON file. Returns
- * the token, or false on write failure.
- *
- * @param array $rows
- * @param string $prefix e.g. 'product' or 'category'
- * @return string|false
- */
- public static function stage_rows($rows, $prefix = 'product') {
- self::prune_staging();
- $token = $prefix . '-' . bin2hex(random_bytes(16));
- $path = self::get_staging_dir() . $token . '.json';
- $encoded = wp_json_encode($rows);
- if ($encoded === false || file_put_contents($path, $encoded) === false) {
- return false;
- }
- return $token;
- }
- /**
- * v1.7.0 — Read a slice of a staged file.
- *
- * @param string $token
- * @param int $offset
- * @param int $batch_size
- * @return array|null array{total:int,batch:array} or null if missing/unreadable
- */
- public static function read_stage_slice($token, $offset, $batch_size) {
- // Validate the token shape (defends against path traversal).
- if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) {
- return null;
- }
- $path = self::get_staging_dir() . $token . '.json';
- if (!file_exists($path)) { return null; }
- $raw = file_get_contents($path);
- if ($raw === false) { return null; }
- $rows = json_decode($raw, true);
- if (!is_array($rows)) { return null; }
- return array(
- 'total' => 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_<sanitized name>.
- 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<int,object>|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;
- }
- }
|