class-studiou-wc-product-manage-product-import.php 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  1. <?php
  2. /**
  3. * Product import class
  4. *
  5. * Handles product import from CSV files
  6. *
  7. * @since 1.4.0
  8. * @package Studiou_WC_Product_Cat_Manage
  9. * @subpackage Studiou_WC_Product_Cat_Manage/includes
  10. */
  11. // If this file is called directly, abort.
  12. if (!defined('WPINC')) {
  13. die;
  14. }
  15. class Studiou_WC_Product_Manage_Product_Import {
  16. /**
  17. * @var Studiou_WC_Product_Manage_Product_DB
  18. */
  19. private $db;
  20. /**
  21. * In-request cache of trimmed source URL => attachment ID, so the same URL
  22. * appearing multiple times within one AJAX batch costs at most one DB lookup.
  23. *
  24. * @var array<string,int>
  25. */
  26. private static $url_cache = array();
  27. /**
  28. * Constructor
  29. *
  30. * @param Studiou_WC_Product_Manage_Product_DB $db Database instance
  31. */
  32. public function __construct($db) {
  33. $this->db = $db;
  34. }
  35. /**
  36. * v1.7.0 — Return (and lazily create) the guarded staging directory used
  37. * by both the product and category importers. Replaces the transient-
  38. * based staging that capped at max_allowed_packet (M4).
  39. *
  40. * @return string Absolute path with trailing slash.
  41. */
  42. public static function get_staging_dir() {
  43. $upload_dir = wp_upload_dir();
  44. $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-staging/';
  45. if (!file_exists($dir)) {
  46. wp_mkdir_p($dir);
  47. }
  48. $idx = $dir . 'index.php';
  49. if (!file_exists($idx)) {
  50. @file_put_contents($idx, '<?php // Silence is golden.');
  51. }
  52. $ht = $dir . '.htaccess';
  53. if (!file_exists($ht)) {
  54. @file_put_contents($ht, "Deny from all\n");
  55. }
  56. return $dir;
  57. }
  58. /**
  59. * v1.7.0 — Prune staging files older than 24h.
  60. */
  61. public static function prune_staging() {
  62. $dir = self::get_staging_dir();
  63. $cutoff = time() - DAY_IN_SECONDS;
  64. $files = glob($dir . '*.json');
  65. if (!is_array($files)) { return; }
  66. foreach ($files as $f) {
  67. $mtime = @filemtime($f);
  68. if ($mtime !== false && $mtime < $cutoff) {
  69. @unlink($f);
  70. }
  71. }
  72. }
  73. /**
  74. * v1.7.0 — Stage an array of rows to a token-named JSON file. Returns
  75. * the token, or false on write failure.
  76. *
  77. * @param array $rows
  78. * @param string $prefix e.g. 'product' or 'category'
  79. * @return string|false
  80. */
  81. public static function stage_rows($rows, $prefix = 'product') {
  82. self::prune_staging();
  83. $token = $prefix . '-' . bin2hex(random_bytes(16));
  84. $path = self::get_staging_dir() . $token . '.json';
  85. $encoded = wp_json_encode($rows);
  86. if ($encoded === false || file_put_contents($path, $encoded) === false) {
  87. return false;
  88. }
  89. return $token;
  90. }
  91. /**
  92. * v1.7.0 — Read a slice of a staged file.
  93. *
  94. * @param string $token
  95. * @param int $offset
  96. * @param int $batch_size
  97. * @return array|null array{total:int,batch:array} or null if missing/unreadable
  98. */
  99. public static function read_stage_slice($token, $offset, $batch_size) {
  100. // Validate the token shape (defends against path traversal).
  101. if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) {
  102. return null;
  103. }
  104. $path = self::get_staging_dir() . $token . '.json';
  105. if (!file_exists($path)) { return null; }
  106. $raw = file_get_contents($path);
  107. if ($raw === false) { return null; }
  108. $rows = json_decode($raw, true);
  109. if (!is_array($rows)) { return null; }
  110. return array(
  111. 'total' => count($rows),
  112. 'batch' => array_slice($rows, $offset, $batch_size),
  113. );
  114. }
  115. /**
  116. * v1.7.0 — Delete a staged file (e.g. on completion).
  117. *
  118. * @param string $token
  119. */
  120. public static function delete_stage($token) {
  121. if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) {
  122. return;
  123. }
  124. @unlink(self::get_staging_dir() . $token . '.json');
  125. }
  126. /**
  127. * Parse CSV file and store data for batch processing
  128. *
  129. * @param string $file_path Path to CSV file
  130. * @return array Result with total count and transient key
  131. */
  132. public function parse_csv_for_batch($file_path) {
  133. if (!file_exists($file_path)) {
  134. return array('error' => __('File not found', 'studiou-wc-product-cat-manage'));
  135. }
  136. // Open CSV file
  137. $handle = fopen($file_path, 'r');
  138. if (!$handle) {
  139. return array('error' => __('Could not open file', 'studiou-wc-product-cat-manage'));
  140. }
  141. // M3 — strip UTF-8 BOM if present so the first header cell isn't
  142. // "\xEF\xBB\xBFID", which would make every row miss the ID key and
  143. // be treated as new (and then likely skipped by SKU-uniqueness).
  144. $bom = fread($handle, 3);
  145. if ($bom !== "\xEF\xBB\xBF") {
  146. rewind($handle);
  147. }
  148. // Read header row
  149. $header = fgetcsv($handle, 0, ',', '"');
  150. if (!$header) {
  151. fclose($handle);
  152. return array('error' => __('Invalid CSV format', 'studiou-wc-product-cat-manage'));
  153. }
  154. $header_count = count($header);
  155. // Read all data rows
  156. $rows = array();
  157. $skipped_rows = array();
  158. $row_number = 1;
  159. while (($row = fgetcsv($handle, 0, ',', '"')) !== false) {
  160. $row_number++;
  161. // Skip empty rows
  162. if (empty(array_filter($row))) {
  163. continue;
  164. }
  165. // H4 — guard array_combine: on PHP 8+ it throws ValueError when
  166. // the row width doesn't match the header. ValueError extends
  167. // Error (not Exception), so the surrounding catches miss it and
  168. // the whole import dies with a bare 500. Pad short rows (likely
  169. // trailing empty cells from Excel); skip and report over-long
  170. // rows (an unescaped comma we can't safely recover).
  171. $row_count = count($row);
  172. if ($row_count < $header_count) {
  173. $row = array_pad($row, $header_count, '');
  174. } elseif ($row_count > $header_count) {
  175. $padded_for_failed = array_pad($row, $header_count, '');
  176. $padded_for_failed = array_slice($padded_for_failed, 0, $header_count);
  177. $skipped_rows[] = array(
  178. 'row_number' => $row_number,
  179. 'data' => array_combine($header, $padded_for_failed),
  180. 'reason' => sprintf(
  181. /* translators: 1: row number, 2: actual column count, 3: expected column count */
  182. __('Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped.', 'studiou-wc-product-cat-manage'),
  183. $row_number,
  184. $row_count,
  185. $header_count
  186. ),
  187. );
  188. continue;
  189. }
  190. // Combine header with row data
  191. $data = array_combine($header, $row);
  192. $rows[] = array(
  193. 'row_number' => $row_number,
  194. 'data' => $data
  195. );
  196. }
  197. fclose($handle);
  198. // M4 (guard) — set_transient returns false when the serialized
  199. // payload exceeds max_allowed_packet or storage fails for any
  200. // other reason. The downstream batch handler then reports
  201. // "Import data not found or expired", which is misleading — the
  202. // upload was fine, the store just couldn't take it. Surface a
  203. // clear, actionable message instead. The disk-staging rework that
  204. // actually removes this ceiling lands in Phase D.
  205. // v1.7.0 — M4 rework: stage rows on disk instead of in a transient.
  206. // The transient was a single wp_options row; large CSVs overflowed
  207. // max_allowed_packet and set_transient returned false, surfacing as
  208. // the misleading "Import data not found or expired" downstream.
  209. // Disk staging has no such ceiling. The staging file is guarded
  210. // (index.php + .htaccess), token-named, and pruned at 24h.
  211. $payload = array(
  212. 'rows' => $rows,
  213. 'skipped' => $skipped_rows,
  214. );
  215. $token = self::stage_rows($payload, 'product');
  216. if ($token === false) {
  217. return array('error' => __('Failed to stage import data on disk (write error).', 'studiou-wc-product-cat-manage'));
  218. }
  219. return array(
  220. // Legacy field name preserved so the JS keeps working without
  221. // changes; the value is now a disk-staging token, not a
  222. // transient key. process_batch reads from disk via the token.
  223. 'transient_key' => $token,
  224. 'total' => count($rows),
  225. );
  226. }
  227. /**
  228. * Process batch of products
  229. *
  230. * @param string $transient_key Transient key with CSV data
  231. * @param int $offset Starting offset
  232. * @param int $batch_size Number of items to process
  233. * @return array Result with statistics
  234. */
  235. public function process_batch($transient_key, $offset = 0, $batch_size = 5) {
  236. // v1.7.0 — read from disk-staging instead of the WP transient.
  237. // First load the full payload so we know total + skipped rows.
  238. $dir = self::get_staging_dir();
  239. if (!preg_match('/^product-[a-f0-9]{32}$/', $transient_key)) {
  240. return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
  241. }
  242. $path = $dir . $transient_key . '.json';
  243. if (!file_exists($path)) {
  244. return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
  245. }
  246. $raw = file_get_contents($path);
  247. if ($raw === false) {
  248. return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
  249. }
  250. $payload = json_decode($raw, true);
  251. if (!is_array($payload) || !isset($payload['rows'])) {
  252. return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
  253. }
  254. $rows = $payload['rows'];
  255. $skipped_at_parse = isset($payload['skipped']) ? $payload['skipped'] : array();
  256. $result = array(
  257. 'success' => 0,
  258. 'failed' => 0,
  259. 'skipped' => 0,
  260. 'failed_items' => array(),
  261. 'messages' => array(),
  262. 'processed' => 0,
  263. 'total' => count($rows)
  264. );
  265. // Get batch of rows to process
  266. $batch = array_slice($rows, $offset, $batch_size);
  267. // On the final batch, fold in any ragged-row skips from the parse
  268. // step (set by parse_csv_for_batch when row width != header width)
  269. // so they appear in the failed-CSV download and the messages list.
  270. $is_final_batch = ($offset + count($batch)) >= count($rows);
  271. if ($is_final_batch && !empty($skipped_at_parse)) {
  272. foreach ($skipped_at_parse as $skip) {
  273. $result['skipped']++;
  274. $result['messages'][] = $skip['reason'];
  275. $result['failed_items'][] = $skip['data'];
  276. }
  277. }
  278. foreach ($batch as $item) {
  279. $row_number = $item['row_number'];
  280. $data = $item['data'];
  281. // Process the product
  282. $process_result = $this->process_product_row($data, $row_number);
  283. if ($process_result['status'] === 'success') {
  284. $result['success']++;
  285. // Don't add success messages to log
  286. } elseif ($process_result['status'] === 'skipped') {
  287. $result['skipped']++;
  288. $result['messages'][] = sprintf(
  289. __('Row %d: SKIPPED - %s', 'studiou-wc-product-cat-manage'),
  290. $row_number,
  291. $process_result['message']
  292. );
  293. // Add to failed items for re-processing
  294. $result['failed_items'][] = $data;
  295. } else {
  296. $result['failed']++;
  297. $result['messages'][] = sprintf(
  298. __('Row %d: FAILED - %s', 'studiou-wc-product-cat-manage'),
  299. $row_number,
  300. $process_result['message']
  301. );
  302. // Add to failed items
  303. $result['failed_items'][] = $data;
  304. }
  305. }
  306. $result['processed'] = $offset + count($batch);
  307. // v1.7.0 — delete the staging file when the import is complete so
  308. // we don't leak token-named JSON files into uploads/. (Pruning at
  309. // 24h would catch them eventually, but cleaning at completion is
  310. // the precise time.)
  311. if ($is_final_batch) {
  312. self::delete_stage($transient_key);
  313. }
  314. return $result;
  315. }
  316. /**
  317. * Process single product row
  318. *
  319. * @param array $data Product data
  320. * @param int $row_number Row number for error reporting
  321. * @return array Result with status and message
  322. */
  323. private function process_product_row($data, $row_number) {
  324. // Validate required fields
  325. if (!isset($data['Typ']) || empty($data['Typ'])) {
  326. return array(
  327. 'status' => 'skipped',
  328. 'message' => __('Missing product type', 'studiou-wc-product-cat-manage')
  329. );
  330. }
  331. // Rule 3: Only allow "variable" or "variation" types
  332. $type = trim($data['Typ']);
  333. if (!in_array($type, array('variable', 'variation'))) {
  334. return array(
  335. 'status' => 'skipped',
  336. 'message' => sprintf(
  337. __('Invalid product type: %s (only "variable" or "variation" allowed)', 'studiou-wc-product-cat-manage'),
  338. $type
  339. )
  340. );
  341. }
  342. // Get product ID
  343. $product_id = isset($data['ID']) ? intval($data['ID']) : 0;
  344. $sku = isset($data['Katalogové číslo']) ? trim($data['Katalogové číslo']) : '';
  345. // Rule 4: SKU must be unique
  346. if (empty($sku)) {
  347. return array(
  348. 'status' => 'skipped',
  349. 'message' => __('Missing SKU (Katalogové číslo)', 'studiou-wc-product-cat-manage')
  350. );
  351. }
  352. if (!$this->db->is_sku_unique($sku, $product_id)) {
  353. return array(
  354. 'status' => 'skipped',
  355. 'message' => sprintf(
  356. __('SKU "%s" already exists', 'studiou-wc-product-cat-manage'),
  357. $sku
  358. )
  359. );
  360. }
  361. // Rule 5: Validate image URL if provided
  362. $image_url = isset($data['Obrázky']) ? trim($data['Obrázky']) : '';
  363. if (!empty($image_url) && !$this->db->validate_image_url($image_url)) {
  364. return array(
  365. 'status' => 'skipped',
  366. 'message' => sprintf(
  367. __('Invalid image URL: %s', 'studiou-wc-product-cat-manage'),
  368. $image_url
  369. )
  370. );
  371. }
  372. // Process based on product type
  373. if ($type === 'variation') {
  374. return $this->process_variation($data, $product_id);
  375. } else {
  376. return $this->process_variable_product($data, $product_id);
  377. }
  378. }
  379. /**
  380. * Process variable product
  381. *
  382. * @param array $data Product data
  383. * @param int $product_id Product ID (0 for new product)
  384. * @return array Result with status and message
  385. */
  386. private function process_variable_product($data, $product_id) {
  387. try {
  388. // Create or update product
  389. if ($product_id == 0) {
  390. $product = new WC_Product_Variable();
  391. } else {
  392. $product = wc_get_product($product_id);
  393. if (!$product || !$product->is_type('variable')) {
  394. return array(
  395. 'status' => 'error',
  396. 'message' => sprintf(
  397. __('Product ID %d is not a variable product', 'studiou-wc-product-cat-manage'),
  398. $product_id
  399. )
  400. );
  401. }
  402. }
  403. // Set basic data
  404. $product->set_name(isset($data['Jméno']) ? $data['Jméno'] : '');
  405. $product->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : '');
  406. $product->set_short_description(isset($data['Krátký popis']) ? $data['Krátký popis'] : '');
  407. $product->set_description(isset($data['Popis']) ? $data['Popis'] : '');
  408. // Set categories
  409. if (isset($data['Kategorie']) && !empty($data['Kategorie'])) {
  410. $categories = array_map('trim', explode('|', $data['Kategorie']));
  411. $category_ids = array();
  412. error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name());
  413. error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']);
  414. foreach ($categories as $category_name) {
  415. if (empty($category_name)) {
  416. continue;
  417. }
  418. $category_id = $this->find_category_by_name($category_name);
  419. error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND'));
  420. if ($category_id) {
  421. $category_ids[] = $category_id;
  422. }
  423. }
  424. error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids));
  425. if (!empty($category_ids)) {
  426. $product->set_category_ids($category_ids);
  427. error_log('STUDIOU WC IMPORT: Categories assigned to product via set_category_ids()');
  428. } else {
  429. error_log('STUDIOU WC IMPORT: WARNING - No valid category IDs found for: ' . $data['Kategorie']);
  430. }
  431. } else {
  432. error_log('STUDIOU WC IMPORT: No categories in CSV for product: ' . $product->get_name());
  433. }
  434. // Handle image
  435. if (isset($data['Obrázky']) && !empty($data['Obrázky'])) {
  436. $image_id = $this->upload_image_from_url($data['Obrázky']);
  437. if ($image_id) {
  438. $product->set_image_id($image_id);
  439. }
  440. }
  441. // Set reviews enabled
  442. $reviews_enabled = isset($data['Povolit zákaznické recenze?']) && $data['Povolit zákaznické recenze?'] === '1';
  443. $product->set_reviews_allowed($reviews_enabled);
  444. // v1.6.3 — Handle product attribute (taxonomy vs. custom).
  445. //
  446. // Pre-1.6.3 behaviour ignored the `Vlastnost 1 globální` column and
  447. // forced every attribute into a global pa_ taxonomy: it called
  448. // create_attribute_if_not_exists() unconditionally, set the
  449. // attribute name via wc_attribute_taxonomy_name(), and passed raw
  450. // name strings to set_options(). Result: custom (product-level)
  451. // attributes silently became global taxonomies; for variations,
  452. // raw names landed where slugs were required, so the storefront
  453. // couldn't match the customer's selection ("No matching variation").
  454. //
  455. // Branch on the global flag and, for the taxonomy path, resolve
  456. // each value to a term (creating it if missing), pass term IDs to
  457. // set_options(), remember the slugs so process_variation() can
  458. // store the correct value, and link the terms via
  459. // wp_set_object_terms after the product save.
  460. //
  461. // Hand-built CSVs that omit `Vlastnost 1 globální` default to the
  462. // custom branch.
  463. //
  464. // LIVE-TEST GATE: §5.B.0 of review-00-plan-00.md asks for a real
  465. // storefront round-trip before deploy. Verify both the taxonomy
  466. // and custom paths there.
  467. $term_ids_for_link = array();
  468. $taxonomy_for_link = '';
  469. if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti'])) {
  470. $attribute_name = trim($data['Název 1 vlastnosti']);
  471. $attribute_values = isset($data['Hodnota(y) 1 vlastnosti']) ? $data['Hodnota(y) 1 vlastnosti'] : '';
  472. $is_visible = isset($data['Vlastnost 1 viditelnost']) && $data['Vlastnost 1 viditelnost'] === '1';
  473. $is_variation = isset($data['Vlastnost 1 varianta']) && $data['Vlastnost 1 varianta'] === '1';
  474. $is_global = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1';
  475. $values = array_map('trim', explode('|', $attribute_values));
  476. $values = array_values(array_filter($values, function($v) { return $v !== ''; }));
  477. $attribute = new WC_Product_Attribute();
  478. if ($is_global) {
  479. // Taxonomy attribute — register, resolve/create terms,
  480. // pass term IDs (not names) to set_options.
  481. $this->db->create_attribute_if_not_exists($attribute_name);
  482. $taxonomy = wc_attribute_taxonomy_name($attribute_name);
  483. if (!taxonomy_exists($taxonomy)) {
  484. register_taxonomy($taxonomy, 'product');
  485. }
  486. $term_ids = array();
  487. foreach ($values as $value) {
  488. $term = get_term_by('name', $value, $taxonomy);
  489. if (!$term) {
  490. $inserted = wp_insert_term($value, $taxonomy);
  491. if (is_wp_error($inserted)) {
  492. error_log('STUDIOU WC IMPORT: failed to insert term "' . $value . '" into ' . $taxonomy . ': ' . $inserted->get_error_message());
  493. continue;
  494. }
  495. $term = get_term($inserted['term_id'], $taxonomy);
  496. }
  497. if ($term && !is_wp_error($term)) {
  498. $term_ids[] = (int) $term->term_id;
  499. }
  500. }
  501. $attribute->set_id(wc_attribute_taxonomy_id_by_name($attribute_name));
  502. $attribute->set_name($taxonomy);
  503. $attribute->set_options($term_ids);
  504. $attribute->set_visible($is_visible);
  505. $attribute->set_variation($is_variation);
  506. $product->set_attributes(array($attribute));
  507. // Defer term-relationship linkage until after save() so we
  508. // have a real product ID.
  509. $term_ids_for_link = $term_ids;
  510. $taxonomy_for_link = $taxonomy;
  511. } else {
  512. // Custom (product-level) attribute — no taxonomy, store
  513. // the raw display name and the raw values.
  514. $attribute->set_id(0);
  515. $attribute->set_name($attribute_name);
  516. $attribute->set_options($values);
  517. $attribute->set_visible($is_visible);
  518. $attribute->set_variation($is_variation);
  519. $product->set_attributes(array($attribute));
  520. }
  521. }
  522. // Handle minimum/maximum quantity
  523. if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) {
  524. $product->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity']));
  525. }
  526. if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) {
  527. $product->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity']));
  528. }
  529. // Save product
  530. $saved_id = $product->save();
  531. // Belt-and-braces: link the attribute terms to the freshly-saved
  532. // product. Modern WC CRUD typically does this on save() when
  533. // set_options() receives term IDs, but the link is cheap and
  534. // makes the import robust against WC internals changes.
  535. if (!empty($term_ids_for_link) && $taxonomy_for_link !== '' && $saved_id) {
  536. wp_set_object_terms($saved_id, $term_ids_for_link, $taxonomy_for_link, false);
  537. }
  538. $message = $product_id == 0 ?
  539. sprintf(__('Created variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name()) :
  540. sprintf(__('Updated variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name());
  541. return array(
  542. 'status' => 'success',
  543. 'message' => $message
  544. );
  545. } catch (Exception $e) {
  546. return array(
  547. 'status' => 'error',
  548. 'message' => $e->getMessage()
  549. );
  550. }
  551. }
  552. /**
  553. * Process product variation
  554. *
  555. * @param array $data Product data
  556. * @param int $variation_id Variation ID (0 for new variation)
  557. * @return array Result with status and message
  558. */
  559. private function process_variation($data, $variation_id) {
  560. try {
  561. // Rule 6: Must have parent reference
  562. if (!isset($data['Nadřazené']) || empty($data['Nadřazené'])) {
  563. return array(
  564. 'status' => 'skipped',
  565. 'message' => __('Missing parent product reference (Nadřazené)', 'studiou-wc-product-cat-manage')
  566. );
  567. }
  568. // Rule 7: Must have regular price
  569. if (!isset($data['Běžná cena']) || empty($data['Běžná cena'])) {
  570. return array(
  571. 'status' => 'skipped',
  572. 'message' => __('Missing regular price (Běžná cena)', 'studiou-wc-product-cat-manage')
  573. );
  574. }
  575. // Find parent product by SKU
  576. $parent_sku = trim($data['Nadřazené']);
  577. $parent_product = $this->db->get_product_by_sku($parent_sku);
  578. if (!$parent_product || !$parent_product->is_type('variable')) {
  579. return array(
  580. 'status' => 'skipped',
  581. 'message' => sprintf(
  582. __('Parent product with SKU "%s" not found or not variable', 'studiou-wc-product-cat-manage'),
  583. $parent_sku
  584. )
  585. );
  586. }
  587. // Create or update variation
  588. if ($variation_id == 0) {
  589. $variation = new WC_Product_Variation();
  590. $variation->set_parent_id($parent_product->get_id());
  591. } else {
  592. $variation = wc_get_product($variation_id);
  593. if (!$variation || !$variation->is_type('variation')) {
  594. return array(
  595. 'status' => 'error',
  596. 'message' => sprintf(
  597. __('Product ID %d is not a variation', 'studiou-wc-product-cat-manage'),
  598. $variation_id
  599. )
  600. );
  601. }
  602. }
  603. // Set basic data
  604. $variation->set_name(isset($data['Jméno']) ? $data['Jméno'] : '');
  605. $variation->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : '');
  606. $variation->set_description(isset($data['Popis']) ? $data['Popis'] : '');
  607. $variation->set_regular_price($data['Běžná cena']);
  608. // Handle image
  609. if (isset($data['Obrázky']) && !empty($data['Obrázky'])) {
  610. $image_id = $this->upload_image_from_url($data['Obrázky']);
  611. if ($image_id) {
  612. $variation->set_image_id($image_id);
  613. }
  614. }
  615. // v1.6.3 — Variation attribute (taxonomy vs. custom).
  616. //
  617. // Pre-1.6.3 stored the raw value (e.g. "Red") under the taxonomy
  618. // meta key (e.g. attribute_pa_color). WC matches variations by
  619. // term slug ("red"), so the variation was unreachable on the
  620. // storefront. Now: for taxonomy attributes resolve the value to
  621. // its term slug; for custom attributes store the literal value
  622. // under attribute_<sanitized name>.
  623. if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti']) &&
  624. isset($data['Hodnota(y) 1 vlastnosti']) && !empty($data['Hodnota(y) 1 vlastnosti'])) {
  625. $attribute_name = trim($data['Název 1 vlastnosti']);
  626. $attribute_value = trim($data['Hodnota(y) 1 vlastnosti']);
  627. $is_global = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1';
  628. if ($is_global) {
  629. $taxonomy = wc_attribute_taxonomy_name($attribute_name);
  630. $term = get_term_by('name', $attribute_value, $taxonomy);
  631. if (!$term) {
  632. // The variation row may arrive before its parent's
  633. // taxonomy terms exist (rare but possible on hand-built
  634. // CSVs). Create it so the variation is selectable.
  635. if (!taxonomy_exists($taxonomy)) {
  636. $this->db->create_attribute_if_not_exists($attribute_name);
  637. register_taxonomy($taxonomy, 'product');
  638. }
  639. $inserted = wp_insert_term($attribute_value, $taxonomy);
  640. if (!is_wp_error($inserted)) {
  641. $term = get_term($inserted['term_id'], $taxonomy);
  642. }
  643. }
  644. $slug = ($term && !is_wp_error($term)) ? $term->slug : sanitize_title($attribute_value);
  645. $variation->set_attributes(array($taxonomy => $slug));
  646. } else {
  647. $variation->set_attributes(array(
  648. sanitize_title($attribute_name) => $attribute_value,
  649. ));
  650. }
  651. }
  652. // Handle minimum/maximum quantity
  653. if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) {
  654. $variation->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity']));
  655. }
  656. if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) {
  657. $variation->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity']));
  658. }
  659. // Save variation
  660. $saved_id = $variation->save();
  661. $message = $variation_id == 0 ?
  662. sprintf(__('Created variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name()) :
  663. sprintf(__('Updated variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name());
  664. return array(
  665. 'status' => 'success',
  666. 'message' => $message
  667. );
  668. } catch (Exception $e) {
  669. return array(
  670. 'status' => 'error',
  671. 'message' => $e->getMessage()
  672. );
  673. }
  674. }
  675. /**
  676. * Find an attachment previously imported from this exact source URL.
  677. *
  678. * Uses get_posts (not a raw $wpdb query) so a row whose attachment post was
  679. * deleted but whose meta was somehow orphaned can't resolve to a missing post.
  680. * Orders by ID ascending so the earliest (canonical) attachment wins
  681. * deterministically if more than one ever carries the same source URL.
  682. *
  683. * @param string $url Trimmed source URL.
  684. * @return int Attachment ID, or 0 if none.
  685. */
  686. private function find_attachment_by_source_url($url) {
  687. $ids = get_posts(array(
  688. 'post_type' => 'attachment',
  689. 'post_status' => 'inherit',
  690. 'posts_per_page' => 1,
  691. 'orderby' => 'ID',
  692. 'order' => 'ASC',
  693. 'fields' => 'ids',
  694. 'no_found_rows' => true,
  695. 'update_post_meta_cache' => false,
  696. 'update_post_term_cache' => false,
  697. 'meta_query' => array(
  698. array(
  699. 'key' => '_studiou_wcpcm_source_url',
  700. 'value' => $url,
  701. 'compare' => '=',
  702. ),
  703. ),
  704. ));
  705. return !empty($ids) ? (int) $ids[0] : 0;
  706. }
  707. /**
  708. * Upload an image from a URL, deduplicating by source URL.
  709. *
  710. * A given source URL is downloaded at most once: an in-request static cache
  711. * absorbs repeats within a batch, and a `_studiou_wcpcm_source_url` postmeta
  712. * lookup reuses attachments across batches and across imports.
  713. *
  714. * @param string $url Image URL (raw value from the Obrázky column).
  715. * @return int|null Attachment ID, or null on empty input / download failure.
  716. */
  717. private function upload_image_from_url($url) {
  718. // Normalize the key. process_product_row() validates a *trimmed* URL but
  719. // the callers pass the raw cell, so trim here to keep the cache key, the
  720. // DB lookup, the download, and the stored postmeta all identical to the
  721. // value that was validated (and to collapse whitespace-only variants).
  722. $url = is_string($url) ? trim($url) : '';
  723. if ($url === '') {
  724. return null;
  725. }
  726. // Layer 2 — in-request static cache.
  727. if (isset(self::$url_cache[$url])) {
  728. if (defined('WP_DEBUG') && WP_DEBUG) {
  729. error_log('STUDIOU WC IMPORT: image cache HIT (request) ' . $url . ' -> ' . self::$url_cache[$url]);
  730. }
  731. return self::$url_cache[$url];
  732. }
  733. // Layer 1 — persistent postmeta lookup (across batches and re-imports).
  734. $existing = $this->find_attachment_by_source_url($url);
  735. if ($existing) {
  736. self::$url_cache[$url] = $existing;
  737. if (defined('WP_DEBUG') && WP_DEBUG) {
  738. error_log('STUDIOU WC IMPORT: image cache HIT (db) ' . $url . ' -> ' . $existing);
  739. }
  740. return $existing;
  741. }
  742. // Layer 0 — fresh sideload.
  743. require_once(ABSPATH . 'wp-admin/includes/media.php');
  744. require_once(ABSPATH . 'wp-admin/includes/file.php');
  745. require_once(ABSPATH . 'wp-admin/includes/image.php');
  746. // Suppress intermediate sizes for import speed. Use the stable
  747. // '__return_empty_array' callable (not a fresh closure) so remove_filter()
  748. // can actually match it, and detach it in finally so it can never leak
  749. // into later media operations in this request.
  750. add_filter('intermediate_image_sizes_advanced', '__return_empty_array');
  751. try {
  752. // download_url()'s second arg sets the request timeout (30s); no
  753. // http_request_timeout filter is needed.
  754. $tmp = download_url($url, 30);
  755. if (is_wp_error($tmp)) {
  756. error_log('STUDIOU WC IMPORT: image download failed for ' . $url . ' - ' . $tmp->get_error_message());
  757. return null;
  758. }
  759. // Derive a clean on-disk filename. parse_url() strips any query string
  760. // (img.jpg?v=2 -> img.jpg) so wp_unique_filename() doesn't fold the
  761. // query into the basename. Null-safe: parse_url() may return null/false
  762. // and basename(null) is deprecated on PHP 8.1+ (we require 8.2).
  763. $path = parse_url($url, PHP_URL_PATH);
  764. $name = (is_string($path) && $path !== '') ? basename($path) : '';
  765. if ($name === '') {
  766. $name = 'image-' . md5($url) . '.jpg';
  767. }
  768. $file_array = array(
  769. 'name' => $name,
  770. 'tmp_name' => $tmp,
  771. );
  772. $id = media_handle_sideload($file_array, 0);
  773. if (is_wp_error($id)) {
  774. @unlink($tmp);
  775. error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message());
  776. return null;
  777. }
  778. // Tag the attachment so future rows / batches / imports dedup on it.
  779. update_post_meta($id, '_studiou_wcpcm_source_url', $url);
  780. self::$url_cache[$url] = (int) $id;
  781. if (defined('WP_DEBUG') && WP_DEBUG) {
  782. error_log('STUDIOU WC IMPORT: image sideloaded ' . $url . ' -> ' . $id);
  783. }
  784. return (int) $id;
  785. } catch (Exception $e) {
  786. error_log('STUDIOU WC IMPORT: image upload exception for ' . $url . ' - ' . $e->getMessage());
  787. return null;
  788. } finally {
  789. remove_filter('intermediate_image_sizes_advanced', '__return_empty_array');
  790. }
  791. }
  792. /**
  793. * Find category by name (simple lookup, categories have unique names)
  794. *
  795. * @param string $category_name Category name
  796. * @return int|null Category ID or null if not found
  797. */
  798. private function find_category_by_name($category_name) {
  799. // Trim and normalize the category name
  800. $category_name = trim($category_name);
  801. if (empty($category_name)) {
  802. return null;
  803. }
  804. // Simple name lookup only (no hierarchy detection)
  805. // Categories have unique names throughout the system
  806. error_log('STUDIOU WC IMPORT: find_category_by_name() called for: "' . $category_name . '"');
  807. // Method 1: Exact name match
  808. $term = get_term_by('name', $category_name, 'product_cat');
  809. if ($term) {
  810. error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id);
  811. return $term->term_id;
  812. }
  813. // Method 2: Try by slug
  814. $slug = sanitize_title($category_name);
  815. error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"');
  816. $term = get_term_by('slug', $slug, 'product_cat');
  817. if ($term) {
  818. error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id);
  819. return $term->term_id;
  820. }
  821. // Method 3: Search case-insensitively through all categories
  822. $all_terms = get_terms(array(
  823. 'taxonomy' => 'product_cat',
  824. 'hide_empty' => false
  825. ));
  826. error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count($all_terms) . ' categories case-insensitively');
  827. foreach ($all_terms as $t) {
  828. if (strcasecmp($t->name, $category_name) === 0) {
  829. error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"');
  830. return $t->term_id;
  831. }
  832. }
  833. // Not found - try to create it
  834. error_log('STUDIOU WC IMPORT: Category NOT FOUND: "' . $category_name . '" - Attempting to create it');
  835. $new_term = wp_insert_term(
  836. $category_name,
  837. 'product_cat',
  838. array(
  839. 'slug' => $slug
  840. )
  841. );
  842. if (is_wp_error($new_term)) {
  843. error_log('STUDIOU WC IMPORT: Failed to create category "' . $category_name . '": ' . $new_term->get_error_message());
  844. error_log('STUDIOU WC IMPORT: Total available categories: ' . count($all_terms));
  845. // Show first 20 categories for debugging
  846. $sample_cats = array_slice($all_terms, 0, 20);
  847. foreach ($sample_cats as $cat) {
  848. error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')');
  849. }
  850. return null;
  851. } else {
  852. $term_id = $new_term['term_id'];
  853. error_log('STUDIOU WC IMPORT: Successfully created category "' . $category_name . '" with ID: ' . $term_id);
  854. return $term_id;
  855. }
  856. }
  857. /**
  858. * Generate CSV with failed items
  859. *
  860. * @param array $failed_items Array of failed product data
  861. * @return string CSV content
  862. */
  863. public function generate_failed_csv($failed_items) {
  864. if (empty($failed_items)) {
  865. return '';
  866. }
  867. // Define CSV header
  868. $header = array(
  869. 'ID', 'Typ', 'Katalogové číslo', 'Jméno', 'Nadřazené', 'Krátký popis', 'Popis',
  870. 'Název 1 vlastnosti', 'Hodnota(y) 1 vlastnosti', 'Kategorie', 'Obrázky', 'Běžná cena',
  871. 'Vlastnost 1 viditelnost', 'Vlastnost 1 globální', 'Vlastnost 1 varianta',
  872. 'Povolit zákaznické recenze?', 'Minimum Quantity', 'Maximum Quantity'
  873. );
  874. // Start CSV output
  875. $csv = '';
  876. $csv .= '"' . implode('","', $header) . '"' . "\n";
  877. // Add failed items
  878. foreach ($failed_items as $item) {
  879. $row = array();
  880. foreach ($header as $column) {
  881. $value = isset($item[$column]) ? $item[$column] : '';
  882. $row[] = '"' . str_replace('"', '""', $value) . '"';
  883. }
  884. $csv .= implode(',', $row) . "\n";
  885. }
  886. return $csv;
  887. }
  888. }