Parcourir la source

Phase A — safety & robustness pass — v1.6.2

Implements review-00-plan-00.md Phase A. Items addressed:

* H1 (partial) — get_unassigned_unused_media_ids() now also excludes
  attachments referenced from termmeta 'thumbnail_id' (covers WC product-
  category images, the immediate cause of silent data loss) and the
  customizer's custom_logo / site_icon / header image. Content-embedded,
  ACF, custom-termmeta, and downloadable-product images are still NOT
  detected — the preview-list UI that actually closes the gap is deferred
  to review-00-plan-02-preview.md.

* M6 — tab-remove-unassigned.php warning rewritten to enumerate exactly
  what is and is not detected, and to label the button as "delete media
  that nothing I can detect is using" rather than promising safety.

* H4 — parse_csv_for_batch() now guards array_combine: short rows are
  padded with empty cells, over-long rows are skipped with a clear
  "column count mismatch" reason and folded into the failed-CSV via a
  sibling transient. Previously a ragged row threw ValueError (extends
  Error, not Exception), which the surrounding catches missed → bare 500.

* L5 — three product import / export handlers' catches widened from
  Exception to \Throwable; their error_log is also now always-on
  (matches the documented "always enabled" import-logging policy).

* M3 — product importer now strips a UTF-8 BOM on parse, matching what
  the category importer has always done. Product exporter now writes a
  BOM (Policy A: write BOM in exports, strip BOM on import), so an
  Excel round-trip is lossless. Previously the BOM corrupted the first
  header into "\xEF\xBB\xBFID", every row was treated as new, and most
  then got skipped by the SKU-uniqueness rule.

* M4 (guard) — parse_csv_for_batch() now checks set_transient's return
  value and surfaces a clear "Import too large to stage (N rows)..."
  message when the serialized payload overflows the wp_options row.
  Previously the next batch reported the misleading "Import data not
  found or expired". The structural fix (disk-staging) ships in v1.7.0.

* M5 — update_category() no longer silently re-parents updated children
  to root when parent-name is blank. On `U` rows: blank parent-name now
  means "leave the existing parent untouched"; the literal sentinel
  __ROOT__ means "explicitly move to root". `A` (add) rows keep blank =
  root, since a new category has no existing parent to preserve.

* L3 — plugin header `WC requires at least` bumped from 9.0 to 9.8 to
  match check_required_plugins() and the documented requirement.

Translations: new user-facing strings (M6 warning bullets, M3/M4
messages) need .po updates and an .mo regeneration. Bundled with the
later-phase .po pass in this same plan.

Phase A only narrows the H1 data-loss risk; do NOT treat
"Remove Not Assigned Media" as safe to run on production until the
preview UI ships.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dalibor Votruba il y a 1 mois
Parent
commit
184e7b8ec3

+ 1 - 1
studiou-wc-product-cat-manage/CLAUDE.md

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 **QDR - Studiou WC Export/Import Product Categories** is a WordPress plugin that extends WooCommerce to provide batch export/import functionality for both product categories and products, along with category manipulation tools.
 
-**Current Version:** 1.6.1
+**Current Version:** 1.6.2
 
 ## Requirements
 

+ 1 - 1
studiou-wc-product-cat-manage/assets/js/admin.js

@@ -19,7 +19,7 @@
     // Initialize admin scripts
     $(document).ready(function() {
         // Debug: Log that script is loaded
-        console.log('STUDIOU WC: Admin script loaded v1.6.1');
+        console.log('STUDIOU WC: Admin script loaded v1.6.2');
 
         if (typeof studiouWcpcm === 'undefined') {
             console.error('STUDIOU WC: studiouWcpcm object is NOT defined - AJAX will not work');

+ 35 - 17
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-cat-manage-db.php

@@ -374,31 +374,49 @@ class Studiou_WC_Product_Cat_Manage_DB {
                 )
             );
         }
-        
-        // Find parent category ID if parent name is provided
+
+        // M5 (v1.6.2) — tri-state parent handling on update:
+        //   * blank `parent-name`      → leave the existing parent untouched
+        //                                (previously silently re-parented to root)
+        //   * literal `__ROOT__`       → explicitly move to root
+        //   * any other non-blank name → resolve and set as before
+        // `parent-name` is still a required header; only the value semantics
+        // change on the update path. `A` (add) still treats blank as root,
+        // since a brand-new category has nothing to "leave alone."
+        $raw_parent = isset($category_data['parent-name']) ? trim($category_data['parent-name']) : '';
+        $parent_provided = ($raw_parent !== '');
         $parent_id = 0;
-        if (!empty($category_data['parent-name'])) {
-            $parent = $this->find_category_by_name($category_data['parent-name']);
-            if ($parent) {
-                $parent_id = $parent->term_id;
+
+        if ($parent_provided) {
+            if ($raw_parent === '__ROOT__') {
+                $parent_id = 0;
             } else {
-                return array(
-                    'status' => 'error',
-                    'message' => sprintf(
-                        __("Parent category '%s' not found.", 'studiou-wc-product-cat-manage'),
-                        $category_data['parent-name']
-                    )
-                );
+                $parent = $this->find_category_by_name($raw_parent);
+                if ($parent) {
+                    $parent_id = $parent->term_id;
+                } else {
+                    return array(
+                        'status' => 'error',
+                        'message' => sprintf(
+                            __("Parent category '%s' not found.", 'studiou-wc-product-cat-manage'),
+                            $raw_parent
+                        )
+                    );
+                }
             }
         }
-        
-        // Update category
+
+        // Update category — only include `parent` in the args when the CSV
+        // actually asked us to manage it. Otherwise wp_update_term would
+        // silently re-parent every updated child to root on any partial CSV.
         $args = array(
             'description' => $category_data['description'],
-            'parent' => $parent_id,
             'slug' => $category_data['url-name'],
         );
-        
+        if ($parent_provided) {
+            $args['parent'] = $parent_id;
+        }
+
         $result = wp_update_term($existing->term_id, 'product_cat', $args);
         
         if (is_wp_error($result)) {

+ 37 - 0
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-cat-manage-manipulator.php

@@ -1507,6 +1507,43 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
             }
         }
 
+        // v1.6.2 — narrow blast radius. The base query above does not see
+        // attachments referenced from term meta (WooCommerce product-category
+        // images are stored as termmeta 'thumbnail_id'), nor customizer images
+        // (custom logo, site icon, header image). Without these exclusions,
+        // running "Remove Not Assigned Media" silently destroys category
+        // artwork. NOTE: content-embedded, ACF/meta-field, and downloadable-
+        // product images are still NOT detected — see the warning in
+        // tab-remove-unassigned.php. The preview-list inversion (plan-02) is
+        // what makes the operation actually safe.
+        $extra_excluded = array();
+
+        $termmeta_ids = $wpdb->get_col(
+            "SELECT DISTINCT meta_value FROM {$wpdb->termmeta}
+             WHERE meta_key = 'thumbnail_id'
+               AND meta_value > 0"
+        );
+        foreach ($termmeta_ids as $tm_id) {
+            $tm_id = (int) $tm_id;
+            if ($tm_id) { $extra_excluded[$tm_id] = true; }
+        }
+
+        foreach (array(
+            (int) get_theme_mod('custom_logo'),
+            (int) get_option('site_icon'),
+        ) as $cust_id) {
+            if ($cust_id) { $extra_excluded[$cust_id] = true; }
+        }
+
+        $hdr = get_theme_mod('header_image_data');
+        if (is_object($hdr) && !empty($hdr->attachment_id)) {
+            $extra_excluded[(int) $hdr->attachment_id] = true;
+        }
+
+        if (!empty($extra_excluded)) {
+            $media_ids = array_diff($media_ids, array_keys($extra_excluded));
+        }
+
         error_log('STUDIOU WC MEDIA: Found ' . count($media_ids) . ' unassigned unused media files');
 
         return array_values($media_ids);

+ 4 - 2
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-export.php

@@ -264,8 +264,10 @@ class Studiou_WC_Product_Manage_Product_Export {
         $filename = 'product-export-' . date('Y-m-d-H-i-s') . '.csv';
         $file_path = $export_dir . '/' . $filename;
 
-        // Write CSV content to file
-        $result = file_put_contents($file_path, $csv_content);
+        // Write CSV content to file with UTF-8 BOM so Excel double-click
+        // opens it with the correct encoding (Policy A, v1.6.2 — match the
+        // category exporter, which has always written the BOM).
+        $result = file_put_contents($file_path, "\xEF\xBB\xBF" . $csv_content);
 
         if ($result === false) {
             return array(

+ 75 - 3
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-import.php

@@ -55,6 +55,14 @@ class Studiou_WC_Product_Manage_Product_Import {
             return array('error' => __('Could not open file', 'studiou-wc-product-cat-manage'));
         }
 
+        // M3 — strip UTF-8 BOM if present so the first header cell isn't
+        // "\xEF\xBB\xBFID", which would make every row miss the ID key and
+        // be treated as new (and then likely skipped by SKU-uniqueness).
+        $bom = fread($handle, 3);
+        if ($bom !== "\xEF\xBB\xBF") {
+            rewind($handle);
+        }
+
         // Read header row
         $header = fgetcsv($handle, 0, ',', '"');
         if (!$header) {
@@ -62,8 +70,11 @@ class Studiou_WC_Product_Manage_Product_Import {
             return array('error' => __('Invalid CSV format', 'studiou-wc-product-cat-manage'));
         }
 
+        $header_count = count($header);
+
         // Read all data rows
         $rows = array();
+        $skipped_rows = array();
         $row_number = 1;
         while (($row = fgetcsv($handle, 0, ',', '"')) !== false) {
             $row_number++;
@@ -73,6 +84,32 @@ class Studiou_WC_Product_Manage_Product_Import {
                 continue;
             }
 
+            // H4 — guard array_combine: on PHP 8+ it throws ValueError when
+            // the row width doesn't match the header. ValueError extends
+            // Error (not Exception), so the surrounding catches miss it and
+            // the whole import dies with a bare 500. Pad short rows (likely
+            // trailing empty cells from Excel); skip and report over-long
+            // rows (an unescaped comma we can't safely recover).
+            $row_count = count($row);
+            if ($row_count < $header_count) {
+                $row = array_pad($row, $header_count, '');
+            } elseif ($row_count > $header_count) {
+                $padded_for_failed = array_pad($row, $header_count, '');
+                $padded_for_failed = array_slice($padded_for_failed, 0, $header_count);
+                $skipped_rows[] = array(
+                    'row_number' => $row_number,
+                    'data'       => array_combine($header, $padded_for_failed),
+                    'reason'     => sprintf(
+                        /* translators: 1: row number, 2: actual column count, 3: expected column count */
+                        __('Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped.', 'studiou-wc-product-cat-manage'),
+                        $row_number,
+                        $row_count,
+                        $header_count
+                    ),
+                );
+                continue;
+            }
+
             // Combine header with row data
             $data = array_combine($header, $row);
             $rows[] = array(
@@ -83,13 +120,32 @@ class Studiou_WC_Product_Manage_Product_Import {
 
         fclose($handle);
 
-        // Store data in transient (expires in 1 hour)
+        // M4 (guard) — set_transient returns false when the serialized
+        // payload exceeds max_allowed_packet or storage fails for any
+        // other reason. The downstream batch handler then reports
+        // "Import data not found or expired", which is misleading — the
+        // upload was fine, the store just couldn't take it. Surface a
+        // clear, actionable message instead. The disk-staging rework that
+        // actually removes this ceiling lands in Phase D.
         $transient_key = 'studiou_wcpcm_import_' . uniqid();
-        set_transient($transient_key, $rows, 3600);
+        if (!set_transient($transient_key, $rows, 3600)) {
+            return array('error' => sprintf(
+                /* translators: %d: number of rows */
+                __('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'),
+                count($rows)
+            ));
+        }
+        // Skipped (column-count-mismatch) rows live in a sibling transient and
+        // get folded into the final batch's failed_items + messages by
+        // process_batch(), so they appear in the failed-CSV download without
+        // changing the JS contract.
+        if (!empty($skipped_rows)) {
+            set_transient($transient_key . '_skipped', $skipped_rows, 3600);
+        }
 
         return array(
             'transient_key' => $transient_key,
-            'total' => count($rows)
+            'total'         => count($rows),
         );
     }
 
@@ -121,6 +177,22 @@ class Studiou_WC_Product_Manage_Product_Import {
         // Get batch of rows to process
         $batch = array_slice($rows, $offset, $batch_size);
 
+        // On the final batch, fold in any ragged-row skips from the parse
+        // step (set by parse_csv_for_batch when row width != header width)
+        // so they appear in the failed-CSV download and the messages list.
+        $is_final_batch = ($offset + count($batch)) >= count($rows);
+        if ($is_final_batch) {
+            $parse_skipped = get_transient($transient_key . '_skipped');
+            if (is_array($parse_skipped) && !empty($parse_skipped)) {
+                foreach ($parse_skipped as $skip) {
+                    $result['skipped']++;
+                    $result['messages'][] = $skip['reason'];
+                    $result['failed_items'][] = $skip['data'];
+                }
+                delete_transient($transient_key . '_skipped');
+            }
+        }
+
         foreach ($batch as $item) {
             $row_number = $item['row_number'];
             $data = $item['data'];

+ 9 - 1
studiou-wc-product-cat-manage/readme.md

@@ -1,6 +1,6 @@
 # QDR - Studiou WC Export/Import Product Categories
 
-**Version: 1.6.1**
+**Version: 1.6.2**
 
 A WordPress plugin that extends WooCommerce to provide batch export and import functionality for both product categories and products, along with category manipulation tools.
 
@@ -352,6 +352,14 @@ For support, please visit [https://www.quadarax.com/plugins/studiou-wc-product-c
 
 ## Changelog
 
+### Version 1.6.2
+- **Safety: "Remove Not Assigned Media" no longer destroys WooCommerce product-category images.** The "unused" attachment query now also excludes any attachment referenced from termmeta `thumbnail_id` (the WC category-image convention) and the customizer (custom logo, site icon, header image). NOTE: this *narrows* the blast radius; it does not make the operation safe in general. Images embedded only in post / page / product content HTML, in ACF/meta fields, in custom termmeta keys other than `thumbnail_id`, and in WooCommerce downloadable-product files are still NOT detected and will still be deleted if you click the button. The tab warning has been rewritten to say so explicitly. The preview-list UI that actually closes the gap is a separate change tracked under review-00-plan-02.
+- **Product import: ragged CSV rows no longer crash the entire import.** On PHP 8+ `array_combine` throws `ValueError` (which extends `Error`, not `Exception`) when a row's column count doesn't match the header. The surrounding `catch (Exception)` missed it and the whole import died as a bare 500. Now: short rows are padded with empty cells (Excel trailing-comma artifact, recoverable); over-long rows are skipped with a clear "column count mismatch" reason and surface in the failed-CSV download. All product import / export handlers' catches widened from `Exception` to `\Throwable`.
+- **Product import: strip UTF-8 BOM on parse.** Excel commonly saves CSV with a leading BOM. Without this fix the first header became `"\xEF\xBB\xBFID"`, every row missed the `ID` key, every product was treated as new, and most then got skipped on SKU-uniqueness. Matches what the category importer has always done. For symmetry, the product export now also writes a BOM (Policy A: write BOM in exports, strip BOM on import) so an Excel round-trip is lossless.
+- **Product import: large CSVs that overflow the WordPress transient store now report a clear, actionable error** instead of the misleading downstream "Import data not found or expired". The structural fix that actually removes the size ceiling (disk-streaming staging) lands in 1.7.0.
+- **Category import update operation no longer silently re-parents children to root.** Previously, any `U` row with a blank `parent-name` would set `parent = 0`, which silently moved every child being updated up to root. Now: blank `parent-name` on a `U` row leaves the existing parent untouched. To explicitly move a child to root, set `parent-name` to the literal sentinel `__ROOT__`. `A` (add) rows keep blank = root, since a new category has no existing parent to preserve.
+- **Plugin header `WC requires at least` corrected from `9.0` to `9.8`** to match the runtime check and the documented requirement.
+
 ### Version 1.6.1
 - **Product import: deduplicate media by source URL** - a single `Obrázky` URL is now downloaded **once** per import (and once per re-import), and every row that references it - the variable parent and all its variations - reuses the same attachment ID. Eliminates the `image-1.jpg`, `image-2.jpg`, ... clutter that previously appeared in `wp-content/uploads/`, and stops each variation from pointing at its own private copy of what is logically the parent's image.
 - New attachment postmeta `_studiou_wcpcm_source_url` records the trimmed source URL for every image this importer brings in, so dedup survives across batches and across re-imports months later. A per-request in-memory cache absorbs repeats within a single AJAX batch.

+ 11 - 15
studiou-wc-product-cat-manage/studiou-wc-product-cat-manage.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Export/Import Product Categories
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-product-cat-manage
  * Description: Allows batch import and export WooCommerce product categories and products
- * Version: 1.6.1
+ * Version: 1.6.2
  * Requires at least: 6.8.1
  * Requires PHP: 8.2
  * Author: Dalibor Votruba
@@ -12,7 +12,7 @@
  * License URI: https://www.gnu.org/licenses/gpl-2.0.html
  * Text Domain: studiou-wc-product-cat-manage
  * Domain Path: /languages
- * WC requires at least: 9.0
+ * WC requires at least: 9.8
  * WC tested up to: 9.8
  * Woo: 12345:342928dfsfhsf8429842374wdf4234sfd
  */
@@ -23,7 +23,7 @@ if (!defined('WPINC')) {
 }
 
 // Define plugin constants
-if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.1');
+if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.2');
 if (!defined('STUDIOU_WCPCM_PLUGIN_DIR')) define('STUDIOU_WCPCM_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_URL')) define('STUDIOU_WCPCM_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_BASENAME')) define('STUDIOU_WCPCM_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -426,10 +426,10 @@ class Studiou_WC_Product_Cat_Manage {
                 'total' => $result['total']
             ));
 
-        } catch (Exception $e) {
-            if (defined('WP_DEBUG') && WP_DEBUG) {
-                error_log('STUDIOU WC Product Import Parse Error: ' . $e->getMessage());
-            }
+        } catch (\Throwable $e) {
+            // \Throwable (not Exception) — array_combine, type errors, and
+            // other Error subclasses would otherwise escape as bare 500s.
+            error_log('STUDIOU WC Product Import Parse Error: ' . $e->getMessage());
             wp_send_json_error(array('message' => $e->getMessage()));
         }
     }
@@ -489,10 +489,8 @@ class Studiou_WC_Product_Cat_Manage {
                 'failed_csv' => $failed_csv
             ));
 
-        } catch (Exception $e) {
-            if (defined('WP_DEBUG') && WP_DEBUG) {
-                error_log('STUDIOU WC Product Import Batch Error: ' . $e->getMessage());
-            }
+        } catch (\Throwable $e) {
+            error_log('STUDIOU WC Product Import Batch Error: ' . $e->getMessage());
             wp_send_json_error(array('message' => $e->getMessage()));
         }
     }
@@ -539,10 +537,8 @@ class Studiou_WC_Product_Cat_Manage {
                 wp_send_json_error(array('message' => $result['message']));
             }
 
-        } catch (Exception $e) {
-            if (defined('WP_DEBUG') && WP_DEBUG) {
-                error_log('STUDIOU WC Product Export Error: ' . $e->getMessage());
-            }
+        } catch (\Throwable $e) {
+            error_log('STUDIOU WC Product Export Error: ' . $e->getMessage());
             wp_send_json_error(array('message' => $e->getMessage()));
         }
     }

+ 9 - 2
studiou-wc-product-cat-manage/views/manipulations/tab-remove-unassigned.php

@@ -18,11 +18,18 @@ $unassigned_media_count = $manipulator->get_unassigned_unused_media_count();
 <div class="studiou-wcpcm-card">
     <h2><?php echo esc_html__('Remove Not Assigned Media', 'studiou-wc-product-cat-manage'); ?></h2>
 
-    <p><?php echo esc_html__('This operation permanently deletes all media files that are not assigned to any media category and are not used as featured image, product gallery image, or attached to any post.', 'studiou-wc-product-cat-manage'); ?></p>
+    <p><?php echo esc_html__('This operation permanently deletes media files that are not assigned to any media category and are not detected as in use. Detection covers: post/page/product featured images, WooCommerce product gallery images, attached media (post_parent), media-category membership, and — since v1.6.2 — WooCommerce product-category images (termmeta), the customizer custom logo, site icon, and header image.', 'studiou-wc-product-cat-manage'); ?></p>
 
     <form id="studiou-wcpcm-remove-unassigned-media-form" method="post" onsubmit="return false;">
         <div class="studiou-wcpcm-warning-message">
-            <p><strong><?php echo esc_html__('WARNING: This operation will permanently delete all unused uncategorized media files from the server. This action cannot be undone!', 'studiou-wc-product-cat-manage'); ?></strong></p>
+            <p><strong><?php echo esc_html__('WARNING: This action cannot be undone.', 'studiou-wc-product-cat-manage'); ?></strong></p>
+            <p><?php echo esc_html__('Detection is heuristic. The following references are NOT detected and the media WILL be deleted if it is only used this way:', 'studiou-wc-product-cat-manage'); ?></p>
+            <ul>
+                <li><?php echo esc_html__('Images embedded only in post / page / product content HTML (including page builders that store HTML).', 'studiou-wc-product-cat-manage'); ?></li>
+                <li><?php echo esc_html__('Images referenced from ACF, custom postmeta, custom termmeta keys other than "thumbnail_id", or theme options.', 'studiou-wc-product-cat-manage'); ?></li>
+                <li><?php echo esc_html__('Files attached to WooCommerce downloadable products.', 'studiou-wc-product-cat-manage'); ?></li>
+            </ul>
+            <p><strong><?php echo esc_html__('Treat this button as: "delete media that nothing I can detect is using." If unsure, back up the uploads folder first.', 'studiou-wc-product-cat-manage'); ?></strong></p>
         </div>
 
         <div class="submit-buttons">