浏览代码

Merge branch '1.5.0.studiou-wc-product-cat-manage'
sync from v.1.7.1

Dalibor Votruba 1 月之前
父节点
当前提交
6255f26a15

+ 7 - 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.0
+**Current Version:** 1.7.1
 
 ## Requirements
 
@@ -82,6 +82,7 @@ The plugin follows a modular class-based architecture:
   - Supports Min/Max quantity metadata
   - Comprehensive error logging (always enabled, prefix: "STUDIOU WC IMPORT:")
   - Logs only skipped/failed items (not successful ones)
+  - Source-URL deduplication (NEW in v1.6.1): each unique `Obrázky` URL is downloaded once and reused via a `_studiou_wcpcm_source_url` attachment postmeta plus a per-request static cache, so all rows sharing an image (variable parent + variations) point at one attachment
 
 - **class-studiou-wc-product-manage-product-export.php** - Product export functionality (NEW in v1.4)
   - Exports products by category selection
@@ -260,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

+ 180 - 79
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.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');
@@ -163,25 +163,29 @@
      * Initialize import form
      */
     function initImportForm() {
+        // v1.7.0 — M1: batched category import. The flow is parse → loop
+        // batches → summarise, mirroring the product importer. Removes the
+        // single-shot ~60s timeout on large category trees. The legacy
+        // single-shot endpoint stays registered for back-compat but JS no
+        // longer calls it.
         $('#studiou-wcpcm-import-form').on('submit', function(e) {
             e.preventDefault();
-            
-            // Show spinner
-            var $submitBtn = $(this).find('#studiou-wcpcm-import-button');
-            var $spinner = $(this).find('.spinner');
-            
+
+            var $form = $(this);
+            var $submitBtn = $form.find('#studiou-wcpcm-import-button');
+            var $spinner = $form.find('.spinner');
+
             $submitBtn.prop('disabled', true);
             $spinner.css('visibility', 'visible');
-            
-            // Clear previous notices
             $('.studiou-wcpcm-notice-area').empty();
-            
-            // Create form data
+            $('.studiou-wcpcm-import-results').hide();
+            $('#studiou-wcpcm-import-results').empty();
+
+            // Step 1 — parse the CSV server-side, stage to disk, get a token.
             var formData = new FormData(this);
-            formData.append('action', 'studiou_wcpcm_import');
+            formData.append('action', 'studiou_wcpcm_import_parse');
             formData.append('nonce', studiouWcpcm.nonce);
-            
-            // Send AJAX request
+
             $.ajax({
                 url: studiouWcpcm.ajaxUrl,
                 type: 'POST',
@@ -189,26 +193,84 @@
                 processData: false,
                 contentType: false,
                 success: function(response) {
-                    if (response.success) {
-                        showNotice('success', studiouWcpcm.i18n.importSuccess);
-                        
-                        // Show results
-                        $('#studiou-wcpcm-import-results').text(response.data.message);
-                        $('.studiou-wcpcm-import-results').show();
-                    } else {
-                        showNotice('error', response.data.message);
+                    if (!response.success) {
+                        showNotice('error', response.data && response.data.message ? response.data.message : 'Error');
+                        $submitBtn.prop('disabled', false);
+                        $spinner.css('visibility', 'hidden');
+                        return;
                     }
+                    runImportBatches(response.data.staging_key, response.data.total, $submitBtn, $spinner);
                 },
                 error: function() {
-                    showNotice('error', studiouWcpcm.i18n.importError);
-                },
-                complete: function() {
-                    // Hide spinner
+                    showNotice('error', studiouWcpcm.i18n.importError || 'Import error');
                     $submitBtn.prop('disabled', false);
                     $spinner.css('visibility', 'hidden');
                 }
             });
         });
+
+        function runImportBatches(stagingKey, total, $submitBtn, $spinner) {
+            var processed = 0;
+            var totalSuccess = 0;
+            var totalErrors = 0;
+            var allMessages = [];
+
+            // Show or build a simple progress indicator. The view doesn't
+            // require a dedicated progress-bar element; we render into the
+            // existing results container.
+            var $results = $('#studiou-wcpcm-import-results');
+            $results.empty();
+            $('.studiou-wcpcm-import-results').show();
+            $results.html('<p>0 / ' + total + '</p>');
+
+            function nextBatch() {
+                $.ajax({
+                    url: studiouWcpcm.ajaxUrl,
+                    type: 'POST',
+                    data: {
+                        action: 'studiou_wcpcm_import_batch',
+                        nonce: studiouWcpcm.nonce,
+                        staging_key: stagingKey,
+                        offset: processed
+                    },
+                    success: function(response) {
+                        if (!response.success) {
+                            showNotice('error', response.data && response.data.message ? response.data.message : 'Error');
+                            $submitBtn.prop('disabled', false);
+                            $spinner.css('visibility', 'hidden');
+                            return;
+                        }
+                        processed = response.data.processed;
+                        totalSuccess += response.data.success || 0;
+                        totalErrors += response.data.errors || 0;
+                        if (Array.isArray(response.data.messages)) {
+                            allMessages = allMessages.concat(response.data.messages);
+                        }
+                        $results.html('<p>' + processed + ' / ' + total + '</p>');
+
+                        if (response.data.done) {
+                            var summary = '<p><strong>' + (totalSuccess + totalErrors) + ' items processed: ' + totalSuccess + ' OK, ' + totalErrors + ' errors.</strong></p>';
+                            if (allMessages.length) {
+                                summary += '<pre style="max-height:300px;overflow:auto;">' + allMessages.map(function(m){return $('<div>').text(m).html();}).join('\n') + '</pre>';
+                            }
+                            $results.html(summary);
+                            showNotice(totalErrors === 0 ? 'success' : 'warning', studiouWcpcm.i18n.importSuccess || 'Import complete');
+                            $submitBtn.prop('disabled', false);
+                            $spinner.css('visibility', 'hidden');
+                        } else {
+                            nextBatch();
+                        }
+                    },
+                    error: function() {
+                        showNotice('error', studiouWcpcm.i18n.importError || 'Import error');
+                        $submitBtn.prop('disabled', false);
+                        $spinner.css('visibility', 'hidden');
+                    }
+                });
+            }
+
+            nextBatch();
+        }
     }
     
     /**
@@ -264,91 +326,130 @@
      * Initialize move products form
      */
     function initMoveForm() {
-        console.log('STUDIOU WC: Initializing move form handlers');
-        
-        // Add debug button click handler first
-        $('#studiou-wcpcm-move-button').on('click', function(e) {
-            console.log('STUDIOU WC: Move button clicked directly');
-        });
-        
+        // v1.7.0 — M2: batched product move. Step 1 (start) returns the
+        // list of product IDs in the source category. Step 2 (chunk) moves
+        // 25 at a time. JS drives the chunking + progress bar. Removes the
+        // single-shot ~60s PHP-timeout cliff on large source categories.
         $('#studiou-wcpcm-move-form').on('submit', function(e) {
             e.preventDefault();
-            
-            // Debug: Log form submission
-            console.log('STUDIOU WC: Move form submitted');
-            
-            // Validate that different categories are selected
+
             var sourceCategory = $('#source_category').val();
             var targetCategory = $('#target_category').val();
-            
-            console.log('STUDIOU WC: Source category:', sourceCategory, 'Target category:', targetCategory);
-            
+
             if (!sourceCategory || !targetCategory) {
-                console.log('STUDIOU WC: Missing category selection');
-                showNotice('error', 'Please select both source and target categories');
+                showNotice('error', studiouWcpcm.i18n.selectBothCategories || 'Please select both source and target categories');
                 return;
             }
-            
             if (sourceCategory === targetCategory) {
-                console.log('STUDIOU WC: Same category selected');
                 showNotice('error', studiouWcpcm.i18n.selectDifferentCategories);
                 return;
             }
-            
-            // Show spinner
-            var $submitBtn = $(this).find('#studiou-wcpcm-move-button');
-            var $spinner = $(this).find('.spinner');
-            
+
+            var $form = $(this);
+            var $submitBtn = $form.find('#studiou-wcpcm-move-button');
+            var $spinner = $form.find('.spinner');
             $submitBtn.prop('disabled', true);
             $spinner.css('visibility', 'visible');
-            
-            // Clear previous notices
             $('.studiou-wcpcm-notice-area').empty();
-            
-            // Debug: Log AJAX request details
-            console.log('STUDIOU WC: Sending AJAX request to:', studiouWcpcm.ajaxUrl);
-            console.log('STUDIOU WC: Nonce:', studiouWcpcm.nonce);
-            
-            // Send AJAX request
+            $('.studiou-wcpcm-move-results').hide();
+
+            // Step 1 — get the product IDs to move.
             $.ajax({
                 url: studiouWcpcm.ajaxUrl,
                 type: 'POST',
                 data: {
-                    action: 'studiou_wcpcm_move_products',
+                    action: 'studiou_wcpcm_move_products_start',
                     source_category: sourceCategory,
                     target_category: targetCategory,
                     nonce: studiouWcpcm.nonce
                 },
                 success: function(response) {
-                    console.log('STUDIOU WC: AJAX response:', response);
-                    
-                    if (response.success) {
-                        showNotice('success', studiouWcpcm.i18n.moveSuccess);
-                        
-                        // Show results
-                        $('#studiou-wcpcm-move-message').html('<p>' + response.data.message + '</p>');
+                    if (!response.success) {
+                        showNotice('error', response.data && response.data.message ? response.data.message : (studiouWcpcm.i18n.moveError || 'Error'));
+                        $submitBtn.prop('disabled', false);
+                        $spinner.css('visibility', 'hidden');
+                        return;
+                    }
+                    var ids = response.data.product_ids || [];
+                    var total = response.data.total || 0;
+                    var sourceName = response.data.source_name || '';
+                    var targetName = response.data.target_name || '';
+
+                    if (total === 0) {
+                        $('#studiou-wcpcm-move-message').html('<p>' + (studiouWcpcm.i18n.moveNoProducts || 'No products to move.') + '</p>');
                         $('.studiou-wcpcm-move-results').show();
-                        
-                        // Refresh the page to update category counts
-                        setTimeout(function() {
-                            location.reload();
-                        }, 3000);
-                    } else {
-                        showNotice('error', response.data.message);
+                        $submitBtn.prop('disabled', false);
+                        $spinner.css('visibility', 'hidden');
+                        return;
                     }
+                    runMoveChunks(ids, total, sourceCategory, targetCategory, sourceName, targetName, $submitBtn, $spinner);
                 },
-                error: function(xhr, status, error) {
-                    console.error('STUDIOU WC: AJAX error:', status, error);
-                    console.error('STUDIOU WC: Response text:', xhr.responseText);
-                    showNotice('error', studiouWcpcm.i18n.moveError + ': ' + error);
-                },
-                complete: function() {
-                    // Hide spinner
+                error: function() {
+                    showNotice('error', studiouWcpcm.i18n.moveError || 'Move error');
                     $submitBtn.prop('disabled', false);
                     $spinner.css('visibility', 'hidden');
                 }
             });
         });
+
+        function runMoveChunks(allIds, total, sourceId, targetId, sourceName, targetName, $submitBtn, $spinner) {
+            var CHUNK = 25;
+            var idx = 0;
+            var movedTotal = 0;
+            var failedTotal = 0;
+
+            var $msg = $('#studiou-wcpcm-move-message');
+            $msg.html('<p>0 / ' + total + '</p>');
+            $('.studiou-wcpcm-move-results').show();
+
+            function nextChunk() {
+                var chunk = allIds.slice(idx, idx + CHUNK);
+                if (chunk.length === 0) { return; }
+
+                $.ajax({
+                    url: studiouWcpcm.ajaxUrl,
+                    type: 'POST',
+                    data: {
+                        action: 'studiou_wcpcm_move_products_chunk',
+                        nonce: studiouWcpcm.nonce,
+                        source_category: sourceId,
+                        target_category: targetId,
+                        product_ids: chunk
+                    },
+                    success: function(response) {
+                        if (!response.success) {
+                            showNotice('error', response.data && response.data.message ? response.data.message : (studiouWcpcm.i18n.moveError || 'Error'));
+                            $submitBtn.prop('disabled', false);
+                            $spinner.css('visibility', 'hidden');
+                            return;
+                        }
+                        movedTotal += response.data.moved || 0;
+                        failedTotal += response.data.failed || 0;
+                        idx += chunk.length;
+                        $msg.html('<p>' + idx + ' / ' + total + '</p>');
+                        if (idx >= total) {
+                            var summary = '<p>' + movedTotal + ' moved from "' + sourceName + '" to "' + targetName + '"';
+                            if (failedTotal > 0) { summary += ', ' + failedTotal + ' failed'; }
+                            summary += '.</p>';
+                            $msg.html(summary);
+                            showNotice(failedTotal === 0 ? 'success' : 'warning', studiouWcpcm.i18n.moveSuccess || 'Move complete');
+                            $submitBtn.prop('disabled', false);
+                            $spinner.css('visibility', 'hidden');
+                            setTimeout(function() { location.reload(); }, 2000);
+                        } else {
+                            nextChunk();
+                        }
+                    },
+                    error: function() {
+                        showNotice('error', studiouWcpcm.i18n.moveError || 'Move error');
+                        $submitBtn.prop('disabled', false);
+                        $spinner.css('visibility', 'hidden');
+                    }
+                });
+            }
+
+            nextChunk();
+        }
     }
     
     /**

+ 51 - 0
studiou-wc-product-cat-manage/fix-multi-image-plan-00.md

@@ -0,0 +1,51 @@
+# Why duplicate media files (`image-1.jpg`, `image-2.jpg`, …) appear on import
+
+The cause is in `includes/class-studiou-wc-product-manage-product-import.php`, not in the view.
+
+## Where the upload happens
+
+- **Variable product row** → `process_variable_product()` calls `upload_image_from_url()` at line 287-292.
+- **Each variation row** → `process_variation()` calls `upload_image_from_url()` again at line 411-416.
+
+Every CSV row that has a non-empty `Obrázky` triggers an independent call.
+
+## What `upload_image_from_url()` does (line 464-511)
+
+1. `download_url($url, 30)` — downloads the remote file to a fresh temp file (PHP's `tmpfile()` name, not the basename of your URL).
+2. Builds `$file_array['name'] = basename($url)`.
+3. Calls `media_handle_sideload($file_array, 0)` — and there is **no check** for whether the same source URL is already in the Media Library.
+
+## Why the `-1`, `-2`, `-3` suffix gets added
+
+`media_handle_sideload()` → `wp_handle_sideload()` → `wp_unique_filename()`. WordPress sees that `uploads/YYYY/MM/image.jpg` already exists from a previous row's import, so it renames the new upload to `image-1.jpg`, then `image-2.jpg`, etc. Each gets its own attachment post + physical file on disk.
+
+## So with a typical variable product CSV
+
+One variable parent + N variations, all pointing at the same `image.jpg`:
+
+- Row 1 (variable) → uploads `image.jpg` (attachment #1)
+- Row 2 (variation) → re-downloads → `image-1.jpg` (attachment #2)
+- Row 3 (variation) → re-downloads → `image-2.jpg` (attachment #3)
+- …and so on.
+
+That's the exact `<thumbnail-file>-<ordinal>.jpg` pattern observed.
+
+## Contributing factors
+
+- **No URL-based dedup cache** anywhere in the importer. There's no lookup like "does an attachment already exist whose `guid` / `_source_url` / `_wp_attached_file` matches this remote URL?"
+- **No in-run dedup** either — even within a single batch, two rows with the same URL each trigger a fresh `download_url()` + `media_handle_sideload()`.
+- **`basename($url)`** is used as the local filename, so the collision detection only catches exact same-filename uploads in the same month folder. A URL like `…/img.jpg?v=2` still produces `img.jpg`, so collisions are likely.
+- The batch architecture (5 rows per AJAX request) doesn't share any cache between batches either — even an in-memory `array($url => $attachment_id)` would die when the request ends.
+
+## Side effect worth noting
+
+Because each variation gets its *own* uploaded image, `$variation->set_image_id($image_id)` at line 414 points each variation at a different attachment ID — even though all the variations were supposed to share the parent's image. So this isn't just file clutter; the parent and every variation are individually referencing distinct copies of what is logically the same image.
+
+## Fix shape (not applied — diagnosis only)
+
+A cache `array<url, attachment_id>` keyed by source URL, checked **before** `download_url()`, that:
+
+1. First queries existing attachments by `guid` / a custom `_source_url` meta / matching `_wp_attached_file` basename, and
+2. If not found, performs the sideload and stores the resulting ID under the URL key (persisting the source URL in postmeta so future imports across requests hit the same attachment).
+
+That would collapse N+1 duplicate uploads per shared URL down to one.

+ 455 - 0
studiou-wc-product-cat-manage/fix-multi-image-plan-01.md

@@ -0,0 +1,455 @@
+# Implementation Plan — Deduplicate media on product import (v1.6.1)
+
+## 1. Symptom
+
+A product CSV in which the variable parent and all its variations share one
+`Obrázky` URL currently produces `image.jpg`, `image-1.jpg`, `image-2.jpg`, … —
+one fresh download and one **distinct** attachment per row. The parent and every
+variation end up pointing at a different physical copy of what is logically the
+same image.
+
+## 2. Root cause (condensed)
+
+Full analysis is in `fix-multi-image-plan-00.md`. In short:
+
+`upload_image_from_url()`
+(`includes/class-studiou-wc-product-manage-product-import.php:464-511`)
+unconditionally runs `download_url()` + `media_handle_sideload()` with **no dedup**,
+and it is called once per row — by `process_variable_product()` (`:288`) for the
+parent and by `process_variation()` (`:412`) for each variation. On every call after
+the first, `media_handle_sideload()` → `wp_unique_filename()` sees
+`uploads/YYYY/MM/image.jpg` already exists and renames the new upload to
+`image-N.jpg`, minting a fresh attachment + file each time. That is the
+`<name>-<ordinal>.jpg` clutter, and the reason each variation references its own
+attachment ID instead of sharing the parent's.
+
+## 3. Goal
+
+When several rows reference the same `Obrázky` URL, the importer must:
+
+1. Download that URL **once** per unique (trimmed) source-URL string.
+2. Make every later row reuse the existing attachment, so the variable parent and
+   all its variations converge on **one** attachment ID.
+3. Hold across the AJAX batch boundary (5 rows per request) and across entirely
+   separate imports — even weeks apart.
+4. Touch nothing that prior imports already created — additive only, no migration.
+
+## 4. Non-goals
+
+- **No retroactive merge** of duplicates left by past imports (see follow-ups).
+- **No content hashing and no aggressive URL normalization.** Same URL string ⇒ same
+  image; two different strings stay independent even if the bytes are identical.
+  Normalization is limited to trimming surrounding whitespace.
+- **No change to failure semantics.** A failed download/sideload still returns `null`
+  and the row proceeds with no image.
+
+## 5. Design — two-layer cache keyed by the exact trimmed source URL
+
+The key for **every** step — in-request cache, DB lookup, download, stored postmeta —
+is the same trimmed URL string the validator already approved. If those strings ever
+diverge, dedup silently misses (see §5.3).
+
+### 5.1 Layer 1 — persistent (attachment postmeta)
+
+Each sideloaded attachment is tagged:
+
+- `meta_key   = _studiou_wcpcm_source_url`
+- `meta_value = <the exact trimmed URL from the Obrázky column>`
+
+Before downloading, look up an attachment carrying that meta value. Hit ⇒ reuse its
+ID. This survives across batches, across imports, and across server restarts.
+
+### 5.2 Layer 2 — in-request static cache
+
+`private static $url_cache = array()` on `Studiou_WC_Product_Manage_Product_Import`,
+checked before Layer 1 and written on every resolution (DB hit or fresh sideload). It
+saves a DB round-trip when the same URL recurs inside one AJAX request (one batch,
+up to 5 rows) and resets naturally when the request ends — Layer 1 carries the
+cross-request case. (A fresh `Product_Import` object is built per request, so static
+vs. instance scope are equivalent in lifetime here; `static` is used to state the
+request-scoped intent and to share across any second instance in the same process.)
+
+### 5.3 Key normalization — also a latent correctness fix
+
+The two callers currently pass the **raw** cell while the validator approved the
+**trimmed** value:
+
+- `process_product_row()` validates `trim($data['Obrázky'])` (`:203`).
+- `process_variable_product()` (`:288`) and `process_variation()` (`:412`) call
+  `upload_image_from_url($data['Obrázky'])` — untrimmed.
+
+So `" https://x/img.jpg"` is validated trimmed but downloaded padded, and two rows
+differing only by surrounding whitespace would key and download separately.
+
+**Fix:** `trim()` once at the top of `upload_image_from_url()` and use that single
+value everywhere. No case-folding, no trailing-slash or query-string stripping for the
+*key* (see Non-goals). The query string is stripped only from the on-disk **filename**
+(§6.1c) so `wp_unique_filename()` doesn't fold `?v=2` into the basename.
+
+### 5.4 Why postmeta, not a transient
+
+Postmeta already delivers cross-batch persistence and is durable beyond the import
+session — a re-import a week later still hits it. A transient would duplicate that,
+need explicit teardown, and expire at an arbitrary moment mid-import. Postmeta is both
+simpler and stronger.
+
+### 5.5 Why a plugin-namespaced meta key
+
+We use our own `_studiou_wcpcm_source_url` rather than WooCommerce's internal
+`_wc_attachment_source` convention. Rationale: the namespaced key is predictable,
+owned by this plugin, and immune to changes in WooCommerce importer internals. The
+trade-off — we won't dedup against images that WooCommerce's *native* CSV importer
+brought in — is acceptable; this importer is the only writer of these images in
+practice. (A future enhancement could additionally probe `_wc_attachment_source` on a
+miss; deferred to keep this change self-contained — see follow-ups.)
+
+## 6. Changes
+
+### 6.1 `includes/class-studiou-wc-product-manage-product-import.php`
+
+**(a) Static cache property** — add immediately after the `$db` property (`:22`):
+
+```php
+/**
+ * In-request cache of trimmed source URL => attachment ID, so the same URL
+ * appearing multiple times within one AJAX batch costs at most one DB lookup.
+ *
+ * @var array<string,int>
+ */
+private static $url_cache = array();
+```
+
+**(b) Finder method** — add next to `upload_image_from_url()`:
+
+```php
+/**
+ * Find an attachment previously imported from this exact source URL.
+ *
+ * Uses get_posts (not a raw $wpdb query) so a row whose attachment post was
+ * deleted but whose meta was somehow orphaned can't resolve to a missing post.
+ * Orders by ID ascending so the earliest (canonical) attachment wins
+ * deterministically if more than one ever carries the same source URL.
+ *
+ * @param string $url Trimmed source URL.
+ * @return int Attachment ID, or 0 if none.
+ */
+private function find_attachment_by_source_url($url) {
+    $ids = get_posts(array(
+        'post_type'              => 'attachment',
+        'post_status'            => 'inherit',
+        'posts_per_page'         => 1,
+        'orderby'                => 'ID',
+        'order'                  => 'ASC',
+        'fields'                 => 'ids',
+        'no_found_rows'          => true,
+        'update_post_meta_cache' => false,
+        'update_post_term_cache' => false,
+        'meta_query'             => array(
+            array(
+                'key'     => '_studiou_wcpcm_source_url',
+                'value'   => $url,
+                'compare' => '=',
+            ),
+        ),
+    ));
+
+    return !empty($ids) ? (int) $ids[0] : 0;
+}
+```
+
+**(c) Replace `upload_image_from_url()` (`:464-511`) entirely** with:
+
+```php
+/**
+ * Upload an image from a URL, deduplicating by source URL.
+ *
+ * A given source URL is downloaded at most once: an in-request static cache
+ * absorbs repeats within a batch, and a `_studiou_wcpcm_source_url` postmeta
+ * lookup reuses attachments across batches and across imports.
+ *
+ * @param string $url Image URL (raw value from the Obrázky column).
+ * @return int|null Attachment ID, or null on empty input / download failure.
+ */
+private function upload_image_from_url($url) {
+    // Normalize the key. process_product_row() validates a *trimmed* URL but
+    // the callers pass the raw cell, so trim here to keep the cache key, the
+    // DB lookup, the download, and the stored postmeta all identical to the
+    // value that was validated (and to collapse whitespace-only variants).
+    $url = is_string($url) ? trim($url) : '';
+    if ($url === '') {
+        return null;
+    }
+
+    // Layer 2 — in-request static cache.
+    if (isset(self::$url_cache[$url])) {
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('STUDIOU WC IMPORT: image cache HIT (request) ' . $url . ' -> ' . self::$url_cache[$url]);
+        }
+        return self::$url_cache[$url];
+    }
+
+    // Layer 1 — persistent postmeta lookup (across batches and re-imports).
+    $existing = $this->find_attachment_by_source_url($url);
+    if ($existing) {
+        self::$url_cache[$url] = $existing;
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('STUDIOU WC IMPORT: image cache HIT (db) ' . $url . ' -> ' . $existing);
+        }
+        return $existing;
+    }
+
+    // Layer 0 — fresh sideload.
+    require_once(ABSPATH . 'wp-admin/includes/media.php');
+    require_once(ABSPATH . 'wp-admin/includes/file.php');
+    require_once(ABSPATH . 'wp-admin/includes/image.php');
+
+    // Suppress intermediate sizes for import speed. Use the stable
+    // '__return_empty_array' callable (not a fresh closure) so remove_filter()
+    // can actually match it, and detach it in finally so it can never leak
+    // into later media operations in this request.
+    add_filter('intermediate_image_sizes_advanced', '__return_empty_array');
+
+    try {
+        // download_url()'s second arg sets the request timeout (30s); no
+        // http_request_timeout filter is needed.
+        $tmp = download_url($url, 30);
+        if (is_wp_error($tmp)) {
+            error_log('STUDIOU WC IMPORT: image download failed for ' . $url . ' - ' . $tmp->get_error_message());
+            return null;
+        }
+
+        // Derive a clean on-disk filename. parse_url() strips any query string
+        // (img.jpg?v=2 -> img.jpg) so wp_unique_filename() doesn't fold the
+        // query into the basename. Null-safe: parse_url() may return null/false
+        // and basename(null) is deprecated on PHP 8.1+ (we require 8.2).
+        $path = parse_url($url, PHP_URL_PATH);
+        $name = (is_string($path) && $path !== '') ? basename($path) : '';
+        if ($name === '') {
+            $name = 'image-' . md5($url) . '.jpg';
+        }
+
+        $file_array = array(
+            'name'     => $name,
+            'tmp_name' => $tmp,
+        );
+
+        $id = media_handle_sideload($file_array, 0);
+        if (is_wp_error($id)) {
+            @unlink($tmp);
+            error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message());
+            return null;
+        }
+
+        // Tag the attachment so future rows / batches / imports dedup on it.
+        update_post_meta($id, '_studiou_wcpcm_source_url', $url);
+
+        self::$url_cache[$url] = (int) $id;
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('STUDIOU WC IMPORT: image sideloaded ' . $url . ' -> ' . $id);
+        }
+        return (int) $id;
+
+    } catch (Exception $e) {
+        error_log('STUDIOU WC IMPORT: image upload exception for ' . $url . ' - ' . $e->getMessage());
+        return null;
+    } finally {
+        remove_filter('intermediate_image_sizes_advanced', '__return_empty_array');
+    }
+}
+```
+
+Design notes on the rewrite:
+
+- **Trim at the top** aligns the key with the already-validated URL — the precondition
+  for dedup to fire at all (§5.3).
+- **Cache-hit paths return before any filter is touched**, so filter management only
+  wraps the actual sideload.
+- **Clean filename** via `parse_url(..., PHP_URL_PATH)` + `basename()`, null-safe, with
+  an `image-<md5>.jpg` fallback. The full trimmed URL still drives the key, so distinct
+  query strings remain distinct attachments; only the on-disk name is cleaned.
+- **Filter handling** uses the stable `'__return_empty_array'` callable added once and
+  removed in `finally`, so it detaches on every exit (success, `is_wp_error`,
+  exception). The old `http_request_timeout` closure is deleted — redundant with
+  `download_url($url, 30)` and never removed (§7).
+- **Logging:** hard failures (download/sideload/exception) log **always-on** under the
+  documented `STUDIOU WC IMPORT:` prefix, per CLAUDE.md's "always enabled" rule —
+  replacing the current `WP_DEBUG`-gated `STUDIOU WC:` lines. High-volume per-row
+  diagnostics (cache hits, successful sideloads) stay behind `WP_DEBUG` so a 30-row
+  single-image import doesn't emit 30 lines every run. No new user-facing strings ⇒ no
+  translation work.
+- **Return contract unchanged:** fresh and DB paths return `int`; empty/failed paths
+  return `null`. Both callers already gate on `if ($image_id)`, so nothing downstream
+  changes.
+
+### 6.2 `studiou-wc-product-cat-manage.php`
+
+- Header `Version: 1.6.0` → `1.6.1` (`:6`).
+- `STUDIOU_WCPCM_VERSION` `'1.6.0'` → `'1.6.1'` (`:26`).
+
+The version-stamped asset mechanism regenerates `admin-1.6.1.js` / `admin-1.6.1.css`
+automatically on the next admin page load from the bumped constant — no manual
+renaming; the `admin-1.6.0.*` copies are gitignored build artifacts.
+
+### 6.3 `assets/js/admin.js`
+
+- Banner `console.log('STUDIOU WC: Admin script loaded v1.6.0')` → `v1.6.1` (`:22`).
+
+**No JS logic change** — the dedup is entirely server-side; the AJAX flow, batch size,
+and handlers are untouched. This keeps the banner in sync per the release process.
+Because the fix is server-side, the studiou.cz `?ver=`-stripping cache issue is
+irrelevant here — there is no behavioral JS/CSS change for a browser to miss.
+
+### 6.4 `CLAUDE.md`
+
+- Update the **Current Version** line to `1.6.1`.
+- Add a bullet under the product-import class description:
+  > Source-URL deduplication (NEW in v1.6.1): each unique `Obrázky` URL is downloaded
+  > once and reused via a `_studiou_wcpcm_source_url` attachment postmeta plus a
+  > per-request static cache, so all rows sharing an image (variable parent +
+  > variations) point at one attachment.
+
+### 6.5 `readme.md`
+
+- Bump `**Version: 1.6.0**` → `**Version: 1.6.1**` (`:3`).
+- Add a `### Version 1.6.1` changelog entry directly above `### Version 1.6.0` (`:355`)
+  summarizing the dedup fix and the two latent-bug fixes.
+
+### 6.6 Translations
+
+No new translatable strings ⇒ no `.po`/`.mo` recompile. Optionally bump the
+`Project-Id-Version` header in both `.po` files for consistency.
+
+## 7. Latent bugs fixed as a side effect
+
+Both live in the method being rewritten, so this is the natural moment to fix them.
+
+1. **`intermediate_image_sizes_advanced` is never actually removed.** `:488` and `:493`
+   add and "remove" the filter with two *separate* closures. `remove_filter()` matches
+   by callable identity, so the fresh closure never matches the added one; the filter
+   stays attached for the rest of the request and suppresses intermediate sizes on any
+   later media operation. The rewrite uses the stable `'__return_empty_array'` callable
+   and removes it in `finally`.
+2. **`http_request_timeout` closure added every call, never removed** (`:470`). Across a
+   5-row batch this stacks up to five closures clamping every later HTTP request to 30s,
+   and it is redundant with `download_url($url, 30)`. The rewrite deletes it.
+
+## 8. Performance / query cost
+
+`find_attachment_by_source_url()` filters `wp_postmeta` by
+`meta_key = '_studiou_wcpcm_source_url'` with an equality compare on the full URL in
+`meta_value`. `meta_value` (LONGTEXT) isn't usefully indexed for a long-string `=`, so
+this scans the rows carrying that key. Acceptable because:
+
+- Only attachments imported by this plugin carry the key, so the candidate set is small
+  relative to the whole table.
+- Layer 2 absorbs repeats within a batch, so at most one such query runs per *distinct*
+  URL per request.
+- `no_found_rows` + `update_post_meta_cache => false` + `update_post_term_cache => false`
+  keep the query lean.
+
+A direct `$wpdb->get_var(... ORDER BY post_id ASC LIMIT 1)` would shave the WP_Query
+overhead, but `get_posts` guarantees the returned ID belongs to an existing attachment
+post, which is worth the small cost. If a library ever grows large enough for this to
+matter, the follow-up is a dedicated lookup table or an indexed hash column —
+out of scope.
+
+## 9. Edge cases handled
+
+- **Empty `Obrázky`** → early `null`, no cache write (matches current behavior).
+- **Same URL within one batch** → first row sideloads + writes postmeta; later rows in
+  the same request hit Layer 2.
+- **Same URL across batches of one import** → batch 1 sideloads; later batches hit
+  Layer 1 (the static cache starts empty in each new request).
+- **Same URL in a re-import months later** → Layer 1 still hits.
+- **Whitespace-only variants** (`"…img.jpg"` vs `" …img.jpg"`) → collapse to one
+  attachment via the trim.
+- **Two different URLs with the same basename** → two attachments (distinct keys); the
+  second file lands as `name-1.jpg` on disk, which is correct.
+- **Query-string URL** (`…/img.jpg?v=2`) → on-disk file is `img.jpg`; the attachment is
+  keyed by the full URL including `?v=2`.
+- **Failed download** → returns `null`, does **not** cache the failure; the next row
+  retries. No worse than today.
+- **Sideload fails after a good download** → temp file unlinked, cache not poisoned,
+  returns `null`.
+- **Pre-1.6.1 attachments** (no `_studiou_wcpcm_source_url`) → cache miss → fresh
+  upload. We don't retroactively tag old media.
+- **Attachment manually deleted** (post + its meta gone) → cache miss → re-upload.
+  Correct.
+- **Concurrent batches** → not applicable; the JS issues batches sequentially.
+
+## 10. Edge cases NOT handled (deliberately)
+
+- **URL differs by trailing slash / case** → treated as distinct (predictable exact
+  match).
+- **Bytes change at the same URL** (image rotated upstream) → reuses the stale
+  attachment. Acceptable for an import tool; delete the attachment to force a refresh.
+- **Same image served from two different URLs** (CDN vs. origin) → two attachments;
+  can't dedup without hashing bytes (a Non-goal).
+- **Attachment post survives but its file was deleted from disk** → the dangling
+  attachment ID is reused, yielding a broken image reference. Rare; `get_posts` only
+  guards post existence, not file existence. Out of scope — re-deleting the attachment
+  forces a clean re-import.
+
+## 11. Execution order
+
+1. Edit `class-studiou-wc-product-manage-product-import.php`: add the static property,
+   add `find_attachment_by_source_url()`, replace `upload_image_from_url()`.
+2. Bump version in `studiou-wc-product-cat-manage.php` (header + constant).
+3. Bump banner in `assets/js/admin.js`.
+4. Update `CLAUDE.md` (Current Version + class bullet).
+5. Update `readme.md` (Version line + `### Version 1.6.1` changelog entry).
+6. Commit as a single change.
+
+No view edits, no JS logic edits, no CSS edits, no `.po`/`.mo` recompile, no AJAX
+handler changes.
+
+## 12. Test plan
+
+- **Happy path** — CSV with 1 variable product + 5 variations, all the same image URL.
+  Expect exactly 1 `attachment` row, 1 file in `uploads/YYYY/MM/`, and the parent + all
+  5 variations sharing that one attachment ID.
+  `SELECT meta_value FROM wp_postmeta WHERE meta_key='_studiou_wcpcm_source_url'` → one
+  row, value = the exact trimmed URL.
+- **Cross-batch** — 30 rows, same URL (6 batches of 5). Batch 1: one Layer 0 sideload,
+  postmeta written. Batches 2–6: Layer 1 hit on every row, zero new downloads. With
+  `WP_DEBUG`: one "sideloaded" line, then "cache HIT" lines.
+- **Re-import** — run the same CSV twice. Second run: every row hits Layer 1; zero new
+  uploads.
+- **Distinct URLs** — 5 rows, 5 different URLs → 5 sideloads, 5 attachments, 5 postmeta
+  rows.
+- **Mixed** — 3 rows URL A, 2 rows URL B, 1 empty `Obrázky` → 2 sideloads, 4 cache hits,
+  1 row with no image.
+- **Whitespace normalization** — row 1 `"https://x/img.jpg"`, row 2
+  `" https://x/img.jpg"` → 1 sideload, 1 attachment, row 2 is a cache hit. (Without the
+  trim: two attachments.)
+- **Query-string filename** — URL `https://x/img.jpg?v=2` → on-disk file is `img.jpg`,
+  not `img.jpg?v=2`; attachment keyed by the full URL including `?v=2`.
+- **Failure resilience** — 3 rows at a 404 URL → each returns `null`, no cache write,
+  products created without images, one always-on failure line per row.
+- **Backwards-compat** — a pre-1.6.1 attachment (no meta) is **not** picked up;
+  re-importing its URL re-sideloads. Documented limitation; verify no crash.
+- **Filter-leak regression** — run a product import, then immediately upload an image
+  through the normal WP Media Library in the same admin session; confirm intermediate
+  sizes are generated (the filter is now removed in `finally`).
+
+## 13. Rollback
+
+Single commit; revert to restore current behavior. No schema changes — the
+`_studiou_wcpcm_source_url` postmeta is additive, and leftover rows after a rollback are
+harmless orphan metadata.
+
+## 14. Optional follow-ups (not in this plan)
+
+- **Probe `_wc_attachment_source` on a miss** so images brought in by WooCommerce's
+  native CSV importer also dedup. Cheap, but couples to a WC-internal key; deferred.
+- **Don't set a variation image when it equals the parent's.** With dedup the variation
+  and parent already converge on one attachment ID, and a variation with no image set
+  falls back to the parent's featured image, so `set_image_id()` on variations is mostly
+  redundant. Suppressing it changes variation behavior — defer until requested.
+- **Merge existing duplicates** — an admin action that finds attachments from past
+  imports (same base filename minus the `-N` ordinal), picks a canonical ID, repoints
+  every product/variation `_thumbnail_id`, and deletes the orphans. Needs dry-run +
+  confirmation UX.
+- **Retroactively tag old attachments** — a one-time action that infers original URLs
+  for existing product images and writes `_studiou_wcpcm_source_url`, extending dedup
+  back to pre-1.6.1 imports.

+ 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)) {

+ 161 - 2
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-cat-manage-import.php

@@ -79,9 +79,168 @@ class Studiou_WC_Product_Cat_Manage_Import {
      */
     public function __construct($db) {
         $this->db = $db;
-        
-        // Register AJAX handler
+
+        // Legacy single-shot import (v1.0+). Kept for back-compat; in
+        // practice the JS now uses the batched parse+batch flow added in
+        // v1.7.0. Slated for removal after one release.
         add_action('wp_ajax_studiou_wcpcm_import', array($this, 'handle_import_ajax'));
+
+        // v1.7.0 — M1: batched category import. Same shape as the product
+        // importer: parse stages rows to disk, batch processes N at a time
+        // by offset, JS polls until processed >= total. Removes the ~60s
+        // PHP-timeout cliff on large category trees.
+        add_action('wp_ajax_studiou_wcpcm_import_parse', array($this, 'handle_import_parse_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_import_batch', array($this, 'handle_import_batch_ajax'));
+    }
+
+    /**
+     * v1.7.0 — Step 1: parse the uploaded CSV, validate headers, stage rows
+     * on disk. Returns the staging token + total count.
+     */
+    public function handle_import_parse_ajax() {
+        while (ob_get_level()) { ob_end_clean(); }
+
+        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+            wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+        }
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+        }
+        if (!isset($_FILES['import_file']) || empty($_FILES['import_file']['tmp_name'])) {
+            wp_send_json_error(array('message' => __('No file was uploaded', 'studiou-wc-product-cat-manage')));
+        }
+
+        try {
+            $file = fopen($_FILES['import_file']['tmp_name'], 'r');
+            if (!$file) {
+                throw new Exception(__('Failed to open the uploaded file', 'studiou-wc-product-cat-manage'));
+            }
+            $bom = fread($file, 3);
+            if ($bom !== "\xEF\xBB\xBF") { rewind($file); }
+            $headers = fgetcsv($file);
+            if (!$headers) {
+                fclose($file);
+                throw new Exception(__('Failed to read CSV headers', 'studiou-wc-product-cat-manage'));
+            }
+            $this->validate_headers($headers);
+
+            $header_count = count($headers);
+            $rows = array();
+            $row_number = 1;
+            while (($row = fgetcsv($file)) !== false) {
+                $row_number++;
+                if (empty(array_filter($row, function($v) { return $v !== '' && $v !== null; }))) {
+                    continue;
+                }
+                $rc = count($row);
+                if ($rc < $header_count) {
+                    $row = array_pad($row, $header_count, '');
+                } elseif ($rc > $header_count) {
+                    $row = array_slice($row, 0, $header_count);
+                }
+                $data = array();
+                foreach ($headers as $i => $h) {
+                    $data[$h] = isset($row[$i]) ? $row[$i] : '';
+                }
+                $rows[] = array('row_number' => $row_number, 'data' => $data);
+            }
+            fclose($file);
+
+            if (empty($rows)) {
+                wp_send_json_error(array('message' => __('CSV contains no data rows.', 'studiou-wc-product-cat-manage')));
+            }
+
+            $token = Studiou_WC_Product_Manage_Product_Import::stage_rows($rows, 'category');
+            if ($token === false) {
+                wp_send_json_error(array('message' => __('Failed to stage import data on disk (write error).', 'studiou-wc-product-cat-manage')));
+            }
+
+            wp_send_json_success(array(
+                'staging_key' => $token,
+                'total'       => count($rows),
+            ));
+        } catch (\Throwable $e) {
+            error_log('STUDIOU WC Category Import Parse Error: ' . $e->getMessage());
+            wp_send_json_error(array('message' => $e->getMessage()));
+        }
+    }
+
+    /**
+     * v1.7.0 — Step 2: process a batch of N rows from offset. JS polls until
+     * processed >= total.
+     */
+    public function handle_import_batch_ajax() {
+        while (ob_get_level()) { ob_end_clean(); }
+
+        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+            wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+        }
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+        }
+
+        $staging_key = isset($_POST['staging_key']) ? sanitize_text_field($_POST['staging_key']) : '';
+        $offset      = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
+        $batch_size  = 10; // category ops are cheap; 10 per batch keeps each AJAX call well under PHP timeout
+
+        if ($staging_key === '') {
+            wp_send_json_error(array('message' => __('Invalid request', 'studiou-wc-product-cat-manage')));
+        }
+
+        try {
+            $slice = Studiou_WC_Product_Manage_Product_Import::read_stage_slice($staging_key, $offset, $batch_size);
+            if ($slice === null) {
+                wp_send_json_error(array('message' => __('Import data not found or expired', 'studiou-wc-product-cat-manage')));
+            }
+
+            $messages = array();
+            $success = 0;
+            $errors = 0;
+
+            foreach ($slice['batch'] as $item) {
+                $row_number = $item['row_number'];
+                $data = $item['data'];
+                try {
+                    $this->validate_row_data($data, $row_number);
+                    $operation = strtoupper(trim($data['operation']));
+                    switch ($operation) {
+                        case 'A': $r = $this->db->create_category($data); break;
+                        case 'U': $r = $this->db->update_category($data); break;
+                        case 'D': $r = $this->db->delete_category($data['name']); break;
+                        default:
+                            throw new Exception(sprintf(__('Invalid operation "%s" at row %d', 'studiou-wc-product-cat-manage'), $operation, $row_number));
+                    }
+                    if (isset($r['status']) && $r['status'] === 'error') {
+                        $errors++;
+                        $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, $r['message']);
+                    } else {
+                        $success++;
+                        $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, isset($r['message']) ? $r['message'] : __('OK', 'studiou-wc-product-cat-manage'));
+                    }
+                } catch (\Throwable $row_e) {
+                    $errors++;
+                    $messages[] = sprintf(__('Row %d: %s', 'studiou-wc-product-cat-manage'), $row_number, $row_e->getMessage());
+                }
+            }
+
+            $processed = $offset + count($slice['batch']);
+            $is_final = $processed >= $slice['total'];
+            if ($is_final) {
+                Studiou_WC_Product_Manage_Product_Import::delete_stage($staging_key);
+            }
+
+            wp_send_json_success(array(
+                'processed' => $processed,
+                'total'     => $slice['total'],
+                'success'   => $success,
+                'errors'    => $errors,
+                'messages'  => $messages,
+                'done'      => $is_final,
+            ));
+        } catch (\Throwable $e) {
+            error_log('STUDIOU WC Category Import Batch Error: ' . $e->getMessage());
+            wp_send_json_error(array('message' => $e->getMessage()));
+        }
     }
     
     /**

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

@@ -32,7 +32,15 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
         $this->db = $db;
         
         // Register AJAX handlers
+        // Legacy single-shot move (v1.0+). Kept for back-compat; JS now uses
+        // the batched flow added in v1.7.0.
         add_action('wp_ajax_studiou_wcpcm_move_products', array($this, 'handle_move_products_ajax'));
+        // v1.7.0 — M2: batched product move. Mirrors the delete-products
+        // pattern: start returns the list of product IDs in the source
+        // category; chunk processes 25 IDs per AJAX call. Removes the
+        // single-shot timeout on large source categories.
+        add_action('wp_ajax_studiou_wcpcm_move_products_start', array($this, 'handle_move_products_start_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_move_products_chunk', array($this, 'handle_move_products_chunk_ajax'));
         add_action('wp_ajax_studiou_wcpcm_batch_description', array($this, 'handle_batch_description_ajax'));
         add_action('wp_ajax_studiou_wcpcm_delete_products', array($this, 'handle_delete_products_ajax'));
         add_action('wp_ajax_studiou_wcpcm_delete_products_batch', array($this, 'handle_delete_products_batch_ajax'));
@@ -360,6 +368,99 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
         }
     }
     
+    /**
+     * v1.7.0 — M2: batched product move, step 1 (start).
+     *
+     * Returns the list of product IDs in the source category. JS chunks
+     * them and calls handle_move_products_chunk_ajax in batches.
+     */
+    public function handle_move_products_start_ajax() {
+        while (ob_get_level()) { ob_end_clean(); }
+
+        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+            wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+        }
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+        }
+
+        $source = isset($_POST['source_category']) ? intval($_POST['source_category']) : 0;
+        $target = isset($_POST['target_category']) ? intval($_POST['target_category']) : 0;
+
+        if ($source <= 0 || $target <= 0) {
+            wp_send_json_error(array('message' => __('Invalid category selection', 'studiou-wc-product-cat-manage')));
+        }
+        if ($source === $target) {
+            wp_send_json_error(array('message' => __('Source and target categories must be different', 'studiou-wc-product-cat-manage')));
+        }
+
+        $source_term = get_term($source, 'product_cat');
+        $target_term = get_term($target, 'product_cat');
+        if (is_wp_error($source_term) || !$source_term || is_wp_error($target_term) || !$target_term) {
+            wp_send_json_error(array('message' => __('Invalid category specified', 'studiou-wc-product-cat-manage')));
+        }
+
+        $product_ids = $this->get_products_in_category($source);
+        wp_send_json_success(array(
+            'product_ids' => array_values(array_map('intval', $product_ids)),
+            'total'       => count($product_ids),
+            'source_name' => $source_term->name,
+            'target_name' => $target_term->name,
+        ));
+    }
+
+    /**
+     * v1.7.0 — M2: batched product move, step 2 (chunk).
+     *
+     * Moves the supplied product IDs from source to target category.
+     * wp_set_post_terms is idempotent, so a re-run of an already-moved
+     * chunk (e.g. after a network retry) is safe.
+     */
+    public function handle_move_products_chunk_ajax() {
+        while (ob_get_level()) { ob_end_clean(); }
+
+        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+            wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+        }
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+        }
+
+        $source = isset($_POST['source_category']) ? intval($_POST['source_category']) : 0;
+        $target = isset($_POST['target_category']) ? intval($_POST['target_category']) : 0;
+        $ids    = isset($_POST['product_ids']) && is_array($_POST['product_ids'])
+                    ? array_values(array_map('intval', $_POST['product_ids']))
+                    : array();
+
+        if ($source <= 0 || $target <= 0 || $source === $target || empty($ids)) {
+            wp_send_json_error(array('message' => __('Invalid request', 'studiou-wc-product-cat-manage')));
+        }
+
+        $moved = 0;
+        $failed = 0;
+        foreach ($ids as $pid) {
+            $current = wp_get_post_terms($pid, 'product_cat', array('fields' => 'ids'));
+            if (is_wp_error($current)) {
+                $failed++;
+                continue;
+            }
+            $new = array_diff($current, array($source));
+            $new[] = $target;
+            $new = array_values(array_unique($new));
+            $r = wp_set_post_terms($pid, $new, 'product_cat');
+            if (is_wp_error($r)) {
+                $failed++;
+            } else {
+                $moved++;
+            }
+        }
+
+        wp_send_json_success(array(
+            'moved'  => $moved,
+            'failed' => $failed,
+        ));
+    }
+
     /**
      * Move all products from source category to target category
      *
@@ -653,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),
                 );
             }
         }
@@ -1507,6 +1624,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);
@@ -1985,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 {
@@ -2000,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++;
@@ -2039,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,
         );
     }
 

+ 138 - 17
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-export.php

@@ -28,6 +28,101 @@ class Studiou_WC_Product_Manage_Product_Export {
      */
     public function __construct($db) {
         $this->db = $db;
+        // v1.6.4 — gated download endpoint, mirroring the category export.
+        add_action('admin_init', array($this, 'handle_export_download'));
+    }
+
+    /**
+     * Return (and lazily create) the guarded export directory.
+     *
+     * @return string Absolute path with trailing slash.
+     */
+    private function get_export_dir() {
+        $upload_dir = wp_upload_dir();
+        $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-product-exports/';
+        if (!file_exists($dir)) {
+            wp_mkdir_p($dir);
+        }
+        // Guard against direct access. Cheap idempotent writes.
+        $idx = $dir . 'index.php';
+        if (!file_exists($idx)) {
+            @file_put_contents($idx, '<?php // Silence is golden.');
+        }
+        $ht = $dir . '.htaccess';
+        if (!file_exists($ht)) {
+            @file_put_contents($ht, "Deny from all\n");
+        }
+        return $dir;
+    }
+
+    /**
+     * Delete export files older than 24 hours. Called on every new export.
+     */
+    private function prune_old_exports() {
+        $dir = $this->get_export_dir();
+        $cutoff = time() - DAY_IN_SECONDS;
+        $files = glob($dir . 'product-export-*.csv');
+        if (!is_array($files)) { return; }
+        foreach ($files as $f) {
+            $mtime = @filemtime($f);
+            if ($mtime !== false && $mtime < $cutoff) {
+                @unlink($f);
+            }
+        }
+    }
+
+    /**
+     * v1.6.4 — gated CSV download.
+     *
+     * Hooked on admin_init. Verifies nonce + manage_woocommerce capability +
+     * that the requested file is a bare basename inside the export dir
+     * (defends against path traversal), then streams via readfile().
+     */
+    public function handle_export_download() {
+        if (!isset($_GET['page']) || $_GET['page'] !== 'studiou-product-export') {
+            return;
+        }
+        if (!isset($_GET['action']) || $_GET['action'] !== 'studiou_wcpcm_download_product_export') {
+            return;
+        }
+        if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'studiou-wcpcm-product-export-download')) {
+            return;
+        }
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(__('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'));
+        }
+
+        $requested = isset($_GET['file']) ? (string) $_GET['file'] : '';
+        // Bare basename only — no slashes, no .., no directory components.
+        if ($requested === '' || $requested !== basename($requested) || strpos($requested, '..') !== false) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+        // Only our own filename shape: product-export-<32 hex>.csv
+        if (!preg_match('/^product-export-[a-f0-9]{32}\.csv$/', $requested)) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+
+        $dir = $this->get_export_dir();
+        $path = $dir . $requested;
+        if (!file_exists($path)) {
+            wp_die(__('Export file not found. Please try exporting again.', 'studiou-wc-product-cat-manage'));
+        }
+
+        // Belt-and-braces realpath check to prevent symlink escape.
+        $real_dir = realpath($dir);
+        $real_path = realpath($path);
+        if ($real_dir === false || $real_path === false || strpos($real_path, $real_dir) !== 0) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+
+        header('Content-Type: text/csv; charset=utf-8');
+        header('Content-Disposition: attachment; filename=product-export-' . date('Y-m-d-H-i-s') . '.csv');
+        header('Content-Length: ' . filesize($real_path));
+        header('Pragma: no-cache');
+        header('Expires: 0');
+        readfile($real_path);
+        exit;
     }
 
     /**
@@ -36,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)) {
@@ -55,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);
@@ -68,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;
@@ -243,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(
@@ -252,20 +362,20 @@ class Studiou_WC_Product_Manage_Product_Export {
             );
         }
 
-        // Create uploads directory if it doesn't exist
-        $upload_dir = wp_upload_dir();
-        $export_dir = $upload_dir['basedir'] . '/studiou-wc-product-exports';
-
-        if (!file_exists($export_dir)) {
-            wp_mkdir_p($export_dir);
-        }
+        // v1.6.4 — write into the guarded export dir and prune anything older
+        // than 24 hours. Filename gets a 32-char random token so the file is
+        // unguessable even if directory listing leaks.
+        $export_dir = $this->get_export_dir();
+        $this->prune_old_exports();
 
-        // Generate filename with timestamp
-        $filename = 'product-export-' . date('Y-m-d-H-i-s') . '.csv';
-        $file_path = $export_dir . '/' . $filename;
+        $token = bin2hex(random_bytes(16)); // 32 hex chars
+        $filename = 'product-export-' . $token . '.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(
@@ -274,14 +384,25 @@ class Studiou_WC_Product_Manage_Product_Export {
             );
         }
 
-        // Generate download URL
-        $download_url = $upload_dir['baseurl'] . '/studiou-wc-product-exports/' . $filename;
+        // Return an admin URL routed through handle_export_download(),
+        // which checks the nonce + capability + basename. The raw public
+        // uploads URL is no longer exposed.
+        $download_url = add_query_arg(
+            array(
+                'page'     => 'studiou-product-export',
+                'action'   => 'studiou_wcpcm_download_product_export',
+                'file'     => $filename,
+                '_wpnonce' => wp_create_nonce('studiou-wcpcm-product-export-download'),
+            ),
+            admin_url('edit.php?post_type=product')
+        );
 
         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
+                $row_count
             ),
             'download_url' => $download_url
         );

+ 514 - 99
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-import.php

@@ -21,6 +21,14 @@ class Studiou_WC_Product_Manage_Product_Import {
      */
     private $db;
 
+    /**
+     * In-request cache of trimmed source URL => attachment ID, so the same URL
+     * appearing multiple times within one AJAX batch costs at most one DB lookup.
+     *
+     * @var array<string,int>
+     */
+    private static $url_cache = array();
+
     /**
      * Constructor
      *
@@ -30,6 +38,102 @@ class Studiou_WC_Product_Manage_Product_Import {
         $this->db = $db;
     }
 
+    /**
+     * v1.7.0 — Return (and lazily create) the guarded staging directory used
+     * by both the product and category importers. Replaces the transient-
+     * based staging that capped at max_allowed_packet (M4).
+     *
+     * @return string Absolute path with trailing slash.
+     */
+    public static function get_staging_dir() {
+        $upload_dir = wp_upload_dir();
+        $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-staging/';
+        if (!file_exists($dir)) {
+            wp_mkdir_p($dir);
+        }
+        $idx = $dir . 'index.php';
+        if (!file_exists($idx)) {
+            @file_put_contents($idx, '<?php // Silence is golden.');
+        }
+        $ht = $dir . '.htaccess';
+        if (!file_exists($ht)) {
+            @file_put_contents($ht, "Deny from all\n");
+        }
+        return $dir;
+    }
+
+    /**
+     * v1.7.0 — Prune staging files older than 24h.
+     */
+    public static function prune_staging() {
+        $dir = self::get_staging_dir();
+        $cutoff = time() - DAY_IN_SECONDS;
+        $files = glob($dir . '*.json');
+        if (!is_array($files)) { return; }
+        foreach ($files as $f) {
+            $mtime = @filemtime($f);
+            if ($mtime !== false && $mtime < $cutoff) {
+                @unlink($f);
+            }
+        }
+    }
+
+    /**
+     * v1.7.0 — Stage an array of rows to a token-named JSON file. Returns
+     * the token, or false on write failure.
+     *
+     * @param array $rows
+     * @param string $prefix e.g. 'product' or 'category'
+     * @return string|false
+     */
+    public static function stage_rows($rows, $prefix = 'product') {
+        self::prune_staging();
+        $token = $prefix . '-' . bin2hex(random_bytes(16));
+        $path = self::get_staging_dir() . $token . '.json';
+        $encoded = wp_json_encode($rows);
+        if ($encoded === false || file_put_contents($path, $encoded) === false) {
+            return false;
+        }
+        return $token;
+    }
+
+    /**
+     * v1.7.0 — Read a slice of a staged file.
+     *
+     * @param string $token
+     * @param int $offset
+     * @param int $batch_size
+     * @return array|null array{total:int,batch:array} or null if missing/unreadable
+     */
+    public static function read_stage_slice($token, $offset, $batch_size) {
+        // Validate the token shape (defends against path traversal).
+        if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) {
+            return null;
+        }
+        $path = self::get_staging_dir() . $token . '.json';
+        if (!file_exists($path)) { return null; }
+        $raw = file_get_contents($path);
+        if ($raw === false) { return null; }
+        $rows = json_decode($raw, true);
+        if (!is_array($rows)) { return null; }
+        return array(
+            'total' => count($rows),
+            'batch' => array_slice($rows, $offset, $batch_size),
+        );
+    }
+
+    /**
+     * v1.7.0 — Delete a staged file (e.g. on completion).
+     *
+     * @param string $token
+     */
+    public static function delete_stage($token) {
+        if (!preg_match('/^(product|category)-[a-f0-9]{32}$/', $token)) {
+            return;
+        }
+        @unlink(self::get_staging_dir() . $token . '.json');
+    }
+
     /**
      * Parse CSV file and store data for batch processing
      *
@@ -47,6 +151,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) {
@@ -54,8 +166,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++;
@@ -65,6 +180,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(
@@ -75,13 +216,34 @@ class Studiou_WC_Product_Manage_Product_Import {
 
         fclose($handle);
 
-        // Store data in transient (expires in 1 hour)
-        $transient_key = 'studiou_wcpcm_import_' . uniqid();
-        set_transient($transient_key, $rows, 3600);
+        // 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.
+        // v1.7.0 — M4 rework: stage rows on disk instead of in a transient.
+        // The transient was a single wp_options row; large CSVs overflowed
+        // max_allowed_packet and set_transient returned false, surfacing as
+        // the misleading "Import data not found or expired" downstream.
+        // Disk staging has no such ceiling. The staging file is guarded
+        // (index.php + .htaccess), token-named, and pruned at 24h.
+        $payload = array(
+            'rows'    => $rows,
+            'skipped' => $skipped_rows,
+        );
+        $token = self::stage_rows($payload, 'product');
+        if ($token === false) {
+            return array('error' => __('Failed to stage import data on disk (write error).', 'studiou-wc-product-cat-manage'));
+        }
 
         return array(
-            'transient_key' => $transient_key,
-            'total' => count($rows)
+            // Legacy field name preserved so the JS keeps working without
+            // changes; the value is now a disk-staging token, not a
+            // transient key. process_batch reads from disk via the token.
+            'transient_key' => $token,
+            'total'         => count($rows),
         );
     }
 
@@ -94,11 +256,26 @@ class Studiou_WC_Product_Manage_Product_Import {
      * @return array Result with statistics
      */
     public function process_batch($transient_key, $offset = 0, $batch_size = 5) {
-        $rows = get_transient($transient_key);
-
-        if (!$rows) {
+        // v1.7.0 — read from disk-staging instead of the WP transient.
+        // First load the full payload so we know total + skipped rows.
+        $dir = self::get_staging_dir();
+        if (!preg_match('/^product-[a-f0-9]{32}$/', $transient_key)) {
+            return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
+        }
+        $path = $dir . $transient_key . '.json';
+        if (!file_exists($path)) {
             return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
         }
+        $raw = file_get_contents($path);
+        if ($raw === false) {
+            return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
+        }
+        $payload = json_decode($raw, true);
+        if (!is_array($payload) || !isset($payload['rows'])) {
+            return array('error' => __('Import data not found or expired', 'studiou-wc-product-cat-manage'));
+        }
+        $rows = $payload['rows'];
+        $skipped_at_parse = isset($payload['skipped']) ? $payload['skipped'] : array();
 
         $result = array(
             'success' => 0,
@@ -113,6 +290,18 @@ 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 && !empty($skipped_at_parse)) {
+            foreach ($skipped_at_parse as $skip) {
+                $result['skipped']++;
+                $result['messages'][] = $skip['reason'];
+                $result['failed_items'][] = $skip['data'];
+            }
+        }
+
         foreach ($batch as $item) {
             $row_number = $item['row_number'];
             $data = $item['data'];
@@ -146,6 +335,14 @@ class Studiou_WC_Product_Manage_Product_Import {
 
         $result['processed'] = $offset + count($batch);
 
+        // v1.7.0 — delete the staging file when the import is complete so
+        // we don't leak token-named JSON files into uploads/. (Pruning at
+        // 24h would catch them eventually, but cleaning at completion is
+        // the precise time.)
+        if ($is_final_batch) {
+            self::delete_stage($transient_key);
+        }
+
         return $result;
     }
 
@@ -250,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)) {
@@ -264,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());
             }
 
@@ -295,26 +505,88 @@ class Studiou_WC_Product_Manage_Product_Import {
             $reviews_enabled = isset($data['Povolit zákaznické recenze?']) && $data['Povolit zákaznické recenze?'] === '1';
             $product->set_reviews_allowed($reviews_enabled);
 
-            // Handle product attribute
+            // v1.6.3 — Handle product attribute (taxonomy vs. custom).
+            //
+            // Pre-1.6.3 behaviour ignored the `Vlastnost 1 globální` column and
+            // forced every attribute into a global pa_ taxonomy: it called
+            // create_attribute_if_not_exists() unconditionally, set the
+            // attribute name via wc_attribute_taxonomy_name(), and passed raw
+            // name strings to set_options(). Result: custom (product-level)
+            // attributes silently became global taxonomies; for variations,
+            // raw names landed where slugs were required, so the storefront
+            // couldn't match the customer's selection ("No matching variation").
+            //
+            // Branch on the global flag and, for the taxonomy path, resolve
+            // each value to a term (creating it if missing), pass term IDs to
+            // set_options(), remember the slugs so process_variation() can
+            // store the correct value, and link the terms via
+            // wp_set_object_terms after the product save.
+            //
+            // Hand-built CSVs that omit `Vlastnost 1 globální` default to the
+            // custom branch.
+            //
+            // LIVE-TEST GATE: §5.B.0 of review-00-plan-00.md asks for a real
+            // storefront round-trip before deploy. Verify both the taxonomy
+            // and custom paths there.
+            $term_ids_for_link = array();
+            $taxonomy_for_link = '';
             if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti'])) {
-                $attribute_name = $data['Název 1 vlastnosti'];
+                $attribute_name = trim($data['Název 1 vlastnosti']);
                 $attribute_values = isset($data['Hodnota(y) 1 vlastnosti']) ? $data['Hodnota(y) 1 vlastnosti'] : '';
                 $is_visible = isset($data['Vlastnost 1 viditelnost']) && $data['Vlastnost 1 viditelnost'] === '1';
                 $is_variation = isset($data['Vlastnost 1 varianta']) && $data['Vlastnost 1 varianta'] === '1';
-
-                // Create attribute taxonomy if it doesn't exist
-                $this->db->create_attribute_if_not_exists($attribute_name);
+                $is_global    = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1';
+                $values = array_map('trim', explode('|', $attribute_values));
+                $values = array_values(array_filter($values, function($v) { return $v !== ''; }));
 
                 $attribute = new WC_Product_Attribute();
-                $attribute->set_name(wc_attribute_taxonomy_name($attribute_name));
-                $attribute->set_visible($is_visible);
-                $attribute->set_variation($is_variation);
 
-                // Set attribute options (terms)
-                $values = array_map('trim', explode('|', $attribute_values));
-                $attribute->set_options($values);
+                if ($is_global) {
+                    // Taxonomy attribute — register, resolve/create terms,
+                    // pass term IDs (not names) to set_options.
+                    $this->db->create_attribute_if_not_exists($attribute_name);
+                    $taxonomy = wc_attribute_taxonomy_name($attribute_name);
+                    if (!taxonomy_exists($taxonomy)) {
+                        register_taxonomy($taxonomy, 'product');
+                    }
 
-                $product->set_attributes(array($attribute));
+                    $term_ids = array();
+                    foreach ($values as $value) {
+                        $term = get_term_by('name', $value, $taxonomy);
+                        if (!$term) {
+                            $inserted = wp_insert_term($value, $taxonomy);
+                            if (is_wp_error($inserted)) {
+                                error_log('STUDIOU WC IMPORT: failed to insert term "' . $value . '" into ' . $taxonomy . ': ' . $inserted->get_error_message());
+                                continue;
+                            }
+                            $term = get_term($inserted['term_id'], $taxonomy);
+                        }
+                        if ($term && !is_wp_error($term)) {
+                            $term_ids[] = (int) $term->term_id;
+                        }
+                    }
+
+                    $attribute->set_id(wc_attribute_taxonomy_id_by_name($attribute_name));
+                    $attribute->set_name($taxonomy);
+                    $attribute->set_options($term_ids);
+                    $attribute->set_visible($is_visible);
+                    $attribute->set_variation($is_variation);
+                    $product->set_attributes(array($attribute));
+
+                    // Defer term-relationship linkage until after save() so we
+                    // have a real product ID.
+                    $term_ids_for_link = $term_ids;
+                    $taxonomy_for_link = $taxonomy;
+                } else {
+                    // Custom (product-level) attribute — no taxonomy, store
+                    // the raw display name and the raw values.
+                    $attribute->set_id(0);
+                    $attribute->set_name($attribute_name);
+                    $attribute->set_options($values);
+                    $attribute->set_visible($is_visible);
+                    $attribute->set_variation($is_variation);
+                    $product->set_attributes(array($attribute));
+                }
             }
 
             // Handle minimum/maximum quantity
@@ -328,6 +600,14 @@ class Studiou_WC_Product_Manage_Product_Import {
             // Save product
             $saved_id = $product->save();
 
+            // Belt-and-braces: link the attribute terms to the freshly-saved
+            // product. Modern WC CRUD typically does this on save() when
+            // set_options() receives term IDs, but the link is cheap and
+            // makes the import robust against WC internals changes.
+            if (!empty($term_ids_for_link) && $taxonomy_for_link !== '' && $saved_id) {
+                wp_set_object_terms($saved_id, $term_ids_for_link, $taxonomy_for_link, false);
+            }
+
             $message = $product_id == 0 ?
                 sprintf(__('Created variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name()) :
                 sprintf(__('Updated variable product: %s', 'studiou-wc-product-cat-manage'), $product->get_name());
@@ -415,16 +695,44 @@ class Studiou_WC_Product_Manage_Product_Import {
                 }
             }
 
-            // Set variation attributes
+            // v1.6.3 — Variation attribute (taxonomy vs. custom).
+            //
+            // Pre-1.6.3 stored the raw value (e.g. "Red") under the taxonomy
+            // meta key (e.g. attribute_pa_color). WC matches variations by
+            // term slug ("red"), so the variation was unreachable on the
+            // storefront. Now: for taxonomy attributes resolve the value to
+            // its term slug; for custom attributes store the literal value
+            // under attribute_<sanitized name>.
             if (isset($data['Název 1 vlastnosti']) && !empty($data['Název 1 vlastnosti']) &&
                 isset($data['Hodnota(y) 1 vlastnosti']) && !empty($data['Hodnota(y) 1 vlastnosti'])) {
 
-                $attribute_name = wc_attribute_taxonomy_name($data['Název 1 vlastnosti']);
-                $attribute_value = $data['Hodnota(y) 1 vlastnosti'];
-
-                $variation->set_attributes(array(
-                    $attribute_name => $attribute_value
-                ));
+                $attribute_name = trim($data['Název 1 vlastnosti']);
+                $attribute_value = trim($data['Hodnota(y) 1 vlastnosti']);
+                $is_global = isset($data['Vlastnost 1 globální']) && $data['Vlastnost 1 globální'] === '1';
+
+                if ($is_global) {
+                    $taxonomy = wc_attribute_taxonomy_name($attribute_name);
+                    $term = get_term_by('name', $attribute_value, $taxonomy);
+                    if (!$term) {
+                        // The variation row may arrive before its parent's
+                        // taxonomy terms exist (rare but possible on hand-built
+                        // CSVs). Create it so the variation is selectable.
+                        if (!taxonomy_exists($taxonomy)) {
+                            $this->db->create_attribute_if_not_exists($attribute_name);
+                            register_taxonomy($taxonomy, 'product');
+                        }
+                        $inserted = wp_insert_term($attribute_value, $taxonomy);
+                        if (!is_wp_error($inserted)) {
+                            $term = get_term($inserted['term_id'], $taxonomy);
+                        }
+                    }
+                    $slug = ($term && !is_wp_error($term)) ? $term->slug : sanitize_title($attribute_value);
+                    $variation->set_attributes(array($taxonomy => $slug));
+                } else {
+                    $variation->set_attributes(array(
+                        sanitize_title($attribute_name) => $attribute_value,
+                    ));
+                }
             }
 
             // Handle minimum/maximum quantity
@@ -456,137 +764,244 @@ class Studiou_WC_Product_Manage_Product_Import {
     }
 
     /**
-     * Upload image from URL
+     * Find an attachment previously imported from this exact source URL.
      *
-     * @param string $url Image URL
-     * @return int|null Attachment ID or null on failure
+     * Uses get_posts (not a raw $wpdb query) so a row whose attachment post was
+     * deleted but whose meta was somehow orphaned can't resolve to a missing post.
+     * Orders by ID ascending so the earliest (canonical) attachment wins
+     * deterministically if more than one ever carries the same source URL.
+     *
+     * @param string $url Trimmed source URL.
+     * @return int Attachment ID, or 0 if none.
+     */
+    private function find_attachment_by_source_url($url) {
+        $ids = get_posts(array(
+            'post_type'              => 'attachment',
+            'post_status'            => 'inherit',
+            'posts_per_page'         => 1,
+            'orderby'                => 'ID',
+            'order'                  => 'ASC',
+            'fields'                 => 'ids',
+            'no_found_rows'          => true,
+            'update_post_meta_cache' => false,
+            'update_post_term_cache' => false,
+            'meta_query'             => array(
+                array(
+                    'key'     => '_studiou_wcpcm_source_url',
+                    'value'   => $url,
+                    'compare' => '=',
+                ),
+            ),
+        ));
+
+        return !empty($ids) ? (int) $ids[0] : 0;
+    }
+
+    /**
+     * Upload an image from a URL, deduplicating by source URL.
+     *
+     * A given source URL is downloaded at most once: an in-request static cache
+     * absorbs repeats within a batch, and a `_studiou_wcpcm_source_url` postmeta
+     * lookup reuses attachments across batches and across imports.
+     *
+     * @param string $url Image URL (raw value from the Obrázky column).
+     * @return int|null Attachment ID, or null on empty input / download failure.
      */
     private function upload_image_from_url($url) {
+        // Normalize the key. process_product_row() validates a *trimmed* URL but
+        // the callers pass the raw cell, so trim here to keep the cache key, the
+        // DB lookup, the download, and the stored postmeta all identical to the
+        // value that was validated (and to collapse whitespace-only variants).
+        $url = is_string($url) ? trim($url) : '';
+        if ($url === '') {
+            return null;
+        }
+
+        // Layer 2 — in-request static cache.
+        if (isset(self::$url_cache[$url])) {
+            if (defined('WP_DEBUG') && WP_DEBUG) {
+                error_log('STUDIOU WC IMPORT: image cache HIT (request) ' . $url . ' -> ' . self::$url_cache[$url]);
+            }
+            return self::$url_cache[$url];
+        }
+
+        // Layer 1 — persistent postmeta lookup (across batches and re-imports).
+        $existing = $this->find_attachment_by_source_url($url);
+        if ($existing) {
+            self::$url_cache[$url] = $existing;
+            if (defined('WP_DEBUG') && WP_DEBUG) {
+                error_log('STUDIOU WC IMPORT: image cache HIT (db) ' . $url . ' -> ' . $existing);
+            }
+            return $existing;
+        }
+
+        // Layer 0 — fresh sideload.
         require_once(ABSPATH . 'wp-admin/includes/media.php');
         require_once(ABSPATH . 'wp-admin/includes/file.php');
         require_once(ABSPATH . 'wp-admin/includes/image.php');
 
-        // Set shorter timeout for image download (30 seconds)
-        add_filter('http_request_timeout', function() { return 30; });
+        // Suppress intermediate sizes for import speed. Use the stable
+        // '__return_empty_array' callable (not a fresh closure) so remove_filter()
+        // can actually match it, and detach it in finally so it can never leak
+        // into later media operations in this request.
+        add_filter('intermediate_image_sizes_advanced', '__return_empty_array');
 
         try {
+            // download_url()'s second arg sets the request timeout (30s); no
+            // http_request_timeout filter is needed.
             $tmp = download_url($url, 30);
-
             if (is_wp_error($tmp)) {
-                if (defined('WP_DEBUG') && WP_DEBUG) {
-                    error_log('STUDIOU WC: Image download failed for URL: ' . $url . ' - ' . $tmp->get_error_message());
-                }
+                error_log('STUDIOU WC IMPORT: image download failed for ' . $url . ' - ' . $tmp->get_error_message());
                 return null;
             }
 
+            // Derive a clean on-disk filename. parse_url() strips any query string
+            // (img.jpg?v=2 -> img.jpg) so wp_unique_filename() doesn't fold the
+            // query into the basename. Null-safe: parse_url() may return null/false
+            // and basename(null) is deprecated on PHP 8.1+ (we require 8.2).
+            $path = parse_url($url, PHP_URL_PATH);
+            $name = (is_string($path) && $path !== '') ? basename($path) : '';
+            if ($name === '') {
+                $name = 'image-' . md5($url) . '.jpg';
+            }
+
             $file_array = array(
-                'name' => basename($url),
-                'tmp_name' => $tmp
+                'name'     => $name,
+                'tmp_name' => $tmp,
             );
 
-            // Disable image processing to speed up import
-            add_filter('intermediate_image_sizes_advanced', function() { return array(); });
-
             $id = media_handle_sideload($file_array, 0);
-
-            // Re-enable image processing
-            remove_filter('intermediate_image_sizes_advanced', function() { return array(); });
-
             if (is_wp_error($id)) {
-                @unlink($file_array['tmp_name']);
-                if (defined('WP_DEBUG') && WP_DEBUG) {
-                    error_log('STUDIOU WC: Image upload failed for URL: ' . $url . ' - ' . $id->get_error_message());
-                }
+                @unlink($tmp);
+                error_log('STUDIOU WC IMPORT: image sideload failed for ' . $url . ' - ' . $id->get_error_message());
                 return null;
             }
 
-            return $id;
+            // Tag the attachment so future rows / batches / imports dedup on it.
+            update_post_meta($id, '_studiou_wcpcm_source_url', $url);
 
-        } catch (Exception $e) {
+            self::$url_cache[$url] = (int) $id;
             if (defined('WP_DEBUG') && WP_DEBUG) {
-                error_log('STUDIOU WC: Image upload exception for URL: ' . $url . ' - ' . $e->getMessage());
+                error_log('STUDIOU WC IMPORT: image sideloaded ' . $url . ' -> ' . $id);
             }
+            return (int) $id;
+
+        } catch (Exception $e) {
+            error_log('STUDIOU WC IMPORT: image upload exception for ' . $url . ' - ' . $e->getMessage());
             return null;
+        } finally {
+            remove_filter('intermediate_image_sizes_advanced', '__return_empty_array');
         }
     }
 
     /**
-     * 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.
      *
-     * @param string $category_name Category name
-     * @return int|null Category ID or null if not found
+     * @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
+     * @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;
     }
 
     /**

二进制
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage-cs_CZ.mo


+ 87 - 8
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the same license as the QDR - Studiou WC Export/Import Product Categories package.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.3\n"
+"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.7.1\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-product-"
 "cat-manage\n"
 "POT-Creation-Date: 2026-04-01 12:00+0000\n"
@@ -973,13 +973,13 @@ msgstr "Nebyly nalezeny žádné nepoužívané nekategorizované mediální sou
 msgid "Remove Not Assigned Media"
 msgstr "Odstranit nepřiřazená média"
 
-#: views/manipulations.php
-msgid "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."
-msgstr "Tato operace trvale smaže všechny mediální soubory, které nejsou přiřazeny k žádné kategorii médií a nejsou použity jako náhledový obrázek, obrázek galerie produktu nebo připojeny k žádnému příspěvku."
+#: views/manipulations/tab-remove-unassigned.php
+msgid "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."
+msgstr "Tato operace trvale smaže mediální soubory, které nejsou přiřazeny k žádné kategorii médií a nejsou detekovány jako používané. Detekce zahrnuje: náhledové obrázky příspěvků/stránek/produktů, obrázky galerie produktů WooCommerce, připojená média (post_parent), členství v kategorii médií a — od verze 1.6.2 — obrázky kategorií produktů WooCommerce (termmeta), obrázek vlastního loga, ikonu webu a obrázek záhlaví."
 
-#: views/manipulations.php
-msgid "WARNING: This operation will permanently delete all unused uncategorized media files from the server. This action cannot be undone!"
-msgstr "VAROVÁNÍ: Tato operace trvale smaže všechny nepoužívané nekategorizované mediální soubory ze serveru. Tuto akci nelze vrátit zpět!"
+#: views/manipulations/tab-remove-unassigned.php
+msgid "WARNING: This action cannot be undone."
+msgstr "VAROVÁNÍ: Tuto akci nelze vrátit zpět."
 
 #: views/manipulations.php
 msgid "Delete unused uncategorized media (%d)"
@@ -1104,4 +1104,83 @@ msgstr "Neznámá záložka"
 
 #: includes/class-studiou-wc-product-cat-manage-manipulator.php
 msgid "Tab content not found"
-msgstr "Obsah záložky nenalezen"
+msgstr "Obsah záložky nenalezen"
+
+# v1.6.2 — M6: nové varovné odrážky vyjmenovávající, co NENÍ detekováno.
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Detection is heuristic. The following references are NOT detected and the media WILL be deleted if it is only used this way:"
+msgstr "Detekce je heuristická. Následující odkazy NEJSOU detekovány a média BUDOU smazána, pokud jsou používána pouze tímto způsobem:"
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Images embedded only in post / page / product content HTML (including page builders that store HTML)."
+msgstr "Obrázky vložené pouze v HTML obsahu příspěvků / stránek / produktů (včetně page builderů, které ukládají HTML)."
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Images referenced from ACF, custom postmeta, custom termmeta keys other than \"thumbnail_id\", or theme options."
+msgstr "Obrázky odkazované z ACF, vlastního postmeta, vlastních klíčů termmeta jiných než \"thumbnail_id\" nebo z nastavení šablony."
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Files attached to WooCommerce downloadable products."
+msgstr "Soubory připojené k stahovatelným produktům WooCommerce."
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Treat this button as: \"delete media that nothing I can detect is using.\" If unsure, back up the uploads folder first."
+msgstr "Berte toto tlačítko jako: „smazat média, o kterých nedokážu zjistit, že je něco používá.“ Pokud si nejste jisti, nejprve si zazálohujte složku uploads."
+
+# v1.6.2 — H4: chyba šířky řádku se nyní objeví ve failed-CSV souboru.
+#: includes/class-studiou-wc-product-manage-product-import.php
+msgid "Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped."
+msgstr "Řádek %1$d: neshoda počtu sloupců (nalezeno %2$d, očekáváno %3$d) — pravděpodobně neuvozená čárka. Řádek byl přeskočen."
+
+# v1.6.2 — M4 guard: srozumitelná chyba při přetečení transient (od 1.7.0 nahrazeno disk-staging, ponecháno pro zpětnou kompatibilitu).
+#: includes/class-studiou-wc-product-manage-product-import.php
+msgid "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."
+msgstr "Import je příliš velký pro přípravu (%d řádků) — zpracované CSV se nevejde do úložiště WordPress transient. Rozdělte soubor do menších dávek, nebo počkejte na streamovací importér v budoucí verzi."
+
+# v1.6.4 — H5: gated download handler product-exportu.
+#: includes/class-studiou-wc-product-manage-product-export.php
+msgid "Invalid file"
+msgstr "Neplatný soubor"
+
+# v1.7.0 — M1 / M4 rework: disk-staging a dávkovaný import kategorií.
+#: includes/class-studiou-wc-product-cat-manage-import.php includes/class-studiou-wc-product-manage-product-import.php
+msgid "Failed to stage import data on disk (write error)."
+msgstr "Nepodařilo se připravit data importu na disku (chyba zápisu)."
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "CSV contains no data rows."
+msgstr "CSV neobsahuje žádné datové řádky."
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "OK"
+msgstr "OK"
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "Row %d: %s"
+msgstr "Řádek %d: %s"
+
+# v1.7.1 — L7: UPVP varuje, když existující akční cena zůstane vyšší než nová běžná cena.
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Warning: %d variations have a sale price greater than the new regular price (sale > regular). Review the variations and adjust the sale price."
+msgstr "Upozornění: %d variant má akční cenu vyšší než novou běžnou cenu (akční > běžná). Zkontrolujte tyto varianty a upravte jejich akční cenu."
+
+# v1.7.1 — L1: JS i18n klíče, které byly dříve napevno v angličtině.
+#: studiou-wc-product-cat-manage.php
+msgid "Please select both source and target categories"
+msgstr "Vyberte prosím zdrojovou i cílovou kategorii"
+
+#: studiou-wc-product-cat-manage.php
+msgid "No products to move."
+msgstr "Žádné produkty k přesunu."
+
+#: studiou-wc-product-cat-manage.php
+msgid "Import complete"
+msgstr "Import dokončen"
+
+#: studiou-wc-product-cat-manage.php
+msgid "Deletion Summary"
+msgstr "Souhrn mazání"
+
+#: studiou-wc-product-cat-manage.php
+msgid "Import Summary"
+msgstr "Souhrn importu"

+ 84 - 5
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage.po

@@ -2,7 +2,7 @@
 # This file is distributed under the same license as the QDR - Studiou WC Export/Import Product Categories package.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.3\n"
+"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.7.1\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-product-cat-manage\n"
 "POT-Creation-Date: 2026-04-01 12:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -939,12 +939,12 @@ msgstr ""
 msgid "Remove Not Assigned Media"
 msgstr ""
 
-#: views/manipulations.php
-msgid "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."
+#: views/manipulations/tab-remove-unassigned.php
+msgid "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."
 msgstr ""
 
-#: views/manipulations.php
-msgid "WARNING: This operation will permanently delete all unused uncategorized media files from the server. This action cannot be undone!"
+#: views/manipulations/tab-remove-unassigned.php
+msgid "WARNING: This action cannot be undone."
 msgstr ""
 
 #: views/manipulations.php
@@ -1070,4 +1070,83 @@ msgstr ""
 
 #: includes/class-studiou-wc-product-cat-manage-manipulator.php
 msgid "Tab content not found"
+msgstr ""
+
+# v1.6.2 — M6: new warning bullets enumerating what is NOT detected.
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Detection is heuristic. The following references are NOT detected and the media WILL be deleted if it is only used this way:"
+msgstr ""
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Images embedded only in post / page / product content HTML (including page builders that store HTML)."
+msgstr ""
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Images referenced from ACF, custom postmeta, custom termmeta keys other than \"thumbnail_id\", or theme options."
+msgstr ""
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Files attached to WooCommerce downloadable products."
+msgstr ""
+
+#: views/manipulations/tab-remove-unassigned.php
+msgid "Treat this button as: \"delete media that nothing I can detect is using.\" If unsure, back up the uploads folder first."
+msgstr ""
+
+# v1.6.2 — H4: column count mismatch surfaced into the failed-CSV.
+#: includes/class-studiou-wc-product-manage-product-import.php
+msgid "Row %1$d: column count mismatch (%2$d found, %3$d expected) — likely an unescaped comma. Row skipped."
+msgstr ""
+
+# v1.6.2 — M4 guard: clear error when the transient overflowed (superseded by 1.7.0 disk staging, kept for back-compat callers).
+#: includes/class-studiou-wc-product-manage-product-import.php
+msgid "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."
+msgstr ""
+
+# v1.6.4 — H5: gated product-export download handler.
+#: includes/class-studiou-wc-product-manage-product-export.php
+msgid "Invalid file"
+msgstr ""
+
+# v1.7.0 — M1 / M4 rework: disk-staging + batched category import.
+#: includes/class-studiou-wc-product-cat-manage-import.php includes/class-studiou-wc-product-manage-product-import.php
+msgid "Failed to stage import data on disk (write error)."
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "CSV contains no data rows."
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "OK"
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-import.php
+msgid "Row %d: %s"
+msgstr ""
+
+# v1.7.1 — L7: UPVP warns when an existing sale price ends up greater than the new regular price.
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Warning: %d variations have a sale price greater than the new regular price (sale > regular). Review the variations and adjust the sale price."
+msgstr ""
+
+# v1.7.1 — L1: JS-side i18n keys that were previously hardcoded English.
+#: studiou-wc-product-cat-manage.php
+msgid "Please select both source and target categories"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "No products to move."
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Import complete"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Deletion Summary"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Import Summary"
 msgstr ""

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

@@ -1,6 +1,6 @@
 # QDR - Studiou WC Export/Import Product Categories
 
-**Version: 1.6.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,53 @@ 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.
+- **Product import no longer uses a WordPress transient to stage CSV rows (M4 rework).** The transient was a single `wp_options` row; large imports overflowed `max_allowed_packet` and surfaced as the misleading "Import data not found or expired". The new disk-streaming staging (shared with the category importer) has no such ceiling. The 1.6.2 transient-failure guard becomes unnecessary but remains as a defensive error path on the new disk write.
+- **Two new helper static methods on `Studiou_WC_Product_Manage_Product_Import`** (`get_staging_dir`, `stage_rows`, `read_stage_slice`, `delete_stage`, `prune_staging`) are the shared staging API used by both importers.
+
+### Version 1.6.4
+- **Security: gated product-export download.** Previously the product exporter dropped `product-export-YYYY-MM-DD-H-i-s.csv` into `wp-content/uploads/studiou-wc-product-exports/` and returned the raw public URL — anyone who knew or guessed the timestamped name could download the file, and the directory was never cleaned. Now mirrors the category exporter:
+  - Export files live in `wp-content/uploads/studiou-wcpcm-product-exports/` with `index.php` and `.htaccess` guards.
+  - Filenames embed a 32-character random hex token (`product-export-<32hex>.csv`) so the path is unguessable even if directory listing leaks.
+  - Downloads now route through `admin-init` with a nonce check, a `manage_woocommerce` capability check, a strict regex on the requested basename, and a `realpath` confinement check that defends against path traversal and symlink escape.
+  - Every new export prunes files older than 24 hours so the directory doesn't grow without bound.
+
+### Version 1.6.3
+- **Product import: correct round-trip for taxonomy and custom attributes.** The importer now reads the `Vlastnost 1 globální` column (previously written by the exporter but never consumed) and branches on it:
+  - **Global / taxonomy attribute** (`globální = 1`): the importer registers the taxonomy, resolves each `Hodnota(y) 1 vlastnosti` value to its term (creating the term via `wp_insert_term` if it doesn't exist), passes the term IDs to `WC_Product_Attribute::set_options()`, and links the terms to the product via `wp_set_object_terms` after save. For variations the importer stores the term **slug** (not the raw name) under the `attribute_pa_*` meta key, so WooCommerce can match the customer's selection on the storefront. Previously the importer passed raw names to `set_options()` and stored raw names in variation meta, which meant taxonomy variations were unreachable ("No matching variation").
+  - **Custom (product-level) attribute** (`globální = 0` or column absent): the importer now correctly creates a non-taxonomy `WC_Product_Attribute` with `set_id(0)` and a plain display name, stores the raw values, and writes variation meta under `attribute_<sanitized name>`. Previously every attribute was force-converted to a `pa_*` taxonomy, polluting the store with global taxonomies for what was meant to be a product-level attribute.
+- **Live-test gate before deployment.** Per the master plan §5.B.0, verify on a real WooCommerce install before shipping: create a variable product with a global `pa_*` attribute and 2-3 variations, export, delete, re-import the export, then storefront-test that each variation is selectable. The fix is shaped from the review's analysis; the live test is what proves it.
+
+### 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.
+- The on-disk filename is now derived from the URL path only (query string stripped), so `img.jpg?v=2` lands as `img.jpg`. The full URL (including query string) still keys the cache, so distinct URLs remain distinct attachments.
+- **Latent bug fixes in `upload_image_from_url()`**:
+  - `intermediate_image_sizes_advanced` filter is now reliably removed - the previous code added and "removed" two separate closures, so `remove_filter()` never matched and the filter leaked into later media operations in the same request. Replaced with the stable `__return_empty_array` callable, detached in `finally` so it leaves on every exit path (success, error, exception).
+  - Removed the redundant per-call `http_request_timeout` closure - it stacked one new closure per row (never removed) and was already covered by `download_url($url, 30)`.
+- Whitespace around image URLs is now trimmed at the upload entry point, matching what the validator already accepted. Two rows differing only by leading/trailing whitespace now collapse to one attachment instead of two.
+- No new translatable strings, no schema changes, no admin UI changes. The fix is entirely server-side in the product importer.
+
 ### Version 1.6.0
 - **Category Manipulations page restructured into tabs**
   - Each of the 6 operations plus Important Notes is now a separate tab

+ 51 - 30
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.0
+ * Version: 1.7.1
  * 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.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;
     }
 
     /**
@@ -426,10 +451,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 +514,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 +562,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">