class-db-manager.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. <?php
  2. /**
  3. * Database operations and CSV-protocol processing for the
  4. * Studiou WC Order Print Statuses plugin.
  5. */
  6. defined('ABSPATH') || exit;
  7. class Studiou_DB_Manager {
  8. /**
  9. * Order statuses that block automated print-pipeline transitions.
  10. * Cancelled / refunded orders should not be silently reactivated by a bulk
  11. * action; completed orders are already finished.
  12. */
  13. const TERMINAL_STATUSES = array('cancelled', 'refunded', 'failed', 'completed');
  14. /**
  15. * Return one row per (order × line-item) for the print-shop export.
  16. *
  17. * Design notes:
  18. * - Multiple product categories are concatenated into prod_cat instead of
  19. * multiplying the row.
  20. * - Simple (non-variable) products are included via LEFT JOIN; their
  21. * prod_var / prod_var_type fields will be NULL.
  22. * - The image URL is resolved in PHP via wp_get_attachment_url() rather
  23. * than reading wp_posts.guid, which the codex warns is not guaranteed
  24. * to be a URL (breaks under offloaded media / multisite uploads paths).
  25. * - The variation attribute is joined on terms.slug — WC stores the slug
  26. * in `attribute_pa_format` postmeta, not the term name.
  27. * - qty is summed across duplicate line items (a customer adding the
  28. * same variation twice without merging produces two rows in the
  29. * lookup table; MIN() would silently undercount).
  30. *
  31. * @param int[] $order_ids
  32. * @return array Each element is an stdClass with fields:
  33. * order_no, prod_cat, prod_name, prod_var, prod_var_type,
  34. * prod_img_url, qty, email.
  35. */
  36. public static function get_orders_for_printing($order_ids) {
  37. global $wpdb;
  38. // Explicit callback so the filter doesn't accidentally drop legitimate
  39. // zero values if this helper is ever repurposed for non-ID lists.
  40. $order_ids = array_filter(
  41. array_map('intval', (array) $order_ids),
  42. function ($id) { return $id > 0; }
  43. );
  44. if (empty($order_ids)) {
  45. return array();
  46. }
  47. $orders_table = $wpdb->prefix . 'wc_orders';
  48. if (!self::table_exists($orders_table)) {
  49. UtilsLog::log("get_orders_for_printing: required HPOS table {$orders_table} is missing. Is WooCommerce HPOS enabled?");
  50. UtilsLog::message(
  51. __('Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.', 'studiou-wc-ord-print-statuses'),
  52. 'error'
  53. );
  54. return array();
  55. }
  56. $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
  57. if (!self::table_exists($lookup_table)) {
  58. UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
  59. UtilsLog::message(
  60. __('Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.', 'studiou-wc-ord-print-statuses'),
  61. 'error'
  62. );
  63. return array();
  64. }
  65. $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
  66. // Raise the GROUP_CONCAT byte cap (default 1024) so multi-category
  67. // products with long names are not silently truncated. Use GREATEST
  68. // so we never *lower* a setting another piece of code may rely on.
  69. $wpdb->query("SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)");
  70. // Non-aggregated SELECT columns are wrapped in MIN()/SUM() to remain
  71. // compliant with MySQL's ONLY_FULL_GROUP_BY mode (default in 5.7+).
  72. // qty uses SUM() because a single (order, product, variation) group
  73. // may contain multiple lookup rows when a customer added the same
  74. // variation twice without cart-merge.
  75. //
  76. // prod_print_status comes from `_print_status` on the order line item
  77. // meta (added in 1.5.0). LEFT JOIN with COALESCE so missing meta
  78. // (lazy-default items) reads as `pending-print` in the export.
  79. $sql = "
  80. SELECT
  81. `orders`.`id` AS `order_no`,
  82. GROUP_CONCAT(DISTINCT `t`.`name` ORDER BY `t`.`name` SEPARATOR ', ') AS `prod_cat`,
  83. MIN(`products`.`post_title`) AS `prod_name`,
  84. MIN(`product_variations`.`post_title`) AS `prod_var`,
  85. MIN(`product_variations_name`.`name`) AS `prod_var_type`,
  86. MIN(`product_meta`.`meta_value`) AS `prod_img_id`,
  87. SUM(`order_products`.`product_qty`) AS `qty`,
  88. COALESCE(MIN(`print_status_meta`.`meta_value`), 'pending-print') AS `prod_print_status`,
  89. MIN(`orders`.`billing_email`) AS `email`
  90. FROM `{$wpdb->prefix}wc_orders` `orders`
  91. JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
  92. ON `orders`.`id` = `order_products`.`order_id`
  93. JOIN `{$wpdb->posts}` `products`
  94. ON `products`.`ID` = `order_products`.`product_id`
  95. LEFT JOIN `{$wpdb->posts}` `product_variations`
  96. ON `product_variations`.`ID` = `order_products`.`variation_id`
  97. LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
  98. ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
  99. AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
  100. LEFT JOIN `{$wpdb->terms}` `product_variations_name`
  101. ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
  102. LEFT JOIN `{$wpdb->postmeta}` `product_meta`
  103. ON `product_meta`.`post_id` = `products`.`ID`
  104. AND `product_meta`.`meta_key` = '_thumbnail_id'
  105. LEFT JOIN `{$wpdb->prefix}woocommerce_order_itemmeta` `print_status_meta`
  106. ON `print_status_meta`.`order_item_id` = `order_products`.`order_item_id`
  107. AND `print_status_meta`.`meta_key` = '_print_status'
  108. LEFT JOIN `{$wpdb->term_relationships}` `tr`
  109. ON `tr`.`object_id` = `products`.`ID`
  110. LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
  111. ON `tt`.`term_taxonomy_id` = `tr`.`term_taxonomy_id`
  112. AND `tt`.`taxonomy` = 'product_cat'
  113. LEFT JOIN `{$wpdb->terms}` `t`
  114. ON `t`.`term_id` = `tt`.`term_id`
  115. WHERE `orders`.`id` IN ($placeholders)
  116. GROUP BY
  117. `orders`.`id`,
  118. `order_products`.`product_id`,
  119. `order_products`.`variation_id`
  120. ORDER BY `orders`.`id`
  121. ";
  122. $prepared = $wpdb->prepare($sql, $order_ids);
  123. $results = $wpdb->get_results($prepared);
  124. if ($results === false) {
  125. UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
  126. return array();
  127. }
  128. // Resolve the attachment ID to a URL via WP's API — respects
  129. // offloaded-media plugins, multisite upload paths, and the
  130. // wp_get_attachment_url filter chain. Normalise integer-valued
  131. // qty (SUM returns DECIMAL → "2.0000") and CSV-escape every value
  132. // to neutralise Excel/LibreOffice formula injection.
  133. foreach ($results as $row) {
  134. $attachment_id = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
  135. $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
  136. unset($row->prod_img_id);
  137. if (isset($row->qty)) {
  138. $row->qty = self::format_quantity($row->qty);
  139. }
  140. foreach (get_object_vars($row) as $field => $value) {
  141. $row->$field = self::csv_escape($value);
  142. }
  143. }
  144. return $results;
  145. }
  146. /**
  147. * Drop trailing zeros and the decimal point when the quantity is whole,
  148. * keeping a sensible decimal representation when it isn't.
  149. */
  150. private static function format_quantity($value) {
  151. if (!is_numeric($value)) {
  152. return (string) $value;
  153. }
  154. $float = (float) $value;
  155. if ((float) (int) $float === $float) {
  156. return (string) (int) $float;
  157. }
  158. return rtrim(rtrim(sprintf('%.4F', $float), '0'), '.');
  159. }
  160. /**
  161. * Defuse Excel / LibreOffice / Google Sheets formula execution by
  162. * prefixing values that begin with =, +, -, @, tab, or CR with a single
  163. * quote. OWASP-recommended pattern. The leading quote is hidden by
  164. * Excel when displayed but disables formula evaluation.
  165. *
  166. * Checks the first non-whitespace character (not just $value[0]) so
  167. * cells like " =CMD" — with leading whitespace some spreadsheet apps
  168. * strip before evaluating — are also defused.
  169. */
  170. private static function csv_escape($value) {
  171. if (!is_string($value)) {
  172. return $value;
  173. }
  174. if ($value === '') {
  175. return $value;
  176. }
  177. $trimmed = ltrim($value);
  178. if ($trimmed === '') {
  179. return $value;
  180. }
  181. $first = $trimmed[0];
  182. if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
  183. return "'" . $value;
  184. }
  185. return $value;
  186. }
  187. /**
  188. * Check that a table exists. `$wpdb->esc_like()` escapes the LIKE
  189. * wildcards (`_`, `%`) that show up in conventional `$wpdb->prefix`
  190. * values like `wp_test_` — otherwise the underscores would match any
  191. * single character and could produce false positives.
  192. */
  193. private static function table_exists($table) {
  194. global $wpdb;
  195. $like = $wpdb->esc_like($table);
  196. $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like));
  197. return $found === $table;
  198. }
  199. /**
  200. * Bulk-set orders to "to-print", skipping orders in terminal states.
  201. *
  202. * @param int[] $order_ids
  203. * @return int Number of orders actually updated.
  204. */
  205. public static function set_orders_to_prepare_printing($order_ids) {
  206. return self::bulk_set_print_status($order_ids, 'to-print', 'to_print_date');
  207. }
  208. /**
  209. * Bulk-set orders to "in-print", skipping orders in terminal states.
  210. *
  211. * @param int[] $order_ids
  212. * @return int Number of orders actually updated.
  213. */
  214. public static function set_orders_to_in_printing($order_ids) {
  215. return self::bulk_set_print_status($order_ids, 'in-print', 'in_print_date');
  216. }
  217. private static function bulk_set_print_status($order_ids, $status_slug, $meta_key) {
  218. $updated_count = 0;
  219. $skipped = 0;
  220. // Explanatory note for the order activity feed. Mirrors the
  221. // "Marked … by … Protocol import." pattern used in the import
  222. // handlers so the audit trail consistently records *how* a
  223. // transition was triggered.
  224. //
  225. // Explicit map keyed by slug so a future contributor adding a
  226. // third status doesn't accidentally inherit the wrong note via
  227. // an else-implicit ternary.
  228. $notes = array(
  229. 'to-print' => __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
  230. 'in-print' => __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
  231. );
  232. $note = isset($notes[$status_slug]) ? $notes[$status_slug] : '';
  233. foreach ((array) $order_ids as $order_id) {
  234. $order_id = (int) $order_id;
  235. $order = wc_get_order($order_id);
  236. if (!$order) {
  237. UtilsLog::log('Order #' . $order_id . ' not found');
  238. continue;
  239. }
  240. if ($order->has_status(self::TERMINAL_STATUSES)) {
  241. UtilsLog::log('Order #' . $order_id . ' skipped (terminal status: ' . $order->get_status() . ')');
  242. $skipped++;
  243. continue;
  244. }
  245. $order->update_status($status_slug, $note);
  246. $order->update_meta_data($meta_key, current_time('mysql'));
  247. $order->save();
  248. $updated_count++;
  249. UtilsLog::log('Order #' . $order_id . ' marked as "' . $status_slug . '"');
  250. }
  251. if ($skipped > 0) {
  252. UtilsLog::message(
  253. sprintf(
  254. _n(
  255. '%d order skipped because its status is final (cancelled, refunded, failed, or completed).',
  256. '%d orders skipped because their status is final (cancelled, refunded, failed, or completed).',
  257. $skipped,
  258. 'studiou-wc-ord-print-statuses'
  259. ),
  260. $skipped
  261. ),
  262. 'warning'
  263. );
  264. }
  265. return $updated_count;
  266. }
  267. /**
  268. * Import an InPrint protocol CSV.
  269. *
  270. * @param array $csv_data Rows produced by parse_csv().
  271. * @return array{updated_count:int,errors:string[]}
  272. */
  273. public static function import_inprint_protocol($csv_data) {
  274. $updated_count = 0;
  275. $errors = array();
  276. foreach (self::dedupe_by_order_no($csv_data) as $row) {
  277. if (!isset($row['order_no']) || !isset($row['externalorder']) || !isset($row['externalorderdate'])) {
  278. $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
  279. continue;
  280. }
  281. $order = wc_get_order($row['order_no']);
  282. if (!$order) {
  283. UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' not found');
  284. $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
  285. continue;
  286. }
  287. if ($order->has_status(self::TERMINAL_STATUSES)) {
  288. UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
  289. $errors[] = sprintf(
  290. __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
  291. $row['order_no']
  292. );
  293. continue;
  294. }
  295. $order->update_status(
  296. 'in-print',
  297. __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
  298. );
  299. $order->update_meta_data('in_print_date', current_time('mysql'));
  300. $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
  301. $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
  302. $order->save();
  303. $updated_count++;
  304. UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' marked "in-print"');
  305. }
  306. return array('updated_count' => $updated_count, 'errors' => $errors);
  307. }
  308. /**
  309. * Import a Delivered protocol CSV.
  310. *
  311. * @param array $csv_data Rows produced by parse_csv().
  312. * @return array{updated_count:int,errors:string[]}
  313. */
  314. public static function import_delivered_protocol($csv_data) {
  315. $updated_count = 0;
  316. $items_advanced_total = 0;
  317. $errors = array();
  318. // Statuses we should not silently flip to "completed". `completed`
  319. // itself is excluded from this list — re-running the same delivered
  320. // protocol on already-completed orders is a benign no-op transition.
  321. $non_completable = array('cancelled', 'refunded', 'failed');
  322. $item_status_mgr = new Order_Item_Status_Manager();
  323. foreach (self::dedupe_by_order_no($csv_data) as $row) {
  324. if (!isset($row['order_no'])) {
  325. $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
  326. continue;
  327. }
  328. $order = wc_get_order($row['order_no']);
  329. if (!$order) {
  330. UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' not found');
  331. $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
  332. continue;
  333. }
  334. if ($order->has_status($non_completable)) {
  335. UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
  336. $errors[] = sprintf(
  337. __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
  338. $row['order_no']
  339. );
  340. continue;
  341. }
  342. // Advance every still-pending or in-print item to done-print so
  343. // the completion guard installed by Order_Item_Status_Manager
  344. // is satisfied. Items already in skip-print or done-print are
  345. // untouched.
  346. $advanced = $item_status_mgr->advance_remaining_to_done($order);
  347. if ($advanced > 0) {
  348. $items_advanced_total += $advanced;
  349. $order->add_order_note(sprintf(
  350. /* translators: %d is the number of items advanced. */
  351. _n(
  352. '%d line item advanced to "Done Print" by Delivered Protocol import.',
  353. '%d line items advanced to "Done Print" by Delivered Protocol import.',
  354. $advanced,
  355. 'studiou-wc-ord-print-statuses'
  356. ),
  357. $advanced
  358. ));
  359. }
  360. $order->update_status(
  361. 'completed',
  362. __('Marked completed by Delivered Protocol import.', 'studiou-wc-ord-print-statuses')
  363. );
  364. $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
  365. $order->save();
  366. $updated_count++;
  367. UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' marked "completed" (' . $advanced . ' items advanced)');
  368. }
  369. if ($items_advanced_total > 0) {
  370. UtilsLog::message(
  371. sprintf(
  372. /* translators: %d total items advanced across all orders in this import. */
  373. _n(
  374. '%d line item advanced to "Done Print" to satisfy completion guard.',
  375. '%d line items advanced to "Done Print" to satisfy completion guard.',
  376. $items_advanced_total,
  377. 'studiou-wc-ord-print-statuses'
  378. ),
  379. $items_advanced_total
  380. ),
  381. 'info'
  382. );
  383. }
  384. return array('updated_count' => $updated_count, 'errors' => $errors);
  385. }
  386. /**
  387. * Parse a CSV file into an array of associative rows.
  388. * Headers are normalised (lowercased, whitespace stripped) so files prepared
  389. * to either the legacy spec casing ("Order No") or the documented lowercase
  390. * form ("order_no") both work. A leading UTF-8 BOM is stripped.
  391. *
  392. * @param string $file_path
  393. * @return array Empty array on failure.
  394. */
  395. public static function parse_csv($file_path) {
  396. $csv_data = array();
  397. $handle = fopen($file_path, 'r');
  398. if ($handle === false) {
  399. UtilsLog::log('parse_csv: failed to open ' . $file_path);
  400. return $csv_data;
  401. }
  402. try {
  403. // Strip a leading UTF-8 BOM if present so the first header parses
  404. // cleanly. The plugin's own exports include a BOM (for Excel),
  405. // and Notepad-saved CSVs frequently include one.
  406. $first_bytes = fread($handle, 3);
  407. if ($first_bytes !== "\xEF\xBB\xBF") {
  408. rewind($handle);
  409. }
  410. // Explicit empty-string escape suppresses the PHP 8.4 deprecation
  411. // notice for the implicit '\\' default.
  412. $headers = fgetcsv($handle, 0, ',', '"', '');
  413. if (!is_array($headers) || empty(array_filter($headers))) {
  414. UtilsLog::log('parse_csv: header row missing or empty');
  415. return $csv_data;
  416. }
  417. $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
  418. // Reject CSVs whose headers collide after normalisation (e.g.,
  419. // "Order No" and "order_no" both → "order_no"). array_combine
  420. // would silently let the later column's data overwrite the
  421. // earlier — losing one column's values entirely.
  422. if (count($headers) !== count(array_unique($headers))) {
  423. UtilsLog::log('parse_csv: duplicate column headers after normalisation: ' . implode(', ', $headers));
  424. return $csv_data;
  425. }
  426. while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
  427. if (count($data) !== count($headers)) {
  428. continue;
  429. }
  430. $csv_data[] = array_combine($headers, $data);
  431. }
  432. } finally {
  433. fclose($handle);
  434. }
  435. return $csv_data;
  436. }
  437. private static function normalise_header($header) {
  438. $header = strtolower(trim((string) $header));
  439. // Strip any UTF-8 BOM that survived the file-level guard.
  440. $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
  441. // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
  442. $header = preg_replace('/\s+/', '_', $header);
  443. return $header;
  444. }
  445. private static function dedupe_by_order_no($rows) {
  446. $seen = array();
  447. $unique = array();
  448. foreach ($rows as $row) {
  449. $raw_key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
  450. if ($raw_key === '') {
  451. continue;
  452. }
  453. // Case-insensitive dedup so alphabetic order numbers
  454. // ("ord-12345" vs "ORD-12345") collapse to a single row.
  455. $key = strtolower($raw_key);
  456. if (isset($seen[$key])) {
  457. continue;
  458. }
  459. $seen[$key] = true;
  460. $unique[] = $row;
  461. }
  462. return $unique;
  463. }
  464. /**
  465. * Normalise a free-form date string into a MySQL datetime in WP local time.
  466. * Returns '' (and logs) on parse failure so the UI does not silently
  467. * carry unparseable raw values through the round-trip.
  468. *
  469. * Avoids strtotime() entirely. WordPress sets the PHP timezone to UTC
  470. * very early in its boot (date_default_timezone_set('UTC')), so
  471. * strtotime() interprets timezone-less inputs as UTC. Combining that
  472. * with wp_date() — which expects a UTC timestamp and formats in WP
  473. * timezone — shifted "2026-05-12 14:30" forward by the WP offset.
  474. *
  475. * Two paths:
  476. * - Fast: ISO-like "YYYY-MM-DD[T ]HH:MM[:SS]" with no explicit timezone
  477. * → pure string reformat, no shift. This is the common shape of
  478. * what the print shop sends.
  479. * - Fallback: DateTimeImmutable($raw, wp_timezone()) — interprets
  480. * timezone-less input as WP local; explicit-timezone input is honoured
  481. * by the constructor (second arg ignored in that case) and then
  482. * converted back to WP timezone via setTimezone() so the output is
  483. * always WP-local.
  484. */
  485. private static function normalise_csv_date($raw) {
  486. $raw = sanitize_text_field((string) $raw);
  487. if ($raw === '') {
  488. return '';
  489. }
  490. // Fast path — common ISO-ish shape with no explicit timezone.
  491. // checkdate() + h/m/s bounds reject structurally-valid but
  492. // semantically-out-of-range inputs (e.g., "2026-13-45T25:99")
  493. // so they fall through to the DateTimeImmutable path, which
  494. // will throw and route us to the catch.
  495. if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
  496. $year = (int) $m[1];
  497. $month = (int) $m[2];
  498. $day = (int) $m[3];
  499. $hour = (int) $m[4];
  500. $minute = (int) $m[5];
  501. $second = isset($m[6]) ? (int) $m[6] : 0;
  502. if (checkdate($month, $day, $year) && $hour <= 23 && $minute <= 59 && $second <= 59) {
  503. return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . sprintf('%02d', $second);
  504. }
  505. }
  506. // Fallback — let DateTimeImmutable handle locale dates and explicit
  507. // timezones. Strict: throws ValueError on garbage (e.g. "foo").
  508. try {
  509. $dt = new DateTimeImmutable($raw, wp_timezone());
  510. $dt = $dt->setTimezone(wp_timezone());
  511. return $dt->format('Y-m-d H:i:s');
  512. } catch (Exception $e) {
  513. UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
  514. return '';
  515. }
  516. }
  517. }