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