class-studiou-wc-product-cat-manage-import.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <?php
  2. /**
  3. * Import class
  4. *
  5. * Handles importing product categories from CSV
  6. *
  7. * @since 1.0.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_Cat_Manage_Import {
  16. /**
  17. * Database handler
  18. *
  19. * @var Studiou_WC_Product_Cat_Manage_DB
  20. */
  21. private $db;
  22. /**
  23. * Required CSV headers
  24. *
  25. * @var array
  26. */
  27. private $required_headers = array(
  28. 'name',
  29. 'url-name',
  30. 'parent-name',
  31. 'description',
  32. 'display-type',
  33. 'visibility',
  34. 'protected-passwords',
  35. 'operation'
  36. );
  37. /**
  38. * Valid display types
  39. *
  40. * @var array
  41. */
  42. private $valid_display_types = array(
  43. 'default',
  44. 'products',
  45. 'subcategories',
  46. 'both'
  47. );
  48. /**
  49. * Valid visibility values
  50. *
  51. * @var array
  52. */
  53. private $valid_visibility = array(
  54. 'public',
  55. 'protected'
  56. );
  57. /**
  58. * Valid operation values
  59. *
  60. * @var array
  61. */
  62. private $valid_operations = array(
  63. 'A', // Add
  64. 'U', // Update
  65. 'D' // Delete
  66. );
  67. /**
  68. * Constructor
  69. *
  70. * @param Studiou_WC_Product_Cat_Manage_DB $db Database handler
  71. */
  72. public function __construct($db) {
  73. $this->db = $db;
  74. // Legacy single-shot import (v1.0+). Kept for back-compat; in
  75. // practice the JS now uses the batched parse+batch flow added in
  76. // v1.7.0. Slated for removal after one release.
  77. add_action('wp_ajax_studiou_wcpcm_import', array($this, 'handle_import_ajax'));
  78. // v1.7.0 — M1: batched category import. Same shape as the product
  79. // importer: parse stages rows to disk, batch processes N at a time
  80. // by offset, JS polls until processed >= total. Removes the ~60s
  81. // PHP-timeout cliff on large category trees.
  82. add_action('wp_ajax_studiou_wcpcm_import_parse', array($this, 'handle_import_parse_ajax'));
  83. add_action('wp_ajax_studiou_wcpcm_import_batch', array($this, 'handle_import_batch_ajax'));
  84. }
  85. /**
  86. * v1.7.0 — Step 1: parse the uploaded CSV, validate headers, stage rows
  87. * on disk. Returns the staging token + total count.
  88. */
  89. public function handle_import_parse_ajax() {
  90. while (ob_get_level()) { ob_end_clean(); }
  91. if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
  92. wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
  93. }
  94. if (!current_user_can('manage_woocommerce')) {
  95. wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
  96. }
  97. if (!isset($_FILES['import_file']) || empty($_FILES['import_file']['tmp_name'])) {
  98. wp_send_json_error(array('message' => __('No file was uploaded', 'studiou-wc-product-cat-manage')));
  99. }
  100. try {
  101. $file = fopen($_FILES['import_file']['tmp_name'], 'r');
  102. if (!$file) {
  103. throw new Exception(__('Failed to open the uploaded file', 'studiou-wc-product-cat-manage'));
  104. }
  105. $bom = fread($file, 3);
  106. if ($bom !== "\xEF\xBB\xBF") { rewind($file); }
  107. $headers = fgetcsv($file);
  108. if (!$headers) {
  109. fclose($file);
  110. throw new Exception(__('Failed to read CSV headers', 'studiou-wc-product-cat-manage'));
  111. }
  112. $this->validate_headers($headers);
  113. $header_count = count($headers);
  114. $rows = array();
  115. $row_number = 1;
  116. while (($row = fgetcsv($file)) !== false) {
  117. $row_number++;
  118. if (empty(array_filter($row, function($v) { return $v !== '' && $v !== null; }))) {
  119. continue;
  120. }
  121. $rc = count($row);
  122. if ($rc < $header_count) {
  123. $row = array_pad($row, $header_count, '');
  124. } elseif ($rc > $header_count) {
  125. $row = array_slice($row, 0, $header_count);
  126. }
  127. $data = array();
  128. foreach ($headers as $i => $h) {
  129. $data[$h] = isset($row[$i]) ? $row[$i] : '';
  130. }
  131. $rows[] = array('row_number' => $row_number, 'data' => $data);
  132. }
  133. fclose($file);
  134. if (empty($rows)) {
  135. wp_send_json_error(array('message' => __('CSV contains no data rows.', 'studiou-wc-product-cat-manage')));
  136. }
  137. $token = Studiou_WC_Product_Manage_Product_Import::stage_rows($rows, 'category');
  138. if ($token === false) {
  139. wp_send_json_error(array('message' => __('Failed to stage import data on disk (write error).', 'studiou-wc-product-cat-manage')));
  140. }
  141. wp_send_json_success(array(
  142. 'staging_key' => $token,
  143. 'total' => count($rows),
  144. ));
  145. } catch (\Throwable $e) {
  146. error_log('STUDIOU WC Category Import Parse Error: ' . $e->getMessage());
  147. wp_send_json_error(array('message' => $e->getMessage()));
  148. }
  149. }
  150. /**
  151. * v1.7.0 — Step 2: process a batch of N rows from offset. JS polls until
  152. * processed >= total.
  153. */
  154. public function handle_import_batch_ajax() {
  155. while (ob_get_level()) { ob_end_clean(); }
  156. if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
  157. wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
  158. }
  159. if (!current_user_can('manage_woocommerce')) {
  160. wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
  161. }
  162. $staging_key = isset($_POST['staging_key']) ? sanitize_text_field($_POST['staging_key']) : '';
  163. $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
  164. $batch_size = 10; // category ops are cheap; 10 per batch keeps each AJAX call well under PHP timeout
  165. if ($staging_key === '') {
  166. wp_send_json_error(array('message' => __('Invalid request', 'studiou-wc-product-cat-manage')));
  167. }
  168. try {
  169. $slice = Studiou_WC_Product_Manage_Product_Import::read_stage_slice($staging_key, $offset, $batch_size);
  170. if ($slice === null) {
  171. wp_send_json_error(array('message' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')));
  172. }
  173. $messages = array();
  174. $success = 0;
  175. $errors = 0;
  176. foreach ($slice['batch'] as $item) {
  177. $row_number = $item['row_number'];
  178. $data = $item['data'];
  179. try {
  180. $this->validate_row_data($data, $row_number);
  181. $operation = strtoupper(trim($data['operation']));
  182. switch ($operation) {
  183. case 'A': $r = $this->db->create_category($data); break;
  184. case 'U': $r = $this->db->update_category($data); break;
  185. case 'D': $r = $this->db->delete_category($data['name']); break;
  186. default:
  187. throw new Exception(sprintf(__('Invalid operation "%s" at row %d', 'studiou-wc-product-cat-manage'), $operation, $row_number));
  188. }
  189. if (isset($r['status']) && $r['status'] === 'error') {
  190. $errors++;
  191. $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, $r['message']);
  192. } else {
  193. $success++;
  194. $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, isset($r['message']) ? $r['message'] : __('OK', 'studiou-wc-product-cat-manage'));
  195. }
  196. } catch (\Throwable $row_e) {
  197. $errors++;
  198. $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, $row_e->getMessage());
  199. }
  200. }
  201. $processed = $offset + count($slice['batch']);
  202. $is_final = $processed >= $slice['total'];
  203. if ($is_final) {
  204. Studiou_WC_Product_Manage_Product_Import::delete_stage($staging_key);
  205. }
  206. wp_send_json_success(array(
  207. 'processed' => $processed,
  208. 'total' => $slice['total'],
  209. 'success' => $success,
  210. 'errors' => $errors,
  211. 'messages' => $messages,
  212. 'done' => $is_final,
  213. ));
  214. } catch (\Throwable $e) {
  215. error_log('STUDIOU WC Category Import Batch Error: ' . $e->getMessage());
  216. wp_send_json_error(array('message' => $e->getMessage()));
  217. }
  218. }
  219. /**
  220. * Handle AJAX import request
  221. */
  222. public function handle_import_ajax() {
  223. // Check nonce
  224. if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
  225. wp_send_json_error(array(
  226. 'message' => __('Security check failed', 'studiou-wc-product-cat-manage')
  227. ));
  228. }
  229. // Check permissions
  230. if (!current_user_can('manage_woocommerce')) {
  231. wp_send_json_error(array(
  232. 'message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')
  233. ));
  234. }
  235. // Check if file was uploaded
  236. if (!isset($_FILES['import_file']) || empty($_FILES['import_file']['tmp_name'])) {
  237. wp_send_json_error(array(
  238. 'message' => __('No file was uploaded', 'studiou-wc-product-cat-manage')
  239. ));
  240. }
  241. try {
  242. // Process the uploaded file
  243. $results = $this->process_import_file($_FILES['import_file']['tmp_name']);
  244. // Generate response message
  245. $message = $this->generate_import_results_message($results);
  246. // Return success response
  247. wp_send_json_success(array(
  248. 'message' => $message
  249. ));
  250. } catch (Exception $e) {
  251. wp_send_json_error(array(
  252. 'message' => sprintf(
  253. __('Import error: %s', 'studiou-wc-product-cat-manage'),
  254. $e->getMessage()
  255. )
  256. ));
  257. }
  258. }
  259. /**
  260. * Process import file
  261. *
  262. * @param string $file_path Path to uploaded CSV file
  263. * @return array Import results
  264. * @throws Exception On validation or processing error
  265. */
  266. private function process_import_file($file_path) {
  267. // Open file
  268. $file = fopen($file_path, 'r');
  269. if (!$file) {
  270. throw new Exception(__('Failed to open the uploaded file', 'studiou-wc-product-cat-manage'));
  271. }
  272. // Read and remove UTF-8 BOM if present
  273. $bom = fread($file, 3);
  274. if ($bom !== "\xEF\xBB\xBF") {
  275. // Not a BOM, rewind the file pointer
  276. rewind($file);
  277. }
  278. // Read headers
  279. $headers = fgetcsv($file);
  280. if (!$headers) {
  281. fclose($file);
  282. throw new Exception(__('Failed to read CSV headers', 'studiou-wc-product-cat-manage'));
  283. }
  284. // Validate headers
  285. $this->validate_headers($headers);
  286. // Process rows
  287. $results = array();
  288. $row_number = 1; // Header row is 1
  289. while (($row = fgetcsv($file)) !== false) {
  290. $row_number++;
  291. try {
  292. // Map row data to associative array
  293. $category_data = array();
  294. foreach ($headers as $index => $header) {
  295. $category_data[$header] = isset($row[$index]) ? $row[$index] : '';
  296. }
  297. // Validate row data
  298. $this->validate_row_data($category_data, $row_number);
  299. // Process based on operation
  300. $operation = strtoupper(trim($category_data['operation']));
  301. switch ($operation) {
  302. case 'A':
  303. $result = $this->db->create_category($category_data);
  304. break;
  305. case 'U':
  306. $result = $this->db->update_category($category_data);
  307. break;
  308. case 'D':
  309. $result = $this->db->delete_category($category_data['name']);
  310. break;
  311. default:
  312. throw new Exception(sprintf(
  313. __('Invalid operation "%s" at row %d', 'studiou-wc-product-cat-manage'),
  314. $operation,
  315. $row_number
  316. ));
  317. }
  318. $results[] = $result;
  319. } catch (Exception $e) {
  320. $results[] = array(
  321. 'status' => 'error',
  322. 'message' => sprintf(
  323. __('Error at row %d: %s', 'studiou-wc-product-cat-manage'),
  324. $row_number,
  325. $e->getMessage()
  326. )
  327. );
  328. }
  329. }
  330. fclose($file);
  331. return $results;
  332. }
  333. /**
  334. * Validate CSV headers
  335. *
  336. * @param array $headers Headers from CSV
  337. * @throws Exception If headers are invalid
  338. */
  339. private function validate_headers($headers) {
  340. // Check that all required headers are present
  341. foreach ($this->required_headers as $required_header) {
  342. if (!in_array($required_header, $headers)) {
  343. throw new Exception(sprintf(
  344. __('Missing required header: %s', 'studiou-wc-product-cat-manage'),
  345. $required_header
  346. ));
  347. }
  348. }
  349. }
  350. /**
  351. * Validate row data
  352. *
  353. * @param array $data Row data
  354. * @param int $row_number Row number for error messages
  355. * @throws Exception If data is invalid
  356. */
  357. private function validate_row_data($data, $row_number) {
  358. // Check required fields based on operation
  359. $operation = strtoupper(trim($data['operation']));
  360. if (!in_array($operation, $this->valid_operations)) {
  361. throw new Exception(sprintf(
  362. __('Invalid operation "%s" at row %d. Must be one of: A, U, D', 'studiou-wc-product-cat-manage'),
  363. $operation,
  364. $row_number
  365. ));
  366. }
  367. // Check name is required for all operations
  368. if (empty($data['name'])) {
  369. throw new Exception(sprintf(
  370. __('Missing required field "name" at row %d', 'studiou-wc-product-cat-manage'),
  371. $row_number
  372. ));
  373. }
  374. // Additional validations for Add and Update operations
  375. if ($operation === 'A' || $operation === 'U') {
  376. // URL name is required
  377. if (empty($data['url-name'])) {
  378. throw new Exception(sprintf(
  379. __('Missing required field "url-name" at row %d', 'studiou-wc-product-cat-manage'),
  380. $row_number
  381. ));
  382. }
  383. // Validate display type
  384. if (!empty($data['display-type']) && !in_array($data['display-type'], $this->valid_display_types)) {
  385. throw new Exception(sprintf(
  386. __('Invalid display type "%s" at row %d. Must be one of: default, products, subcategories, both', 'studiou-wc-product-cat-manage'),
  387. $data['display-type'],
  388. $row_number
  389. ));
  390. }
  391. // Validate visibility
  392. if (!empty($data['visibility']) && !in_array($data['visibility'], $this->valid_visibility)) {
  393. throw new Exception(sprintf(
  394. __('Invalid visibility "%s" at row %d. Must be one of: public, protected', 'studiou-wc-product-cat-manage'),
  395. $data['visibility'],
  396. $row_number
  397. ));
  398. }
  399. // If visibility is protected, passwords are required
  400. if (isset($data['visibility']) && $data['visibility'] === 'protected' && empty($data['protected-passwords'])) {
  401. throw new Exception(sprintf(
  402. __('Protected visibility requires at least one password at row %d', 'studiou-wc-product-cat-manage'),
  403. $row_number
  404. ));
  405. }
  406. }
  407. }
  408. /**
  409. * Generate import results message
  410. *
  411. * @param array $results Import results
  412. * @return string Formatted message
  413. */
  414. private function generate_import_results_message($results) {
  415. if (empty($results)) {
  416. return __('No items were processed.', 'studiou-wc-product-cat-manage');
  417. }
  418. $count = count($results);
  419. $message = sprintf(__('Imported %d items:', 'studiou-wc-product-cat-manage'), $count) . "\n";
  420. foreach ($results as $result) {
  421. $message .= "\t" . $result['message'] . "\n";
  422. }
  423. return $message;
  424. }
  425. }