Skip to content

Commit 11a9a02

Browse files
Avoid duplicated images when sideloading images to Woo API
1 parent dbf252a commit 11a9a02

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

php/class-media.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3147,7 +3147,7 @@ public function setup() {
31473147
// Internal components.
31483148
$this->global_transformations = new Global_Transformations( $this );
31493149
$this->gallery = $this->plugin->get_component( 'gallery' );
3150-
$this->woocommerce_gallery = new WooCommerceGallery( $this->gallery );
3150+
$this->woocommerce_gallery = new WooCommerceGallery( $this->gallery, $this );
31513151
$this->filter = new Filter( $this );
31523152
$this->upgrade = new Upgrade( $this );
31533153
$this->video = new Video( $this );

php/media/class-woocommercegallery.php

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
namespace Cloudinary\Media;
99

10+
use Cloudinary\Media;
11+
use WP_REST_Request;
12+
use WP_REST_Response;
13+
1014
/**
1115
* Class WooCommerceGallery.
1216
*
@@ -20,16 +24,29 @@ class WooCommerceGallery {
2024
*/
2125
private $gallery;
2226

27+
/**
28+
* The media instance.
29+
*
30+
* @var Media
31+
*/
32+
private $media;
33+
2334
/**
2435
* Init woo gallery.
2536
*
2637
* @param Gallery $gallery Gallery instance.
38+
* @param Media $media Media instance.
2739
*/
28-
public function __construct( Gallery $gallery ) {
40+
public function __construct( Gallery $gallery, Media $media ) {
2941
$this->gallery = $gallery;
42+
$this->media = $media;
43+
44+
if ( self::woocommerce_active() ) {
45+
$this->setup_rest_hooks();
3046

31-
if ( self::woocommerce_active() && $this->enabled() ) {
32-
$this->setup_hooks();
47+
if ( $this->enabled() ) {
48+
$this->setup_hooks();
49+
}
3350
}
3451
}
3552

@@ -86,6 +103,13 @@ public function maybe_enqueue_scripts( $can ) {
86103
return $can;
87104
}
88105

106+
/**
107+
* Setup hooks for the REST API integration.
108+
*/
109+
public function setup_rest_hooks() {
110+
add_filter( 'rest_request_before_callbacks', array( $this, 'pre_process_product_images' ), 10, 3 );
111+
}
112+
89113
/**
90114
* Setup hooks for the gallery.
91115
*/
@@ -105,4 +129,98 @@ static function () {
105129

106130
add_filter( 'cloudinary_enqueue_gallery_script', array( $this, 'maybe_enqueue_scripts' ) );
107131
}
132+
133+
/**
134+
* Pre-process product images in REST API requests to resolve Cloudinary URLs to existing
135+
* media library attachment IDs, preventing unnecessary sideloads and duplicate assets.
136+
*
137+
* @param WP_REST_Response|null $response The response object or null.
138+
* @param WP_REST_Server $handler The request handler.
139+
* @param WP_REST_Request $request The request object.
140+
*
141+
* @return WP_REST_Response|null
142+
*/
143+
public function pre_process_product_images( $response, $handler, $request ) {
144+
$route = $request->get_route();
145+
$method = $request->get_method();
146+
147+
// Ignore requests to other API endpoints.
148+
if (
149+
false === strpos( $route, '/wc/' )
150+
|| false === strpos( $route, '/products' )
151+
|| ! in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true )
152+
) {
153+
return $response;
154+
}
155+
156+
// We only care about requests that include images.
157+
$images = $request->get_param( 'images' );
158+
if ( empty( $images ) || ! is_array( $images ) ) {
159+
return $response;
160+
}
161+
162+
$modified = false;
163+
164+
foreach ( $images as $index => $image ) {
165+
// If the image ID is already passed, WooCommerce will be able to find the corresponding attachment from the Media Library.
166+
if ( ! empty( $image['id'] ) ) {
167+
continue;
168+
}
169+
170+
$src = isset( $image['src'] ) ? esc_url_raw( $image['src'] ) : '';
171+
172+
// We only care about images with a cloudinary URL.
173+
if ( ! $src || ! $this->media->is_cloudinary_url( $src ) ) {
174+
continue;
175+
}
176+
177+
$attachment_id = $this->find_attachment_by_cloudinary_url( $src );
178+
179+
// Apply the ID so that WooCommerce assigns the existing attachment.
180+
if ( ! is_null( $attachment_id ) ) {
181+
$images[ $index ]['id'] = $attachment_id;
182+
$modified = true;
183+
}
184+
}
185+
186+
if ( $modified ) {
187+
$request->set_param( 'images', $images );
188+
}
189+
190+
return $response;
191+
}
192+
193+
/**
194+
* Find an existing media library attachment that corresponds to a Cloudinary URL.
195+
*
196+
* The URL may include transformation segments, so the lookup proceeds in three steps:
197+
* exact sync key match, bare public ID match, then base key match.
198+
*
199+
* @param string $url A Cloudinary asset URL.
200+
*
201+
* @return int|null Attachment ID, or null if not found.
202+
*/
203+
private function find_attachment_by_cloudinary_url( $url ) {
204+
// Step 1: exact sync key — handles URLs that already exist verbatim in the library.
205+
$attachment_id = $this->media->get_id_from_url( $url );
206+
if ( $attachment_id ) {
207+
return $attachment_id;
208+
}
209+
210+
$public_id = $this->media->get_public_id_from_url( $url );
211+
if ( ! $public_id ) {
212+
return null;
213+
}
214+
215+
// Step 2: bare public ID — matches assets uploaded from WordPress without transformations.
216+
$linked = $this->media->get_linked_attachments( $public_id );
217+
if ( ! empty( $linked ) ) {
218+
return array_shift( $linked );
219+
}
220+
221+
// Step 3: base key — matches assets imported from Cloudinary.
222+
$base_id = $this->media->get_id_from_sync_key( 'base_' . $public_id );
223+
224+
return $base_id ? $base_id : null;
225+
}
108226
}

0 commit comments

Comments
 (0)