|
|
@@ -21,6 +21,14 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
*/
|
|
|
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
|
|
|
*
|
|
|
@@ -30,6 +38,102 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
$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
|
|
|
*
|
|
|
@@ -47,6 +151,14 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
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) {
|
|
|
@@ -54,8 +166,11 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
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++;
|
|
|
@@ -65,6 +180,32 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
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(
|
|
|
@@ -75,13 +216,34 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
|
|
|
fclose($handle);
|
|
|
|
|
|
- // Store data in transient (expires in 1 hour)
|
|
|
- $transient_key = 'studiou_wcpcm_import_' . uniqid();
|
|
|
- set_transient($transient_key, $rows, 3600);
|
|
|
+ // 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(
|
|
|
- 'transient_key' => $transient_key,
|
|
|
- 'total' => count($rows)
|
|
|
+ // 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),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -94,11 +256,26 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
* @return array Result with statistics
|
|
|
*/
|
|
|
public function process_batch($transient_key, $offset = 0, $batch_size = 5) {
|
|
|
- $rows = get_transient($transient_key);
|
|
|
-
|
|
|
- if (!$rows) {
|
|
|
+ // 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,
|
|
|
@@ -113,6 +290,18 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
// 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'];
|
|
|
@@ -146,6 +335,14 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
|
|
|
$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;
|
|
|
}
|
|
|
|
|
|
@@ -250,13 +447,19 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
$product->set_short_description(isset($data['Krátký popis']) ? $data['Krátký popis'] : '');
|
|
|
$product->set_description(isset($data['Popis']) ? $data['Popis'] : '');
|
|
|
|
|
|
- // Set categories
|
|
|
+ // 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();
|
|
|
|
|
|
- error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name());
|
|
|
- error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']);
|
|
|
+ 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)) {
|
|
|
@@ -264,22 +467,29 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
}
|
|
|
|
|
|
$category_id = $this->find_category_by_name($category_name);
|
|
|
- error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND'));
|
|
|
+ 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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids));
|
|
|
+ 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);
|
|
|
- error_log('STUDIOU WC IMPORT: Categories assigned to product via set_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 {
|
|
|
+ } else if ($debug) {
|
|
|
error_log('STUDIOU WC IMPORT: No categories in CSV for product: ' . $product->get_name());
|
|
|
}
|
|
|
|
|
|
@@ -295,26 +505,88 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
$reviews_enabled = isset($data['Povolit zákaznické recenze?']) && $data['Povolit zákaznické recenze?'] === '1';
|
|
|
$product->set_reviews_allowed($reviews_enabled);
|
|
|
|
|
|
- // Handle product attribute
|
|
|
+ // 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 = $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';
|
|
|
-
|
|
|
- // Create attribute taxonomy if it doesn't exist
|
|
|
- $this->db->create_attribute_if_not_exists($attribute_name);
|
|
|
+ $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();
|
|
|
- $attribute->set_name(wc_attribute_taxonomy_name($attribute_name));
|
|
|
- $attribute->set_visible($is_visible);
|
|
|
- $attribute->set_variation($is_variation);
|
|
|
|
|
|
- // Set attribute options (terms)
|
|
|
- $values = array_map('trim', explode('|', $attribute_values));
|
|
|
- $attribute->set_options($values);
|
|
|
+ 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');
|
|
|
+ }
|
|
|
|
|
|
- $product->set_attributes(array($attribute));
|
|
|
+ $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
|
|
|
@@ -328,6 +600,14 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
// 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());
|
|
|
@@ -415,16 +695,44 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Set variation attributes
|
|
|
+ // 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 = wc_attribute_taxonomy_name($data['Název 1 vlastnosti']);
|
|
|
- $attribute_value = $data['Hodnota(y) 1 vlastnosti'];
|
|
|
-
|
|
|
- $variation->set_attributes(array(
|
|
|
- $attribute_name => $attribute_value
|
|
|
- ));
|
|
|
+ $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
|
|
|
@@ -456,137 +764,244 @@ class Studiou_WC_Product_Manage_Product_Import {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Upload image from URL
|
|
|
+ * Find an attachment previously imported from this exact source URL.
|
|
|
*
|
|
|
- * @param string $url Image URL
|
|
|
- * @return int|null Attachment ID or null on failure
|
|
|
+ * 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');
|
|
|
|
|
|
- // Set shorter timeout for image download (30 seconds)
|
|
|
- add_filter('http_request_timeout', function() { return 30; });
|
|
|
+ // 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)) {
|
|
|
- if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
|
- error_log('STUDIOU WC: Image download failed for URL: ' . $url . ' - ' . $tmp->get_error_message());
|
|
|
- }
|
|
|
+ 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' => basename($url),
|
|
|
- 'tmp_name' => $tmp
|
|
|
+ 'name' => $name,
|
|
|
+ 'tmp_name' => $tmp,
|
|
|
);
|
|
|
|
|
|
- // Disable image processing to speed up import
|
|
|
- add_filter('intermediate_image_sizes_advanced', function() { return array(); });
|
|
|
-
|
|
|
$id = media_handle_sideload($file_array, 0);
|
|
|
-
|
|
|
- // Re-enable image processing
|
|
|
- remove_filter('intermediate_image_sizes_advanced', function() { return array(); });
|
|
|
-
|
|
|
if (is_wp_error($id)) {
|
|
|
- @unlink($file_array['tmp_name']);
|
|
|
- if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
|
- error_log('STUDIOU WC: Image upload failed for URL: ' . $url . ' - ' . $id->get_error_message());
|
|
|
- }
|
|
|
+ @unlink($tmp);
|
|
|
+ error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message());
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- return $id;
|
|
|
+ // Tag the attachment so future rows / batches / imports dedup on it.
|
|
|
+ update_post_meta($id, '_studiou_wcpcm_source_url', $url);
|
|
|
|
|
|
- } catch (Exception $e) {
|
|
|
+ self::$url_cache[$url] = (int) $id;
|
|
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
|
- error_log('STUDIOU WC: Image upload exception for URL: ' . $url . ' - ' . $e->getMessage());
|
|
|
+ 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');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Find category by name (simple lookup, categories have unique names)
|
|
|
+ * 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.
|
|
|
*
|
|
|
- * @param string $category_name Category name
|
|
|
- * @return int|null Category ID or null if not found
|
|
|
+ * @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) {
|
|
|
- // Trim and normalize the category name
|
|
|
- $category_name = trim($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;
|
|
|
}
|
|
|
|
|
|
- // Simple name lookup only (no hierarchy detection)
|
|
|
- // Categories have unique names throughout the system
|
|
|
-
|
|
|
- error_log('STUDIOU WC IMPORT: find_category_by_name() called for: "' . $category_name . '"');
|
|
|
+ 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) {
|
|
|
- error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id);
|
|
|
+ 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);
|
|
|
- error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"');
|
|
|
+ if ($debug) { error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"'); }
|
|
|
$term = get_term_by('slug', $slug, 'product_cat');
|
|
|
if ($term) {
|
|
|
- error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id);
|
|
|
+ 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
|
|
|
- $all_terms = get_terms(array(
|
|
|
- 'taxonomy' => 'product_cat',
|
|
|
- 'hide_empty' => false
|
|
|
- ));
|
|
|
+ // 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();
|
|
|
+ }
|
|
|
|
|
|
- error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count($all_terms) . ' categories case-insensitively');
|
|
|
+ if ($debug) {
|
|
|
+ error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count(self::$all_product_cats_cache) . ' categories case-insensitively');
|
|
|
+ }
|
|
|
|
|
|
- foreach ($all_terms as $t) {
|
|
|
+ foreach (self::$all_product_cats_cache as $t) {
|
|
|
if (strcasecmp($t->name, $category_name) === 0) {
|
|
|
- error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"');
|
|
|
+ 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 - try to create it
|
|
|
+ // 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
|
|
|
- )
|
|
|
- );
|
|
|
-
|
|
|
+ $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());
|
|
|
- error_log('STUDIOU WC IMPORT: Total available categories: ' . count($all_terms));
|
|
|
-
|
|
|
- // Show first 20 categories for debugging
|
|
|
- $sample_cats = array_slice($all_terms, 0, 20);
|
|
|
- foreach ($sample_cats as $cat) {
|
|
|
- error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')');
|
|
|
+ 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;
|
|
|
- } else {
|
|
|
- $term_id = $new_term['term_id'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $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;
|
|
|
}
|
|
|
+ return $term_id;
|
|
|
}
|
|
|
|
|
|
/**
|