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.
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.
When several rows reference the same Obrázky URL, the importer must:
null
and the row proceeds with no image.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).
Each sideloaded attachment is tagged:
meta_key = _studiou_wcpcm_source_urlmeta_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.
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.)
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.
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.
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.)
includes/class-studiou-wc-product-manage-product-import.php(a) Static cache property — add immediately after the $db property (:22):
/**
* 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():
/**
* 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:
/**
* 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:
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.'__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).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.int; empty/failed paths
return null. Both callers already gate on if ($image_id), so nothing downstream
changes.studiou-wc-product-cat-manage.phpVersion: 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.
assets/js/admin.jsconsole.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.
CLAUDE.md1.6.1.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.readme.md**Version: 1.6.0** → **Version: 1.6.1** (:3).### Version 1.6.1 changelog entry directly above ### Version 1.6.0 (:355)
summarizing the dedup fix and the two latent-bug fixes.No new translatable strings ⇒ no .po/.mo recompile. Optionally bump the
Project-Id-Version header in both .po files for consistency.
Both live in the method being rewritten, so this is the natural moment to fix them.
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.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.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:
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.
Obrázky → early null, no cache write (matches current behavior)."…img.jpg" vs " …img.jpg") → collapse to one
attachment via the trim.name-1.jpg on disk, which is correct.…/img.jpg?v=2) → on-disk file is img.jpg; the attachment is
keyed by the full URL including ?v=2.null, does not cache the failure; the next row
retries. No worse than today.null._studiou_wcpcm_source_url) → cache miss → fresh
upload. We don't retroactively tag old media.get_posts only
guards post existence, not file existence. Out of scope — re-deleting the attachment
forces a clean re-import.class-studiou-wc-product-manage-product-import.php: add the static property,
add find_attachment_by_source_url(), replace upload_image_from_url().studiou-wc-product-cat-manage.php (header + constant).assets/js/admin.js.CLAUDE.md (Current Version + class bullet).readme.md (Version line + ### Version 1.6.1 changelog entry).No view edits, no JS logic edits, no CSS edits, no .po/.mo recompile, no AJAX
handler changes.
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.WP_DEBUG: one "sideloaded" line, then "cache HIT" lines.Obrázky → 2 sideloads, 4 cache hits,
1 row with no image."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.)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.null, no cache write,
products created without images, one always-on failure line per row.finally).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.
_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.set_image_id() on variations is mostly
redundant. Suppressing it changes variation behavior — defer until requested.-N ordinal), picks a canonical ID, repoints
every product/variation _thumbnail_id, and deletes the orphans. Needs dry-run +
confirmation UX._studiou_wcpcm_source_url, extending dedup
back to pre-1.6.1 imports.