Explorar o código

Phase E — cleanup pass — v1.7.1

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

* M7 — Product importer success-path logs are now gated behind
  WP_DEBUG (Processing categories for X, Categories assigned, etc.).
  Failure-path logs (zero categories matched, term insert failed,
  category creation failed) stay always-on. Matches CLAUDE.md's
  "Only logs failures" policy.

* M8 — find_or_create_category_by_name (the new name; old
  find_category_by_name is now a thin alias) caches the full
  product_cat term list in a private static after the first miss-path
  call, so subsequent rows reuse it instead of re-running get_terms.
  Newly created terms append into the cache so they're visible to
  later rows in the same request.

* M9 — get_categories_with_counts_uncached() replaces the per-category
  count loop with a single GROUP BY query. The query joins wp_posts
  with the SAME filters get_category_product_count applies
  (post_type='product', post_status IN publish/private/draft), so
  trashed and draft products no longer inflate displayed counts. Zero-
  count categories survive the LEFT JOIN.

* L2 — Product export row count now accumulates during generation
  (export_products gains a by-ref &$row_count out param), so
  multi-line description fields no longer inflate the displayed count
  via substr_count("\n").

* L4 — Runtime-generated versioned asset copies (admin-X.Y.Z.js etc.)
  moved from the plugin directory into
  uploads/studiou-wcpcm-cache/<rel>/. The plugin dir stays untouched;
  security scanners stop tripping; managed hosts with read-only plugin
  dirs work. index.php silence files written into the cache subdirs.

* L7 — UPVP apply_variant_price now detects variations whose
  sale_price would be greater than the new regular_price after the
  update, logs a "STUDIOU WC UPVP:" warning per affected variation,
  and returns them in a sale_warnings response field for the dry-run /
  results UI to show. Does NOT auto-clamp the sale_price (operator-
  owned).

* L1 — Five new i18n keys added (selectBothCategories, moveNoProducts,
  importInitializing, importComplete, deletionSummary, importSummary).
  The Phase-D batched flows already use studiouWcpcm.i18n.* with
  English fallbacks, so they pick up these keys automatically once
  the .po files are updated.

* L6, L8 — doc-only notes added under a new "Security notes" section
  in CLAUDE.md: server-side image fetching is SSRF surface gated by
  manage_woocommerce; failed-CSV retry is creation-only and won't
  finish rows that partially succeeded but need update.

Translations: new strings introduced in this and prior phases (A's
warning copy, A's M4 message, C's download / errors, D's progress
strings, E's i18n keys) need .po updates + .mo regeneration. The user
handles .mo regeneration; .po edits remain a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dalibor Votruba hai 1 mes
pai
achega
e9f250c14d

+ 6 - 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.7.0
+**Current Version:** 1.7.1
 
 ## Requirements
 
@@ -261,6 +261,11 @@ All pages are added under **Products** menu:
   - Attribute taxonomy creation via `wc_create_attribute()`
   - 7 comprehensive import rules enforced
 
+## Security notes
+
+- **Image URLs in product imports are fetched server-side without a host allow-list** (`upload_image_from_url()` → `download_url($url, 30)`). An admin who can upload a CSV can cause the server to issue HTTP requests to any URL they put in the `Obrázky` column, including internal hostnames and cloud metadata endpoints. The risk is gated by the `manage_woocommerce` capability — on low-trust shops, restrict who has it. (L6 in review-00.)
+- **Failed-products retry is creation-only.** The CSV produced by "Save Failed Products" carries `ID = 0` for every row. On re-import, the ID-based create-vs-update rule will try to create, and the SKU-uniqueness rule will then skip anything that has already been created. So the retry workflow can only finish the rows that never landed; rows that *partially* succeeded and need an update aren't fixable through the failed-CSV path. Edit the source CSV with a non-zero ID for those rows instead. (L8 in review-00.)
+
 ## Testing Notes
 
 ### Category Operations

+ 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.7.0');
+        console.log('STUDIOU WC: Admin script loaded v1.7.1');
 
         if (typeof studiouWcpcm === 'undefined') {
             console.error('STUDIOU WC: studiouWcpcm object is NOT defined - AJAX will not work');

+ 64 - 16
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-cat-manage-manipulator.php

@@ -754,25 +754,41 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
     public function get_categories_with_counts_uncached() {
         global $wpdb;
 
-        $terms = $wpdb->get_results($wpdb->prepare("
-            SELECT t.term_id, t.name
+        // v1.7.1 — M9: replace the per-category get_category_product_count()
+        // loop (N+1) with a single aggregate query. The naive
+        // term_relationships count would over-count trashed/pending/
+        // auto-draft products because trashing a product does NOT remove
+        // its term relationship — a category emptied via "Delete products
+        // in category" could still display a non-zero count. So we JOIN
+        // wp_posts and apply the SAME filters get_category_product_count()
+        // uses (post_type='product', post_status IN publish/private/draft),
+        // with the filters in the ON clause so zero-count categories
+        // survive the LEFT JOIN.
+        $sql = $wpdb->prepare("
+            SELECT t.term_id, t.name, COUNT(DISTINCT p.ID) AS cnt
             FROM {$wpdb->terms} t
             INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
+            LEFT JOIN {$wpdb->term_relationships} tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
+            LEFT JOIN {$wpdb->posts} p
+                ON p.ID = tr.object_id
+               AND p.post_type = %s
+               AND p.post_status IN ('publish','private','draft')
             WHERE tt.taxonomy = %s
+            GROUP BY t.term_id, t.name
             ORDER BY t.name ASC
-        ", 'product_cat'));
-
-        $categories_with_counts = array();
+        ", 'product', 'product_cat');
 
-        if (!empty($terms)) {
-            foreach ($terms as $term) {
-                $product_count = $this->get_category_product_count($term->term_id);
+        $rows = $wpdb->get_results($sql);
 
+        $categories_with_counts = array();
+        if (!empty($rows)) {
+            foreach ($rows as $row) {
+                $count = (int) $row->cnt;
                 $categories_with_counts[] = array(
-                    'id' => (int) $term->term_id,
-                    'name' => $term->name,
-                    'count' => $product_count,
-                    'display_name' => sprintf('%s (%d products)', $term->name, $product_count)
+                    'id' => (int) $row->term_id,
+                    'name' => $row->name,
+                    'count' => $count,
+                    'display_name' => sprintf('%s (%d products)', $row->name, $count),
                 );
             }
         }
@@ -2123,11 +2139,17 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
         }
 
         $formatted = wc_format_decimal($new_price);
+        $new_price_f = (float) $formatted;
 
         $updated     = 0;
         $failed      = 0;
         $failed_ids  = array();
         $parent_ids  = array();
+        // v1.7.1 — L7: collect variations whose existing sale_price is higher
+        // than the new regular_price. We don't auto-clamp (sale data is
+        // operator-owned) but we surface the inconsistency so the operator
+        // can fix it. Returned in the response so the UI can show it.
+        $sale_warnings = array();
 
         foreach ($variation_ids as $id) {
             try {
@@ -2138,6 +2160,23 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
                     error_log('STUDIOU WC UPVP: Variation ' . $id . ' not found or not a variation');
                     continue;
                 }
+
+                $sale_price_raw = $variation->get_sale_price();
+                if ($sale_price_raw !== '' && $sale_price_raw !== null) {
+                    $sale_f = (float) $sale_price_raw;
+                    if ($sale_f > $new_price_f) {
+                        error_log(sprintf(
+                            'STUDIOU WC UPVP: variation %d has sale_price %s greater than new regular_price %s — sale > regular after update; review needed',
+                            $id, $sale_price_raw, $formatted
+                        ));
+                        $sale_warnings[] = array(
+                            'id'           => $id,
+                            'sale_price'   => $sale_price_raw,
+                            'new_regular'  => $formatted,
+                        );
+                    }
+                }
+
                 $variation->set_regular_price($formatted);
                 $variation->save();
                 $updated++;
@@ -2177,11 +2216,20 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
             );
         }
 
+        if (!empty($sale_warnings)) {
+            $message .= ' ' . sprintf(
+                /* translators: %d: number of variations whose existing sale_price is now greater than the new regular_price */
+                __('Warning: %d variations have a sale price greater than the new regular price (sale > regular). Review the variations and adjust the sale price.', 'studiou-wc-product-cat-manage'),
+                count($sale_warnings)
+            );
+        }
+
         return array(
-            'updated'    => $updated,
-            'failed'     => $failed,
-            'failed_ids' => $failed_ids,
-            'message'    => $message,
+            'updated'       => $updated,
+            'failed'        => $failed,
+            'failed_ids'    => $failed_ids,
+            'sale_warnings' => $sale_warnings,
+            'message'       => $message,
         );
     }
 

+ 19 - 3
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-export.php

@@ -131,7 +131,7 @@ class Studiou_WC_Product_Manage_Product_Export {
      * @param array $category_ids Array of category IDs
      * @return string CSV content
      */
-    public function export_products($category_ids) {
+    public function export_products($category_ids, &$row_count = null) {
         $product_ids = $this->db->get_products_by_categories($category_ids);
 
         if (empty($product_ids)) {
@@ -150,6 +150,11 @@ class Studiou_WC_Product_Manage_Product_Export {
         $csv = '';
         $csv .= '"' . implode('","', $header) . '"' . "\n";
 
+        // v1.7.1 — L2: accumulate the count during generation. The previous
+        // substr_count("\n") - 1 over-counted whenever any field (typically
+        // the product description) contained a newline.
+        $count = 0;
+
         // Process each product
         foreach ($product_ids as $product_id) {
             $product = wc_get_product($product_id);
@@ -163,6 +168,11 @@ class Studiou_WC_Product_Manage_Product_Export {
             }
 
             $csv .= $this->format_product_row($product);
+            $count++;
+        }
+
+        if (func_num_args() >= 2) {
+            $row_count = $count;
         }
 
         return $csv;
@@ -338,7 +348,12 @@ class Studiou_WC_Product_Manage_Product_Export {
      * @return array Result with download URL or error message
      */
     public function generate_export_file($category_ids) {
-        $csv_content = $this->export_products($category_ids);
+        // v1.7.1 — L2: ask export_products() to return both the CSV string
+        // AND the row count it accumulated during generation. substr_count
+        // on "\n" over-counted whenever any product description contained
+        // a newline.
+        $row_count = 0;
+        $csv_content = $this->export_products($category_ids, $row_count);
 
         if (empty($csv_content)) {
             return array(
@@ -385,8 +400,9 @@ class Studiou_WC_Product_Manage_Product_Export {
         return array(
             'success' => true,
             'message' => sprintf(
+                /* translators: %d: number of exported products */
                 __('Export successful! %d products exported.', 'studiou-wc-product-cat-manage'),
-                substr_count($csv_content, "\n") - 1 // Subtract header row (note: L2 fixes the multi-line case in v1.7.1)
+                $row_count
             ),
             'download_url' => $download_url
         );

+ 91 - 47
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-import.php

@@ -447,13 +447,19 @@ class Studiou_WC_Product_Manage_Product_Import {
             $product->set_short_description(isset($data['Krátký popis']) ? $data['Krátký popis'] : '');
             $product->set_description(isset($data['Popis']) ? $data['Popis'] : '');
 
-            // Set categories
+            // v1.7.1 — M7: gate success-path logging behind WP_DEBUG so a
+            // normal production import doesn't write dozens of lines per
+            // row to error.log. Failure-path logs stay always-on (matches
+            // the documented "Only logs failures" policy).
+            $debug = defined('WP_DEBUG') && WP_DEBUG;
             if (isset($data['Kategorie']) && !empty($data['Kategorie'])) {
                 $categories = array_map('trim', explode('|', $data['Kategorie']));
                 $category_ids = array();
 
-                error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name());
-                error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']);
+                if ($debug) {
+                    error_log('STUDIOU WC IMPORT: Processing categories for product: ' . $product->get_name());
+                    error_log('STUDIOU WC IMPORT: Categories string from CSV: ' . $data['Kategorie']);
+                }
 
                 foreach ($categories as $category_name) {
                     if (empty($category_name)) {
@@ -461,22 +467,29 @@ class Studiou_WC_Product_Manage_Product_Import {
                     }
 
                     $category_id = $this->find_category_by_name($category_name);
-                    error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND'));
+                    if ($debug) {
+                        error_log('STUDIOU WC IMPORT: Looking for category "' . $category_name . '" - Result: ' . ($category_id ? 'FOUND ID=' . $category_id : 'NOT FOUND'));
+                    }
 
                     if ($category_id) {
                         $category_ids[] = $category_id;
                     }
                 }
 
-                error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids));
+                if ($debug) {
+                    error_log('STUDIOU WC IMPORT: Total category IDs found: ' . count($category_ids) . ' - IDs: ' . implode(',', $category_ids));
+                }
 
                 if (!empty($category_ids)) {
                     $product->set_category_ids($category_ids);
-                    error_log('STUDIOU WC IMPORT: Categories assigned to product via set_category_ids()');
+                    if ($debug) {
+                        error_log('STUDIOU WC IMPORT: Categories assigned to product via set_category_ids()');
+                    }
                 } else {
+                    // Always-on: a row that names categories but resolves zero is a real failure worth logging.
                     error_log('STUDIOU WC IMPORT: WARNING - No valid category IDs found for: ' . $data['Kategorie']);
                 }
-            } else {
+            } else if ($debug) {
                 error_log('STUDIOU WC IMPORT: No categories in CSV for product: ' . $product->get_name());
             }
 
@@ -882,82 +895,113 @@ class Studiou_WC_Product_Manage_Product_Import {
     }
 
     /**
-     * Find category by name (simple lookup, categories have unique names)
+     * v1.7.1 — M8: per-request cache of the entire product_cat term list.
+     * Populated lazily on first miss-path call to get_terms(); reused for
+     * every subsequent row in the same AJAX batch. Resets naturally when
+     * the request ends, so changes mid-import (e.g. categories we just
+     * created) are picked up — wp_insert_term + cache append is handled
+     * inline below.
+     *
+     * @var array<int,object>|null  Terms keyed by term_id, or null if not loaded.
+     */
+    private static $all_product_cats_cache = null;
+
+    /**
+     * v1.7.1 — M8: thin alias of find_or_create_category_by_name() so
+     * existing call sites keep compiling. The rename clarifies that this
+     * method also CREATES the term as a side effect, which used to be
+     * surprising for a function called "find".
      *
-     * @param string $category_name Category name
-     * @return int|null Category ID or null if not found
+     * @param string $category_name
+     * @return int|null
      */
     private function find_category_by_name($category_name) {
-        // Trim and normalize the category name
-        $category_name = trim($category_name);
+        return $this->find_or_create_category_by_name($category_name);
+    }
+
+    /**
+     * Find a category by name; create it if missing.
+     *
+     * @param string $category_name
+     * @return int|null Term ID or null if creation failed.
+     */
+    private function find_or_create_category_by_name($category_name) {
+        $debug = defined('WP_DEBUG') && WP_DEBUG;
 
+        $category_name = trim($category_name);
         if (empty($category_name)) {
             return null;
         }
 
-        // Simple name lookup only (no hierarchy detection)
-        // Categories have unique names throughout the system
-
-        error_log('STUDIOU WC IMPORT: find_category_by_name() called for: "' . $category_name . '"');
+        if ($debug) {
+            error_log('STUDIOU WC IMPORT: find_or_create_category_by_name() called for: "' . $category_name . '"');
+        }
 
         // Method 1: Exact name match
         $term = get_term_by('name', $category_name, 'product_cat');
         if ($term) {
-            error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id);
+            if ($debug) { error_log('STUDIOU WC IMPORT: Found by exact name match - ID: ' . $term->term_id); }
             return $term->term_id;
         }
 
         // Method 2: Try by slug
         $slug = sanitize_title($category_name);
-        error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"');
+        if ($debug) { error_log('STUDIOU WC IMPORT: Exact name failed, trying slug: "' . $slug . '"'); }
         $term = get_term_by('slug', $slug, 'product_cat');
         if ($term) {
-            error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id);
+            if ($debug) { error_log('STUDIOU WC IMPORT: Found by slug match - ID: ' . $term->term_id); }
             return $term->term_id;
         }
 
-        // Method 3: Search case-insensitively through all categories
-        $all_terms = get_terms(array(
-            'taxonomy' => 'product_cat',
-            'hide_empty' => false
-        ));
+        // Method 3: Search case-insensitively through all categories. Pre-1.7.1
+        // this called get_terms() on EVERY miss-path row — O(N) categories per
+        // miss, O(N*M) overall on a multi-row import. Now we load once per
+        // request and reuse.
+        if (self::$all_product_cats_cache === null) {
+            $loaded = get_terms(array(
+                'taxonomy'   => 'product_cat',
+                'hide_empty' => false,
+            ));
+            self::$all_product_cats_cache = is_array($loaded) ? $loaded : array();
+        }
 
-        error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count($all_terms) . ' categories case-insensitively');
+        if ($debug) {
+            error_log('STUDIOU WC IMPORT: Slug failed, searching ' . count(self::$all_product_cats_cache) . ' categories case-insensitively');
+        }
 
-        foreach ($all_terms as $t) {
+        foreach (self::$all_product_cats_cache as $t) {
             if (strcasecmp($t->name, $category_name) === 0) {
-                error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"');
+                if ($debug) { error_log('STUDIOU WC IMPORT: Found by case-insensitive match - ID: ' . $t->term_id . ', name: "' . $t->name . '"'); }
                 return $t->term_id;
             }
         }
 
-        // Not found - try to create it
+        // Not found — create it (always-on log because category creation is a real side effect).
         error_log('STUDIOU WC IMPORT: Category NOT FOUND: "' . $category_name . '" - Attempting to create it');
-
-        $new_term = wp_insert_term(
-            $category_name,
-            'product_cat',
-            array(
-                'slug' => $slug
-            )
-        );
-
+        $new_term = wp_insert_term($category_name, 'product_cat', array('slug' => $slug));
         if (is_wp_error($new_term)) {
             error_log('STUDIOU WC IMPORT: Failed to create category "' . $category_name . '": ' . $new_term->get_error_message());
-            error_log('STUDIOU WC IMPORT: Total available categories: ' . count($all_terms));
-
-            // Show first 20 categories for debugging
-            $sample_cats = array_slice($all_terms, 0, 20);
-            foreach ($sample_cats as $cat) {
-                error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')');
+            if ($debug) {
+                error_log('STUDIOU WC IMPORT: Total available categories: ' . count(self::$all_product_cats_cache));
+                $sample_cats = array_slice(self::$all_product_cats_cache, 0, 20);
+                foreach ($sample_cats as $cat) {
+                    error_log('STUDIOU WC IMPORT: Available: "' . $cat->name . '" (ID: ' . $cat->term_id . ', slug: ' . $cat->slug . ')');
+                }
             }
-
             return null;
-        } else {
-            $term_id = $new_term['term_id'];
+        }
+
+        $term_id = (int) $new_term['term_id'];
+        // Append the new term to the cache so subsequent rows see it
+        // without re-running get_terms().
+        $fresh = get_term($term_id, 'product_cat');
+        if ($fresh && !is_wp_error($fresh)) {
+            self::$all_product_cats_cache[] = $fresh;
+        }
+        if ($debug) {
             error_log('STUDIOU WC IMPORT: Successfully created category "' . $category_name . '" with ID: ' . $term_id);
-            return $term_id;
         }
+        return $term_id;
     }
 
     /**

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

@@ -1,6 +1,6 @@
 # QDR - Studiou WC Export/Import Product Categories
 
-**Version: 1.7.0**
+**Version: 1.7.1**
 
 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,16 @@ For support, please visit [https://www.quadarax.com/plugins/studiou-wc-product-c
 
 ## Changelog
 
+### Version 1.7.1
+- **Performance: category counts in the manipulation dropdowns are now a single aggregate query (M9).** Previously the manipulator looped over every product category and ran a separate `COUNT(DISTINCT)` query for each, which the 5-minute transient cache masked — but the cache is busted by every `set_object_terms` / `edited_term` / etc., which this plugin's own bulk operations fire constantly, so the per-category loop ran often. Replaced with one `GROUP BY` query that preserves the existing `post_type = 'product'` and `post_status IN (publish, private, draft)` filters via the `ON` clause, so trashed and pending products don't inflate the displayed counts. Zero-count categories still appear (LEFT JOIN). Cache layer unchanged.
+- **Product import: per-request cache for the full product_cat term list (M8).** The case-insensitive fallback inside `find_or_create_category_by_name` (still aliased as `find_category_by_name` for compat) used to call `get_terms()` on every miss-path row — O(N) categories per miss, O(N*M) overall. Now loaded once per AJAX request and reused; newly created terms append into the cache. Method renamed to make the "creates as a side effect" behaviour visible at call sites.
+- **Product import: success-row logs are now gated behind WP_DEBUG (M7).** "Processing categories for product X", "Categories assigned via set_category_ids()", "find_category_by_name called for Y", "Found by exact name match - ID Z", and similar success/info lines no longer flood the production PHP error log on every import. Failure-path logs (creation failed, term insert failed, image download failed, etc.) stay always-on. Matches the documented "Only logs failures" policy.
+- **Product export row count is now correct when fields contain newlines (L2).** The old `substr_count($csv_content, "\n") - 1` over-counted whenever a product description contained a newline. `export_products()` now accumulates the count during generation and returns it via a by-ref parameter.
+- **Runtime-generated versioned asset copies moved out of the plugin directory and into `uploads/studiou-wcpcm-cache/` (L4).** Generating versioned `admin-X.Y.Z.js` / `admin-X.Y.Z.css` files inside the plugin directory tripped some security scanners and failed on managed hosts with read-only plugin dirs. The new location is the writable WordPress uploads dir; the cache subdirectory gets `index.php` silence files. The plugin dir is left untouched.
+- **UPVP: detects and reports variations whose sale price ends up greater than the new regular price (L7).** When the operator sets a new regular price below an existing sale price (sale > regular is invalid), the operation now logs `STUDIOU WC UPVP: variation N has sale_price S greater than new regular_price R` per affected variation and includes a `sale_warnings` array in the AJAX response. The plugin does **not** auto-clamp the sale price — sale data is operator-owned — but the warning gives the operator the information needed to fix it.
+- **Security & operational notes documented in CLAUDE.md (L6, L8).** Server-side image fetching (SSRF surface gated by `manage_woocommerce`) and the failed-CSV retry being creation-only are both called out under a new "Security notes" section.
+- Two legacy single-shot AJAX actions from prior versions (`studiou_wcpcm_import`, `studiou_wcpcm_move_products`) remain registered for backwards compatibility. The bundled UI no longer calls them.
+
 ### Version 1.7.0
 - **Category import is now batched and disk-staged (M1, M4 rework).** The legacy single-shot endpoint that processed the entire CSV in one AJAX call ran into the ~60 second PHP timeout on stores with thousands of categories. The new flow mirrors the product importer: the parse step stages rows to a guarded `wp-content/uploads/studiou-wcpcm-staging/<token>.json` file (`index.php` + `.htaccess` guards, 24-hour TTL); the batch step processes 10 rows per AJAX call and reports progress; the JS controller polls until done. Two new AJAX actions: `studiou_wcpcm_import_parse`, `studiou_wcpcm_import_batch`. The legacy `studiou_wcpcm_import` action is still registered for backwards compatibility but is no longer used by the bundled UI.
 - **Product move is now batched (M2).** Same shape as the existing delete-products flow: a `studiou_wcpcm_move_products_start` action returns the source category's product IDs, then `studiou_wcpcm_move_products_chunk` moves 25 at a time. `wp_set_post_terms` is idempotent, so a retried chunk after a network blip won't corrupt anything. The single-shot `studiou_wcpcm_move_products` action remains for backwards compatibility.

+ 42 - 17
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.7.0
+ * Version: 1.7.1
  * Requires at least: 6.8.1
  * Requires PHP: 8.2
  * Author: Dalibor Votruba
@@ -23,7 +23,7 @@ if (!defined('WPINC')) {
 }
 
 // Define plugin constants
-if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.7.0');
+if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.7.1');
 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__));
@@ -293,6 +293,13 @@ class Studiou_WC_Product_Cat_Manage {
                     'upvpConfirmApply' => __('Are you sure you want to update %d variations?', 'studiou-wc-product-cat-manage'),
                     'tabLoading' => __('Loading...', 'studiou-wc-product-cat-manage'),
                     'tabLoadError' => __('Failed to load tab', 'studiou-wc-product-cat-manage'),
+                    // v1.7.1 — L1: keys for JS strings that were previously hardcoded English.
+                    'selectBothCategories' => __('Please select both source and target categories', 'studiou-wc-product-cat-manage'),
+                    'moveNoProducts'       => __('No products to move.', 'studiou-wc-product-cat-manage'),
+                    'importInitializing'   => __('Initializing...', 'studiou-wc-product-cat-manage'),
+                    'importComplete'       => __('Import complete', 'studiou-wc-product-cat-manage'),
+                    'deletionSummary'      => __('Deletion Summary', 'studiou-wc-product-cat-manage'),
+                    'importSummary'        => __('Import Summary', 'studiou-wc-product-cat-manage'),
                 )
             ));
             
@@ -328,26 +335,44 @@ class Studiou_WC_Product_Cat_Manage {
             return STUDIOU_WCPCM_PLUGIN_URL . $source_rel;
         }
 
-        $versioned_rel = $rel_no_ext . '-' . STUDIOU_WCPCM_VERSION . '.' . $ext;
-        $versioned_abs = STUDIOU_WCPCM_PLUGIN_DIR . $versioned_rel;
-
-        // Regenerate the versioned copy if it is missing or out of date.
-        if (!file_exists($versioned_abs) || filemtime($source_abs) > filemtime($versioned_abs)) {
-            // Remove stale versioned copies of this asset.
-            $old_files = glob(STUDIOU_WCPCM_PLUGIN_DIR . $rel_no_ext . '-*.' . $ext);
-            if (is_array($old_files)) {
-                foreach ($old_files as $old_file) {
-                    @unlink($old_file);
+        // v1.7.1 — L4: write the versioned copies under uploads/ instead of
+        // the plugin directory. The plugin dir is conventionally read-only
+        // on managed hosts and trips some security scanners when files
+        // appear in it at runtime; uploads/ is the right place for runtime-
+        // generated assets. The original source files in the plugin dir
+        // are still the canonical edit targets — only the versioned copies
+        // moved.
+        $upload_dir = wp_upload_dir();
+        if (empty($upload_dir['error'])) {
+            $cache_dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-cache/' . dirname($rel_no_ext) . '/';
+            $cache_url = trailingslashit($upload_dir['baseurl']) . 'studiou-wcpcm-cache/' . dirname($rel_no_ext) . '/';
+            if (!file_exists($cache_dir)) {
+                wp_mkdir_p($cache_dir);
+                @file_put_contents(dirname($cache_dir) . '/index.php', '<?php // Silence is golden.');
+                @file_put_contents($cache_dir . 'index.php', '<?php // Silence is golden.');
+            }
+            $basename = basename($rel_no_ext) . '-' . STUDIOU_WCPCM_VERSION . '.' . $ext;
+            $versioned_abs = $cache_dir . $basename;
+            $versioned_url = $cache_url . $basename;
+
+            if (!file_exists($versioned_abs) || filemtime($source_abs) > filemtime($versioned_abs)) {
+                // Remove stale versioned copies of this asset in the uploads cache.
+                $old_files = glob($cache_dir . basename($rel_no_ext) . '-*.' . $ext);
+                if (is_array($old_files)) {
+                    foreach ($old_files as $old_file) {
+                        @unlink($old_file);
+                    }
+                }
+                if (!@copy($source_abs, $versioned_abs)) {
+                    return STUDIOU_WCPCM_PLUGIN_URL . $source_rel;
                 }
             }
 
-            // Copy failed (e.g. read-only filesystem) - fall back to plain URL.
-            if (!@copy($source_abs, $versioned_abs)) {
-                return STUDIOU_WCPCM_PLUGIN_URL . $source_rel;
-            }
+            return $versioned_url;
         }
 
-        return STUDIOU_WCPCM_PLUGIN_URL . $versioned_rel;
+        // wp_upload_dir() failed — fall back to the plain source URL.
+        return STUDIOU_WCPCM_PLUGIN_URL . $source_rel;
     }
 
     /**