# 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 `-.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 = ` 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 */ 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-.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.