Forráskód Böngészése

Deduplicate media on product import — v1.6.1

A product CSV in which the variable parent and its variations share one
Obrázky URL no longer produces image.jpg, image-1.jpg, image-2.jpg, ...
Each unique source URL is downloaded once; every later row reuses the
existing attachment, so the parent and all its variations converge on a
single attachment ID.

Mechanism is a two-layer cache keyed by the trimmed source URL:
- Per-request static cache absorbs repeats within one AJAX batch.
- _studiou_wcpcm_source_url attachment postmeta carries the dedup across
  batches and across re-imports.

Also fixes two adjacent latent bugs in upload_image_from_url():
- intermediate_image_sizes_advanced was added and "removed" with 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.
- The per-call http_request_timeout closure stacked one new closure per
  row, was never removed, and was already covered by download_url's
  own 30s arg. Deleted.

On-disk filename is now derived from the URL path only (query string
stripped via parse_url + basename), so img.jpg?v=2 lands as img.jpg.
The full URL (including query string) still keys the cache, so two
distinct URLs remain distinct attachments. Whitespace is trimmed at the
upload entry point to match what the validator already accepted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dalibor Votruba 1 hónapja
szülő
commit
d2ea69fb22

+ 2 - 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.6.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

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

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

+ 109 - 25
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
      *
@@ -456,57 +464,133 @@ class Studiou_WC_Product_Manage_Product_Import {
     }
 
     /**
-     * Upload image from URL
+     * 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;
+    }
+
+    /**
+     * Upload an image from a URL, deduplicating by source URL.
      *
-     * @param string $url Image URL
-     * @return int|null Attachment ID or null on failure
+     * 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');
         }
     }
 

+ 11 - 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.6.1**
 
 A WordPress plugin that extends WooCommerce to provide batch export and import functionality for both product categories and products, along with category manipulation tools.
 
@@ -352,6 +352,16 @@ For support, please visit [https://www.quadarax.com/plugins/studiou-wc-product-c
 
 ## Changelog
 
+### Version 1.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

+ 2 - 2
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.6.1
  * Requires at least: 6.8.1
  * Requires PHP: 8.2
  * Author: Dalibor Votruba
@@ -23,7 +23,7 @@ if (!defined('WPINC')) {
 }
 
 // Define plugin constants
-if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.0');
+if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.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__));