Преглед изворни кода

Add analysis and implementation plan for product-image dedup (v1.6.1)

fix-multi-image-plan-00.md walks through why importing a CSV in which the
variable parent and its variations share one image URL produces
image-1.jpg, image-2.jpg, ... duplicates (per-row sideload with no dedup,
wp_unique_filename appending an ordinal each time).

fix-multi-image-plan-01.md is the implementation plan: a two-layer cache
keyed by the trimmed source URL (in-request static + persistent
_studiou_wcpcm_source_url postmeta), tightened filename handling, and two
adjacent latent-bug fixes (filter remove_filter() never matching its add,
http_request_timeout closures stacking per row).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dalibor Votruba пре 1 месец
родитељ
комит
bf114cd86e

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