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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  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. * Parse CSV file and store data for batch processing
  37. *
  38. * @param string $file_path Path to CSV file
  39. * @return array Result with total count and transient key
  40. */
  41. public function parse_csv_for_batch($file_path) {
  42. if (!file_exists($file_path)) {
  43. return array('error' => __('File not found', 'studiou-wc-product-cat-manage'));
  44. }
  45. // Open CSV file
  46. $handle = fopen($file_path, 'r');
  47. if (!$handle) {
  48. return array('error' => __('Could not open file', 'studiou-wc-product-cat-manage'));
  49. }
  50. // M3 — strip UTF-8 BOM if present so the first header cell isn't
  51. // "\xEF\xBB\xBFID", which would make every row miss the ID key and
  52. // be treated as new (and then likely skipped by SKU-uniqueness).
  53. $bom = fread($handle, 3);
  54. if ($bom !== "\xEF\xBB\xBF") {
  55. rewind($handle);
  56. }
  57. // Read header row
  58. $header = fgetcsv($handle, 0, ',', '"');
  59. if (!$header) {
  60. fclose($handle);
  61. return array('error' => __('Invalid CSV format', 'studiou-wc-product-cat-manage'));
  62. }
  63. $header_count = count($header);
  64. // Read all data rows
  65. $rows = array();
  66. $skipped_rows = array();
  67. $row_number = 1;
  68. while (($row = fgetcsv($handle, 0, ',', '"')) !== false) {
  69. $row_number++;
  70. // Skip empty rows
  71. if (empty(array_filter($row))) {
  72. continue;
  73. }
  74. // H4 — guard array_combine: on PHP 8+ it throws ValueError when
  75. // the row width doesn't match the header. ValueError extends
  76. // Error (not Exception), so the surrounding catches miss it and
  77. // the whole import dies with a bare 500. Pad short rows (likely
  78. // trailing empty cells from Excel); skip and report over-long
  79. // rows (an unescaped comma we can't safely recover).
  80. $row_count = count($row);
  81. if ($row_count < $header_count) {
  82. $row = array_pad($row, $header_count, '');
  83. } elseif ($row_count > $header_count) {
  84. $padded_for_failed = array_pad($row, $header_count, '');
  85. $padded_for_failed = array_slice($padded_for_failed, 0, $header_count);
  86. $skipped_rows[] = array(
  87. 'row_number' => $row_number,
  88. 'data' => array_combine($header, $padded_for_failed),
  89. 'reason' => sprintf(
  90. /* translators: 1: row number, 2: actual column count, 3: expected column count */
  91. __('Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped.', 'studiou-wc-product-cat-manage'),
  92. $row_number,
  93. $row_count,
  94. $header_count
  95. ),
  96. );
  97. continue;
  98. }
  99. // Combine header with row data
  100. $data = array_combine($header, $row);
  101. $rows[] = array(
  102. 'row_number' => $row_number,
  103. 'data' => $data
  104. );
  105. }
  106. fclose($handle);
  107. // M4 (guard) — set_transient returns false when the serialized
  108. // payload exceeds max_allowed_packet or storage fails for any
  109. // other reason. The downstream batch handler then reports
  110. // "Import data not found or expired", which is misleading — the
  111. // upload was fine, the store just couldn't take it. Surface a
  112. // clear, actionable message instead. The disk-staging rework that
  113. // actually removes this ceiling lands in Phase D.
  114. $transient_key = 'studiou_wcpcm_import_' . uniqid();
  115. if (!set_transient($transient_key, $rows, 3600)) {
  116. return array('error' => sprintf(
  117. /* translators: %d: number of rows */
  118. __('Import too large to stage (%d rows) — the parsed CSV would not fit in the WordPress transient store. Split the file into smaller batches, or wait for the streaming importer in a future release.', 'studiou-wc-product-cat-manage'),
  119. count($rows)
  120. ));
  121. }
  122. // Skipped (column-count-mismatch) rows live in a sibling transient and
  123. // get folded into the final batch's failed_items + messages by
  124. // process_batch(), so they appear in the failed-CSV download without
  125. // changing the JS contract.
  126. if (!empty($skipped_rows)) {
  127. set_transient($transient_key . '_skipped', $skipped_rows, 3600);
  128. }
  129. return array(
  130. 'transient_key' => $transient_key,
  131. 'total' => count($rows),
  132. );
  133. }
  134. /**
  135. * Process batch of products
  136. *
  137. * @param string $transient_key Transient key with CSV data
  138. * @param int $offset Starting offset
  139. * @param int $batch_size Number of items to process
  140. * @return array Result with statistics
  141. */
  142. public function process_batch($transient_key, $offset = 0, $batch_size = 5) {
  143. $rows = get_transient($transient_key);
  144. if (!$rows) {
  145. return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
  146. }
  147. $result = array(
  148. 'success' => 0,
  149. 'failed' => 0,
  150. 'skipped' => 0,
  151. 'failed_items' => array(),
  152. 'messages' => array(),
  153. 'processed' => 0,
  154. 'total' => count($rows)
  155. );
  156. // Get batch of rows to process
  157. $batch = array_slice($rows, $offset, $batch_size);
  158. // On the final batch, fold in any ragged-row skips from the parse
  159. // step (set by parse_csv_for_batch when row width != header width)
  160. // so they appear in the failed-CSV download and the messages list.
  161. $is_final_batch = ($offset + count($batch)) >= count($rows);
  162. if ($is_final_batch) {
  163. $parse_skipped = get_transient($transient_key . '_skipped');
  164. if (is_array($parse_skipped) && !empty($parse_skipped)) {
  165. foreach ($parse_skipped as $skip) {
  166. $result['skipped']++;
  167. $result['messages'][] = $skip['reason'];
  168. $result['failed_items'][] = $skip['data'];
  169. }
  170. delete_transient($transient_key . '_skipped');
  171. }
  172. }
  173. foreach ($batch as $item) {
  174. $row_number = $item['row_number'];
  175. $data = $item['data'];
  176. // Process the product
  177. $process_result = $this->process_product_row($data, $row_number);
  178. if ($process_result['status'] === 'success') {
  179. $result['success']++;
  180. // Don't add success messages to log
  181. } elseif ($process_result['status'] === 'skipped') {
  182. $result['skipped']++;
  183. $result['messages'][] = sprintf(
  184. __('Row %d: SKIPPED - %s', 'studiou-wc-product-cat-manage'),
  185. $row_number,
  186. $process_result['message']
  187. );
  188. // Add to failed items for re-processing
  189. $result['failed_items'][] = $data;
  190. } else {
  191. $result['failed']++;
  192. $result['messages'][] = sprintf(
  193. __('Row %d: FAILED - %s', 'studiou-wc-product-cat-manage'),
  194. $row_number,
  195. $process_result['message']
  196. );
  197. // Add to failed items
  198. $result['failed_items'][] = $data;
  199. }
  200. }
  201. $result['processed'] = $offset + count($batch);
  202. return $result;
  203. }
  204. /**
  205. * Process single product row
  206. *
  207. * @param array $data Product data
  208. * @param int $row_number Row number for error reporting
  209. * @return array Result with status and message
  210. */
  211. private function process_product_row($data, $row_number) {
  212. // Validate required fields
  213. if (!isset($data['Typ']) || empty($data['Typ'])) {
  214. return array(
  215. 'status' => 'skipped',
  216. 'message' => __('Missing product type', 'studiou-wc-product-cat-manage')
  217. );
  218. }
  219. // Rule 3: Only allow "variable" or "variation" types
  220. $type = trim($data['Typ']);
  221. if (!in_array($type, array('variable', 'variation'))) {
  222. return array(
  223. 'status' => 'skipped',
  224. 'message' => sprintf(
  225. __('Invalid product type: %s (only "variable" or "variation" allowed)', 'studiou-wc-product-cat-manage'),
  226. $type
  227. )
  228. );
  229. }
  230. // Get product ID
  231. $product_id = isset($data['ID']) ? intval($data['ID']) : 0;
  232. $sku = isset($data['Katalogové číslo']) ? trim($data['Katalogové číslo']) : '';
  233. // Rule 4: SKU must be unique
  234. if (empty($sku)) {
  235. return array(
  236. 'status' => 'skipped',
  237. 'message' => __('Missing SKU (Katalogové číslo)', 'studiou-wc-product-cat-manage')
  238. );
  239. }
  240. if (!$this->db->is_sku_unique($sku, $product_id)) {
  241. return array(
  242. 'status' => 'skipped',
  243. 'message' => sprintf(
  244. __('SKU "%s" already exists', 'studiou-wc-product-cat-manage'),
  245. $sku
  246. )
  247. );
  248. }
  249. // Rule 5: Validate image URL if provided
  250. $image_url = isset($data['Obrázky']) ? trim($data['Obrázky']) : '';
  251. if (!empty($image_url) && !$this->db->validate_image_url($image_url)) {
  252. return array(
  253. 'status' => 'skipped',
  254. 'message' => sprintf(
  255. __('Invalid image URL: %s', 'studiou-wc-product-cat-manage'),
  256. $image_url
  257. )
  258. );
  259. }
  260. // Process based on product type
  261. if ($type === 'variation') {
  262. return $this->process_variation($data, $product_id);
  263. } else {
  264. return $this->process_variable_product($data, $product_id);
  265. }
  266. }
  267. /**
  268. * Process variable product
  269. *
  270. * @param array $data Product data
  271. * @param int $product_id Product ID (0 for new product)
  272. * @return array Result with status and message
  273. */
  274. private function process_variable_product($data, $product_id) {
  275. try {
  276. // Create or update product
  277. if ($product_id == 0) {
  278. $product = new WC_Product_Variable();
  279. } else {
  280. $product = wc_get_product($product_id);
  281. if (!$product || !$product->is_type('variable')) {
  282. return array(
  283. 'status' => 'error',
  284. 'message' => sprintf(
  285. __('Product ID %d is not a variable product', 'studiou-wc-product-cat-manage'),
  286. $product_id
  287. )
  288. );
  289. }
  290. }
  291. // Set basic data
  292. $product->set_name(isset($data['Jméno']) ? $data['Jméno'] : '');
  293. $product->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : '');
  294. $product->set_short_description(isset($data['Krátký popis']) ? $data['Krátký popis'] : '');
  295. $product->set_description(isset($data['Popis']) ? $data['Popis'] : '');
  296. // Set categories
  297. if (isset($data['Kategorie']) && !empty($data['Kategorie'])) {
  298. $categories = array_map('trim', explode('|', $data['Kategorie']));
  299. $category_ids = array();
  300. error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name());
  301. error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']);
  302. foreach ($categories as $category_name) {
  303. if (empty($category_name)) {
  304. continue;
  305. }
  306. $category_id = $this->find_category_by_name($category_name);
  307. error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND'));
  308. if ($category_id) {
  309. $category_ids[] = $category_id;
  310. }
  311. }
  312. error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids));
  313. if (!empty($category_ids)) {
  314. $product->set_category_ids($category_ids);
  315. error_log('STUDIOU WC IMPORT: Categories assigned to product via set_category_ids()');
  316. } else {
  317. error_log('STUDIOU WC IMPORT: WARNING - No valid category IDs found for: ' . $data['Kategorie']);
  318. }
  319. } else {
  320. error_log('STUDIOU WC IMPORT: No categories in CSV for product: ' . $product->get_name());
  321. }
  322. // Handle image
  323. if (isset($data['Obrázky']) && !empty($data['Obrázky'])) {
  324. $image_id = $this->upload_image_from_url($data['Obrázky']);
  325. if ($image_id) {
  326. $product->set_image_id($image_id);
  327. }
  328. }
  329. // Set reviews enabled
  330. $reviews_enabled = isset($data['Povolit zákaznické recenze?']) && $data['Povolit zákaznické recenze?'] === '1';
  331. $product->set_reviews_allowed($reviews_enabled);
  332. // Handle product attribute
  333. if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti'])) {
  334. $attribute_name = $data['Název 1 vlastnosti'];
  335. $attribute_values = isset($data['Hodnota(y) 1 vlastnosti']) ? $data['Hodnota(y) 1 vlastnosti'] : '';
  336. $is_visible = isset($data['Vlastnost 1 viditelnost']) && $data['Vlastnost 1 viditelnost'] === '1';
  337. $is_variation = isset($data['Vlastnost 1 varianta']) && $data['Vlastnost 1 varianta'] === '1';
  338. // Create attribute taxonomy if it doesn't exist
  339. $this->db->create_attribute_if_not_exists($attribute_name);
  340. $attribute = new WC_Product_Attribute();
  341. $attribute->set_name(wc_attribute_taxonomy_name($attribute_name));
  342. $attribute->set_visible($is_visible);
  343. $attribute->set_variation($is_variation);
  344. // Set attribute options (terms)
  345. $values = array_map('trim', explode('|', $attribute_values));
  346. $attribute->set_options($values);
  347. $product->set_attributes(array($attribute));
  348. }
  349. // Handle minimum/maximum quantity
  350. if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) {
  351. $product->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity']));
  352. }
  353. if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) {
  354. $product->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity']));
  355. }
  356. // Save product
  357. $saved_id = $product->save();
  358. $message = $product_id == 0 ?
  359. sprintf(__('Created variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name()) :
  360. sprintf(__('Updated variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name());
  361. return array(
  362. 'status' => 'success',
  363. 'message' => $message
  364. );
  365. } catch (Exception $e) {
  366. return array(
  367. 'status' => 'error',
  368. 'message' => $e->getMessage()
  369. );
  370. }
  371. }
  372. /**
  373. * Process product variation
  374. *
  375. * @param array $data Product data
  376. * @param int $variation_id Variation ID (0 for new variation)
  377. * @return array Result with status and message
  378. */
  379. private function process_variation($data, $variation_id) {
  380. try {
  381. // Rule 6: Must have parent reference
  382. if (!isset($data['Nadřazené']) || empty($data['Nadřazené'])) {
  383. return array(
  384. 'status' => 'skipped',
  385. 'message' => __('Missing parent product reference (Nadřazené)', 'studiou-wc-product-cat-manage')
  386. );
  387. }
  388. // Rule 7: Must have regular price
  389. if (!isset($data['Běžná cena']) || empty($data['Běžná cena'])) {
  390. return array(
  391. 'status' => 'skipped',
  392. 'message' => __('Missing regular price (Běžná cena)', 'studiou-wc-product-cat-manage')
  393. );
  394. }
  395. // Find parent product by SKU
  396. $parent_sku = trim($data['Nadřazené']);
  397. $parent_product = $this->db->get_product_by_sku($parent_sku);
  398. if (!$parent_product || !$parent_product->is_type('variable')) {
  399. return array(
  400. 'status' => 'skipped',
  401. 'message' => sprintf(
  402. __('Parent product with SKU "%s" not found or not variable', 'studiou-wc-product-cat-manage'),
  403. $parent_sku
  404. )
  405. );
  406. }
  407. // Create or update variation
  408. if ($variation_id == 0) {
  409. $variation = new WC_Product_Variation();
  410. $variation->set_parent_id($parent_product->get_id());
  411. } else {
  412. $variation = wc_get_product($variation_id);
  413. if (!$variation || !$variation->is_type('variation')) {
  414. return array(
  415. 'status' => 'error',
  416. 'message' => sprintf(
  417. __('Product ID %d is not a variation', 'studiou-wc-product-cat-manage'),
  418. $variation_id
  419. )
  420. );
  421. }
  422. }
  423. // Set basic data
  424. $variation->set_name(isset($data['Jméno']) ? $data['Jméno'] : '');
  425. $variation->set_sku(isset($data['Katalogové číslo']) ? $data['Katalogové číslo'] : '');
  426. $variation->set_description(isset($data['Popis']) ? $data['Popis'] : '');
  427. $variation->set_regular_price($data['Běžná cena']);
  428. // Handle image
  429. if (isset($data['Obrázky']) && !empty($data['Obrázky'])) {
  430. $image_id = $this->upload_image_from_url($data['Obrázky']);
  431. if ($image_id) {
  432. $variation->set_image_id($image_id);
  433. }
  434. }
  435. // Set variation attributes
  436. if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti']) &&
  437. isset($data['Hodnota(y) 1 vlastnosti']) && !empty($data['Hodnota(y) 1 vlastnosti'])) {
  438. $attribute_name = wc_attribute_taxonomy_name($data['Název 1 vlastnosti']);
  439. $attribute_value = $data['Hodnota(y) 1 vlastnosti'];
  440. $variation->set_attributes(array(
  441. $attribute_name => $attribute_value
  442. ));
  443. }
  444. // Handle minimum/maximum quantity
  445. if (isset($data['Minimum Quantity']) && !empty($data['Minimum Quantity'])) {
  446. $variation->update_meta_data('_minimum_quantity', intval($data['Minimum Quantity']));
  447. }
  448. if (isset($data['Maximum Quantity']) && !empty($data['Maximum Quantity'])) {
  449. $variation->update_meta_data('_maximum_quantity', intval($data['Maximum Quantity']));
  450. }
  451. // Save variation
  452. $saved_id = $variation->save();
  453. $message = $variation_id == 0 ?
  454. sprintf(__('Created variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name()) :
  455. sprintf(__('Updated variation: %s', 'studiou-wc-product-cat-manage'), $variation->get_name());
  456. return array(
  457. 'status' => 'success',
  458. 'message' => $message
  459. );
  460. } catch (Exception $e) {
  461. return array(
  462. 'status' => 'error',
  463. 'message' => $e->getMessage()
  464. );
  465. }
  466. }
  467. /**
  468. * Find an attachment previously imported from this exact source URL.
  469. *
  470. * Uses get_posts (not a raw $wpdb query) so a row whose attachment post was
  471. * deleted but whose meta was somehow orphaned can't resolve to a missing post.
  472. * Orders by ID ascending so the earliest (canonical) attachment wins
  473. * deterministically if more than one ever carries the same source URL.
  474. *
  475. * @param string $url Trimmed source URL.
  476. * @return int Attachment ID, or 0 if none.
  477. */
  478. private function find_attachment_by_source_url($url) {
  479. $ids = get_posts(array(
  480. 'post_type' => 'attachment',
  481. 'post_status' => 'inherit',
  482. 'posts_per_page' => 1,
  483. 'orderby' => 'ID',
  484. 'order' => 'ASC',
  485. 'fields' => 'ids',
  486. 'no_found_rows' => true,
  487. 'update_post_meta_cache' => false,
  488. 'update_post_term_cache' => false,
  489. 'meta_query' => array(
  490. array(
  491. 'key' => '_studiou_wcpcm_source_url',
  492. 'value' => $url,
  493. 'compare' => '=',
  494. ),
  495. ),
  496. ));
  497. return !empty($ids) ? (int) $ids[0] : 0;
  498. }
  499. /**
  500. * Upload an image from a URL, deduplicating by source URL.
  501. *
  502. * A given source URL is downloaded at most once: an in-request static cache
  503. * absorbs repeats within a batch, and a `_studiou_wcpcm_source_url` postmeta
  504. * lookup reuses attachments across batches and across imports.
  505. *
  506. * @param string $url Image URL (raw value from the Obrázky column).
  507. * @return int|null Attachment ID, or null on empty input / download failure.
  508. */
  509. private function upload_image_from_url($url) {
  510. // Normalize the key. process_product_row() validates a *trimmed* URL but
  511. // the callers pass the raw cell, so trim here to keep the cache key, the
  512. // DB lookup, the download, and the stored postmeta all identical to the
  513. // value that was validated (and to collapse whitespace-only variants).
  514. $url = is_string($url) ? trim($url) : '';
  515. if ($url === '') {
  516. return null;
  517. }
  518. // Layer 2 — in-request static cache.
  519. if (isset(self::$url_cache[$url])) {
  520. if (defined('WP_DEBUG') && WP_DEBUG) {
  521. error_log('STUDIOU WC IMPORT: image cache HIT (request) ' . $url . ' -> ' . self::$url_cache[$url]);
  522. }
  523. return self::$url_cache[$url];
  524. }
  525. // Layer 1 — persistent postmeta lookup (across batches and re-imports).
  526. $existing = $this->find_attachment_by_source_url($url);
  527. if ($existing) {
  528. self::$url_cache[$url] = $existing;
  529. if (defined('WP_DEBUG') && WP_DEBUG) {
  530. error_log('STUDIOU WC IMPORT: image cache HIT (db) ' . $url . ' -> ' . $existing);
  531. }
  532. return $existing;
  533. }
  534. // Layer 0 — fresh sideload.
  535. require_once(ABSPATH . 'wp-admin/includes/media.php');
  536. require_once(ABSPATH . 'wp-admin/includes/file.php');
  537. require_once(ABSPATH . 'wp-admin/includes/image.php');
  538. // Suppress intermediate sizes for import speed. Use the stable
  539. // '__return_empty_array' callable (not a fresh closure) so remove_filter()
  540. // can actually match it, and detach it in finally so it can never leak
  541. // into later media operations in this request.
  542. add_filter('intermediate_image_sizes_advanced', '__return_empty_array');
  543. try {
  544. // download_url()'s second arg sets the request timeout (30s); no
  545. // http_request_timeout filter is needed.
  546. $tmp = download_url($url, 30);
  547. if (is_wp_error($tmp)) {
  548. error_log('STUDIOU WC IMPORT: image download failed for ' . $url . ' - ' . $tmp->get_error_message());
  549. return null;
  550. }
  551. // Derive a clean on-disk filename. parse_url() strips any query string
  552. // (img.jpg?v=2 -> img.jpg) so wp_unique_filename() doesn't fold the
  553. // query into the basename. Null-safe: parse_url() may return null/false
  554. // and basename(null) is deprecated on PHP 8.1+ (we require 8.2).
  555. $path = parse_url($url, PHP_URL_PATH);
  556. $name = (is_string($path) && $path !== '') ? basename($path) : '';
  557. if ($name === '') {
  558. $name = 'image-' . md5($url) . '.jpg';
  559. }
  560. $file_array = array(
  561. 'name' => $name,
  562. 'tmp_name' => $tmp,
  563. );
  564. $id = media_handle_sideload($file_array, 0);
  565. if (is_wp_error($id)) {
  566. @unlink($tmp);
  567. error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message());
  568. return null;
  569. }
  570. // Tag the attachment so future rows / batches / imports dedup on it.
  571. update_post_meta($id, '_studiou_wcpcm_source_url', $url);
  572. self::$url_cache[$url] = (int) $id;
  573. if (defined('WP_DEBUG') && WP_DEBUG) {
  574. error_log('STUDIOU WC IMPORT: image sideloaded ' . $url . ' -> ' . $id);
  575. }
  576. return (int) $id;
  577. } catch (Exception $e) {
  578. error_log('STUDIOU WC IMPORT: image upload exception for ' . $url . ' - ' . $e->getMessage());
  579. return null;
  580. } finally {
  581. remove_filter('intermediate_image_sizes_advanced', '__return_empty_array');
  582. }
  583. }
  584. /**
  585. * Find category by name (simple lookup, categories have unique names)
  586. *
  587. * @param string $category_name Category name
  588. * @return int|null Category ID or null if not found
  589. */
  590. private function find_category_by_name($category_name) {
  591. // Trim and normalize the category name
  592. $category_name = trim($category_name);
  593. if (empty($category_name)) {
  594. return null;
  595. }
  596. // Simple name lookup only (no hierarchy detection)
  597. // Categories have unique names throughout the system
  598. error_log('STUDIOU WC IMPORT: find_category_by_name() called for: "' . $category_name . '"');
  599. // Method 1: Exact name match
  600. $term = get_term_by('name', $category_name, 'product_cat');
  601. if ($term) {
  602. error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id);
  603. return $term->term_id;
  604. }
  605. // Method 2: Try by slug
  606. $slug = sanitize_title($category_name);
  607. error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"');
  608. $term = get_term_by('slug', $slug, 'product_cat');
  609. if ($term) {
  610. error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id);
  611. return $term->term_id;
  612. }
  613. // Method 3: Search case-insensitively through all categories
  614. $all_terms = get_terms(array(
  615. 'taxonomy' => 'product_cat',
  616. 'hide_empty' => false
  617. ));
  618. error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count($all_terms) . ' categories case-insensitively');
  619. foreach ($all_terms as $t) {
  620. if (strcasecmp($t->name, $category_name) === 0) {
  621. error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"');
  622. return $t->term_id;
  623. }
  624. }
  625. // Not found - try to create it
  626. error_log('STUDIOU WC IMPORT: Category NOT FOUND: "' . $category_name . '" - Attempting to create it');
  627. $new_term = wp_insert_term(
  628. $category_name,
  629. 'product_cat',
  630. array(
  631. 'slug' => $slug
  632. )
  633. );
  634. if (is_wp_error($new_term)) {
  635. error_log('STUDIOU WC IMPORT: Failed to create category "' . $category_name . '": ' . $new_term->get_error_message());
  636. error_log('STUDIOU WC IMPORT: Total available categories: ' . count($all_terms));
  637. // Show first 20 categories for debugging
  638. $sample_cats = array_slice($all_terms, 0, 20);
  639. foreach ($sample_cats as $cat) {
  640. error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')');
  641. }
  642. return null;
  643. } else {
  644. $term_id = $new_term['term_id'];
  645. error_log('STUDIOU WC IMPORT: Successfully created category "' . $category_name . '" with ID: ' . $term_id);
  646. return $term_id;
  647. }
  648. }
  649. /**
  650. * Generate CSV with failed items
  651. *
  652. * @param array $failed_items Array of failed product data
  653. * @return string CSV content
  654. */
  655. public function generate_failed_csv($failed_items) {
  656. if (empty($failed_items)) {
  657. return '';
  658. }
  659. // Define CSV header
  660. $header = array(
  661. 'ID', 'Typ', 'Katalogové číslo', 'Jméno', 'Nadřazené', 'Krátký popis', 'Popis',
  662. 'Název 1 vlastnosti', 'Hodnota(y) 1 vlastnosti', 'Kategorie', 'Obrázky', 'Běžná cena',
  663. 'Vlastnost 1 viditelnost', 'Vlastnost 1 globální', 'Vlastnost 1 varianta',
  664. 'Povolit zákaznické recenze?', 'Minimum Quantity', 'Maximum Quantity'
  665. );
  666. // Start CSV output
  667. $csv = '';
  668. $csv .= '"' . implode('","', $header) . '"' . "\n";
  669. // Add failed items
  670. foreach ($failed_items as $item) {
  671. $row = array();
  672. foreach ($header as $column) {
  673. $value = isset($item[$column]) ? $item[$column] : '';
  674. $row[] = '"' . str_replace('"', '""', $value) . '"';
  675. }
  676. $csv .= implode(',', $row) . "\n";
  677. }
  678. return $csv;
  679. }
  680. }