PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / import / class-wdkit-import-media.php

class-wdkit-import-media.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/import/class-wdkit-import-media.php

1,339 lines 43.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Image discovery, matching and substitution (PHP).
4 *
5 * PHP counterpart of the image logic in import_loader.js — extractImageUrls,
6 * extractAllImageUrls, replaceMediaLinks, Image_sizer and replace_ai_team. The browser
7 * versions are untouched and still drive the wizard.
8 *
9 * ── How images actually work here ───────────────────────────────────────────
10 *
11 * Templates mark image slots with `wdkitai_image_description_type` on the widget, or on an
12 * ancestor:
13 * 'image_stock' - swap for one of the visitor's chosen stock images
14 * 'team_library' - swap for a person photo from the team library, further keyed by
15 * `wdkitai_team_library` (collection) and `wdkitai_team_library_type`
16 * ('testimonial' vs 'team_member')
17 *
18 * Slots are read from `image.url` (Elementor) / `tImg.url` (Gutenberg), `background_image.url`,
19 * or a bare `url`, and only when the URL ends in a real image extension.
20 *
21 * Substitution then happens on the **serialised JSON string**, not the object tree — which is
22 * why both the plain and the JSON-escaped form of each URL have to be replaced. That is how
23 * the browser does it, and matching it keeps output identical.
24 *
25 * Selection is aspect-ratio nearest-match with a reuse penalty, so a wide hero does not get a
26 * portrait and the same photo is not used everywhere.
27 *
28 * @package Wdesignkit
29 * @since 2.6.5
30 */
31
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 if ( ! class_exists( 'Wdkit_Import_Media' ) ) {
37
38 /**
39 * Importer media services.
40 */
41 class Wdkit_Import_Media {
42
43 /**
44 * Extensions treated as replaceable images. Mirrors the regex in import_loader.js.
45 */
46 const IMAGE_EXT_PATTERN = '/\.(jpg|jpeg|png|gif|webp)$/i';
47
48 /**
49 * Transfer ceilings for the concurrent prefetch, matching Wdkit_Import_Images so the two
50 * curl paths refuse the same oversized responses: 2 MB per file, 32 MB retained across a
51 * batch. A single large source or a large kit therefore cannot buffer without bound.
52 */
53 const PREFETCH_MAX_BYTES = 2097152;
54 const PREFETCH_MAX_TOTAL_BYTES = 33554432;
55
56 /**
57 * Remote dimension lookups are slow; memoise per request.
58 *
59 * @var array<string,array|null>
60 */
61 private static $size_cache = array();
62
63 /**
64 * Is this string a URL pointing at a replaceable image?
65 *
66 * @param mixed $url Candidate.
67 * @return bool
68 */
69 private static function is_image_url( $url ) {
70 return is_string( $url ) && 1 === preg_match( self::IMAGE_EXT_PATTERN, $url );
71 }
72
73 /**
74 * The key holding the widget's main image for a given builder.
75 *
76 * @param string $builder 'elementor'|'gutenberg'.
77 * @return string
78 */
79 private static function image_key( $builder ) {
80 return ( 'gutenberg' === $builder ) ? 'tImg' : 'image';
81 }
82
83 /*
84 |--------------------------------------------------------------------------
85 | 1. Discovery
86 |--------------------------------------------------------------------------
87 */
88
89 /**
90 * Find the AI-replaceable image slots in a template tree.
91 *
92 * Port of extractImageUrls(). `wdkitai_image_description_type` is inherited from the
93 * nearest ancestor that declares it, which is why the walk carries an ancestor stack.
94 *
95 * @param array $tree Decoded template tree (Elementor elements or parsed blocks).
96 * @param string $builder 'elementor'|'gutenberg'.
97 * @return array{ai:string[],ai_team:array[]} Stock URLs, and team groups
98 * ({img_type, team_type, imgs[]}).
99 */
100 public static function extract_image_urls( $tree, $builder = 'elementor' ) {
101 $stock = array();
102 $team_map = array();
103
104 self::walk_images( $tree, array(), $builder, $stock, $team_map );
105
106 /** imgs is URL-keyed while collecting so it dedupes; callers want a plain list */
107 foreach ( $team_map as $key => $group ) {
108 $team_map[ $key ]['imgs'] = array_values( $group['imgs'] );
109 }
110
111 return array(
112 'ai' => array_values( $stock ),
113 'ai_team' => array_values( $team_map ),
114 );
115 }
116
117 /**
118 * Recursive worker for extract_image_urls().
119 *
120 * @param mixed $value Current node.
121 * @param array $ancestors Ancestor nodes, nearest last.
122 * @param string $builder Builder.
123 * @param array $stock Accumulator (by reference).
124 * @param array $team_map Accumulator (by reference).
125 * @return void
126 */
127 private static function walk_images( $value, $ancestors, $builder, &$stock, &$team_map ) {
128 if ( ! is_array( $value ) ) {
129 return;
130 }
131
132 /** a plain list carries no settings of its own — just descend */
133 if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) && ! empty( $value ) ) {
134 foreach ( $value as $item ) {
135 self::walk_images( $item, $ancestors, $builder, $stock, $team_map );
136 }
137
138 return;
139 }
140
141 $desc_type = isset( $value['wdkitai_image_description_type'] ) ? $value['wdkitai_image_description_type'] : '';
142
143 if ( empty( $desc_type ) ) {
144 for ( $i = count( $ancestors ) - 1; $i >= 0; $i-- ) {
145 if ( ! empty( $ancestors[ $i ]['wdkitai_image_description_type'] ) ) {
146 $desc_type = $ancestors[ $i ]['wdkitai_image_description_type'];
147 break;
148 }
149 }
150 }
151
152 $img_type = 'default';
153 $team_type = 'team_member';
154
155 if ( 'team_library' === $desc_type ) {
156 for ( $i = count( $ancestors ) - 1; $i >= 0; $i-- ) {
157 if ( ! empty( $ancestors[ $i ]['wdkitai_team_library'] ) ) {
158 $img_type = $ancestors[ $i ]['wdkitai_team_library'];
159 break;
160 }
161 }
162
163 if ( ! empty( $value['wdkitai_team_library'] ) ) {
164 $img_type = $value['wdkitai_team_library'];
165 }
166
167 if ( ! empty( $value['wdkitai_team_library_type'] ) ) {
168 $team_type = ( 'testimonial' === $value['wdkitai_team_library_type'] ) ? 'testimonial' : 'team_member';
169 } else {
170 for ( $i = count( $ancestors ) - 1; $i >= 0; $i-- ) {
171 if ( ! empty( $ancestors[ $i ]['wdkitai_team_library_type'] ) ) {
172 $team_type = ( 'testimonial' === $ancestors[ $i ]['wdkitai_team_library_type'] ) ? 'testimonial' : 'team_member';
173 break;
174 }
175 }
176 }
177 }
178
179 $image_key = self::image_key( $builder );
180
181 $candidates = array();
182
183 if ( isset( $value[ $image_key ]['url'] ) && self::is_image_url( $value[ $image_key ]['url'] ) ) {
184 $candidates[] = array( $value[ $image_key ]['url'], true );
185 }
186
187 /** background images are only ever stock, never team photos */
188 if ( isset( $value['background_image']['url'] ) && self::is_image_url( $value['background_image']['url'] ) ) {
189 $candidates[] = array( $value['background_image']['url'], false );
190 }
191
192 if ( isset( $value['url'] ) && self::is_image_url( $value['url'] ) ) {
193 $candidates[] = array( $value['url'], true );
194 }
195
196 foreach ( $candidates as $candidate ) {
197 list( $url, $team_eligible ) = $candidate;
198
199 if ( 'image_stock' === $desc_type ) {
200 $stock[ $url ] = $url;
201 continue;
202 }
203
204 if ( 'team_library' === $desc_type && $team_eligible ) {
205 $key = $img_type . '|' . $team_type;
206
207 if ( ! isset( $team_map[ $key ] ) ) {
208 $team_map[ $key ] = array(
209 'img_type' => $img_type,
210 'team_type' => $team_type,
211 'imgs' => array(),
212 );
213 }
214
215 /* Keyed by URL, which dedupes. The walk necessarily sees the same file
216 * twice — once as `image.url` on the widget and again as the bare `url`
217 * when it descends into that image object.
218 *
219 * This is a deliberate, documented divergence from import_loader.js, which
220 * pushes to a plain array here and so counts every photo twice: it then
221 * asks the team library for 2N images to fill N slots and discards half.
222 * Deduping requests exactly what is needed and maps 1:1. Slot order is
223 * preserved because PHP keeps insertion order. */
224 $team_map[ $key ]['imgs'][ $url ] = $url;
225 }
226 }
227
228 $next = $ancestors;
229 $next[] = $value;
230
231 foreach ( $value as $child ) {
232 self::walk_images( $child, $next, $builder, $stock, $team_map );
233 }
234 }
235
236 /**
237 * Every image URL in a template, regardless of AI marking.
238 *
239 * Port of extractAllImageUrls(). Used to know which files a page references at all.
240 *
241 * @param array $tree Decoded template tree.
242 * @param string $builder Builder.
243 * @return string[] Unique URLs.
244 */
245 public static function extract_all_image_urls( $tree, $builder = 'elementor' ) {
246 $urls = array();
247
248 self::walk_all_images( $tree, $builder, $urls );
249
250 return array_values( $urls );
251 }
252
253 /**
254 * @param mixed $value Node.
255 * @param string $builder Builder.
256 * @param array $urls Accumulator (by reference).
257 * @return void
258 */
259 private static function walk_all_images( $value, $builder, &$urls ) {
260 if ( ! is_array( $value ) ) {
261 return;
262 }
263
264 $image_key = self::image_key( $builder );
265
266 foreach ( array( $image_key, 'background_image' ) as $key ) {
267 if ( isset( $value[ $key ]['url'] ) && self::is_image_url( $value[ $key ]['url'] ) ) {
268 $urls[ $value[ $key ]['url'] ] = $value[ $key ]['url'];
269 }
270 }
271
272 if ( isset( $value['url'] ) && self::is_image_url( $value['url'] ) ) {
273 $urls[ $value['url'] ] = $value['url'];
274 }
275
276 foreach ( $value as $child ) {
277 self::walk_all_images( $child, $builder, $urls );
278 }
279 }
280
281 /*
282 |--------------------------------------------------------------------------
283 | 2. Selection
284 |--------------------------------------------------------------------------
285 */
286
287 /**
288 * Image dimensions for a remote or local URL.
289 *
290 * The browser reads these from a decoded <img>; PHP asks getimagesize(). Every URL is
291 * SSRF-validated first, reusing wdesignkit_validate_external_url() — which resolves the
292 * host and refuses loopback, RFC1918, link-local and reserved ranges.
293 *
294 * @param string $url Image URL.
295 * @return array{width:int,height:int}|null
296 */
297 /**
298 * URLs measured over HTTP this request. Diagnostic only.
299 *
300 * @var string[]
301 */
302 public static $remote_size_calls = array();
303
304 public static function image_size( $url ) {
305 if ( ! is_string( $url ) || '' === $url ) {
306 return null;
307 }
308
309 if ( array_key_exists( $url, self::$size_cache ) ) {
310 return self::$size_cache[ $url ];
311 }
312
313 self::$size_cache[ $url ] = null;
314
315 /** a local attachment can be measured without leaving the box */
316 $local_path = self::local_path_for_url( $url );
317
318 if ( null !== $local_path ) {
319 $size = @getimagesize( $local_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
320
321 if ( is_array( $size ) && ! empty( $size[0] ) ) {
322 self::$size_cache[ $url ] = array(
323 'width' => (int) $size[0],
324 'height' => (int) $size[1],
325 );
326 }
327
328 return self::$size_cache[ $url ];
329 }
330
331 /* prefetch() may already have this exact source URL on disk. getimagesize() over
332 * HTTP opens a connection and reads the header for every candidate, one at a time -
333 * measured 28s on the one template whose images go through the slot mapping, which
334 * was more than the rest of the kit combined. A local read is free by comparison. */
335 $prefetched = self::existing_attachment_for( $url );
336
337 if ( $prefetched > 0 ) {
338 $file = get_attached_file( $prefetched );
339
340 if ( $file && file_exists( $file ) ) {
341 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
342
343 if ( is_array( $size ) && ! empty( $size[0] ) ) {
344 self::$size_cache[ $url ] = array(
345 'width' => (int) $size[0],
346 'height' => (int) $size[1],
347 );
348 }
349
350 return self::$size_cache[ $url ];
351 }
352 }
353
354 if ( function_exists( 'wdesignkit_validate_external_url' ) && ! wdesignkit_validate_external_url( $url ) ) {
355 return null;
356 }
357
358 self::$remote_size_calls[] = $url;
359
360 /* getimagesize() over HTTP takes no context and inherits default_socket_timeout,
361 * which is 60s on most installs - so a slot on a host that blackholes the request
362 * stalls the whole serial map for a minute. The importing BROWSER caps this at 8s
363 * per image (WDKIT_IMAGE_PROBE_TIMEOUT) and measures every slot concurrently; this
364 * cannot do the latter cheaply, but it can at least match the cap. */
365 $previous_timeout = ini_get( 'default_socket_timeout' );
366 @ini_set( 'default_socket_timeout', '8' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
367
368 $size = @getimagesize( $url ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
369
370 if ( false !== $previous_timeout ) {
371 @ini_set( 'default_socket_timeout', $previous_timeout ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
372 }
373
374 if ( is_array( $size ) && ! empty( $size[0] ) ) {
375 self::$size_cache[ $url ] = array(
376 'width' => (int) $size[0],
377 'height' => (int) $size[1],
378 );
379 }
380
381 return self::$size_cache[ $url ];
382 }
383
384 /**
385 * Filesystem path for a URL inside this site's uploads, or null.
386 *
387 * @param string $url URL.
388 * @return string|null
389 */
390 private static function local_path_for_url( $url ) {
391 $uploads = wp_get_upload_dir();
392
393 if ( empty( $uploads['baseurl'] ) || empty( $uploads['basedir'] ) ) {
394 return null;
395 }
396
397 if ( 0 !== strpos( $url, $uploads['baseurl'] ) ) {
398 return null;
399 }
400
401 $relative = ltrim( substr( $url, strlen( $uploads['baseurl'] ) ), '/' );
402 $path = trailingslashit( $uploads['basedir'] ) . $relative;
403
404 return file_exists( $path ) ? $path : null;
405 }
406
407 /**
408 * Choose the replacement whose aspect ratio is closest to the slot's.
409 *
410 * Port of the selection inside replaceMediaLinks(): the score is
411 * |candidate ratio − target ratio| plus a reuse penalty, so an image already used is
412 * only picked again when nothing better fits. The penalty is returned so the caller can
413 * age the pool between slots.
414 *
415 * @param array $target {width, height} of the slot.
416 * @param array $candidates List of {url, width, height, used?}.
417 * @return array{index:int,candidate:array}|null
418 */
419 public static function closest_by_aspect( $target, $candidates ) {
420 if ( empty( $candidates ) || ! is_array( $candidates ) ) {
421 return null;
422 }
423
424 $t_width = ! empty( $target['width'] ) ? (float) $target['width'] : 50.0;
425 $t_height = ! empty( $target['height'] ) ? (float) $target['height'] : 50.0;
426
427 if ( $t_height <= 0 ) {
428 $t_height = 50.0;
429 }
430
431 $target_ratio = $t_width / $t_height;
432
433 $best = null;
434 $best_index = -1;
435 $best_score = INF;
436
437 foreach ( $candidates as $index => $candidate ) {
438 $width = ! empty( $candidate['width'] ) ? (float) $candidate['width'] : 0.0;
439 $height = ! empty( $candidate['height'] ) ? (float) $candidate['height'] : 0.0;
440
441 if ( $height <= 0 ) {
442 continue;
443 }
444
445 $used = isset( $candidate['used'] ) ? (float) $candidate['used'] : 0.0;
446 $score = abs( ( $width / $height ) - $target_ratio ) + $used;
447
448 if ( $score < $best_score ) {
449 $best_score = $score;
450 $best = $candidate;
451 $best_index = $index;
452 }
453 }
454
455 if ( null === $best ) {
456 return null;
457 }
458
459 return array(
460 'index' => $best_index,
461 'candidate' => $best,
462 );
463 }
464
465 /*
466 |--------------------------------------------------------------------------
467 | 3. Substitution
468 |--------------------------------------------------------------------------
469 */
470
471 /**
472 * Replace image URLs inside a serialised template.
473 *
474 * Both the plain and the JSON-escaped (`\/`) form of each URL are replaced, because the
475 * template travels as a JSON string and Elementor stores its data escaped. Matching the
476 * browser here is what keeps stored content identical between the two paths.
477 *
478 * ── One deliberate divergence from the browser ─────────────────────────
479 *
480 * Keys are applied longest-first. The browser iterates the map in insertion order, so
481 * when one URL is a prefix of another — `photo.jpg` and `photo.jpg?w=800` are the case
482 * that actually occurs — replacing the short one first mangles the long one into
483 * `local.png?w=800`, pointing at a file that does not exist. Length ordering removes
484 * that without changing the result for any map where no key is a prefix of another,
485 * which is every map in practice. It only ever makes the substitution more correct.
486 *
487 * @param string $json Serialised template.
488 * @param array $map old URL => new URL.
489 * @return string
490 */
491 public static function replace_urls_in_json( $json, $map ) {
492 if ( ! is_string( $json ) || empty( $map ) || ! is_array( $map ) ) {
493 return $json;
494 }
495
496 $keys = array_keys( $map );
497
498 usort(
499 $keys,
500 static function ( $a, $b ) {
501 return strlen( (string) $b ) - strlen( (string) $a );
502 }
503 );
504
505 $ordered = array();
506
507 foreach ( $keys as $key ) {
508 $ordered[ $key ] = $map[ $key ];
509 }
510
511 $search = array();
512 $replace = array();
513
514 foreach ( $ordered as $old => $new ) {
515 if ( ! is_string( $old ) || '' === $old || ! is_string( $new ) || '' === $new ) {
516 continue;
517 }
518
519 /** escaped form first — replacing the plain form first would corrupt it */
520 $search[] = str_replace( '/', '\\/', $old );
521 $replace[] = str_replace( '/', '\\/', $new );
522
523 $search[] = $old;
524 $replace[] = $new;
525 }
526
527 if ( empty( $search ) ) {
528 return $json;
529 }
530
531 return str_replace( $search, $replace, $json );
532 }
533
534 /**
535 * Build an old => new map for stock image slots.
536 *
537 * Slots are matched to the visitor's chosen images by aspect ratio, ageing the pool as
538 * it goes so the same photo is not reused while alternatives remain. A slot whose
539 * dimensions cannot be read is left alone rather than guessed at.
540 *
541 * @param string[] $slots Slot URLs found in the template.
542 * @param array[] $selected Chosen images: {url, width, height}.
543 * @return array old URL => new URL.
544 */
545 public static function map_stock_images( $slots, $selected ) {
546 $map = array();
547
548 if ( empty( $slots ) || empty( $selected ) ) {
549 return $map;
550 }
551
552 /** normalise, filling in dimensions where the caller did not supply them */
553 $pool = array();
554
555 foreach ( $selected as $image ) {
556 $url = is_array( $image ) ? ( isset( $image['url'] ) ? $image['url'] : '' ) : (string) $image;
557
558 if ( '' === $url ) {
559 continue;
560 }
561
562 $width = is_array( $image ) && ! empty( $image['width'] ) ? (int) $image['width'] : 0;
563 $height = is_array( $image ) && ! empty( $image['height'] ) ? (int) $image['height'] : 0;
564
565 if ( ! $width || ! $height ) {
566 $size = self::image_size( $url );
567
568 if ( null === $size ) {
569 continue;
570 }
571
572 $width = $size['width'];
573 $height = $size['height'];
574 }
575
576 $pool[] = array(
577 'url' => $url,
578 'width' => $width,
579 'height' => $height,
580 'used' => 0.0,
581 );
582 }
583
584 if ( empty( $pool ) ) {
585 return $map;
586 }
587
588 foreach ( $slots as $slot ) {
589 $size = self::image_size( $slot );
590
591 if ( null === $size ) {
592 continue;
593 }
594
595 $match = self::closest_by_aspect( $size, $pool );
596
597 if ( null === $match ) {
598 continue;
599 }
600
601 $map[ $slot ] = $match['candidate']['url'];
602
603 /** same 0.5 reuse penalty the browser applies */
604 $pool[ $match['index'] ]['used'] += 0.5;
605 }
606
607 return $map;
608 }
609
610 /**
611 * Build an old => new map for a team-library group.
612 *
613 * The library may return fewer photos than there are slots; the browser cycles the list
614 * to fill the gap and this does the same, so a six-person team never renders blanks.
615 *
616 * @param string[] $slots Slot URLs, in template order.
617 * @param string[] $images Library URLs.
618 * @return array old URL => new URL.
619 */
620 public static function map_team_images( $slots, $images ) {
621 $map = array();
622
623 $slots = array_values( array_filter( (array) $slots, 'is_string' ) );
624 $images = array_values( array_filter( (array) $images, 'is_string' ) );
625
626 if ( empty( $slots ) || empty( $images ) ) {
627 return $map;
628 }
629
630 $count = count( $images );
631
632 foreach ( $slots as $index => $slot ) {
633 $map[ $slot ] = $images[ $index % $count ];
634 }
635
636 return $map;
637 }
638
639 /*
640 |--------------------------------------------------------------------------
641 | 4. Sideload
642 |--------------------------------------------------------------------------
643 */
644
645 /**
646 * Bring one external image into the media library and return its local URL.
647 *
648 * Delegates to Wdkit_Api_Call::wdkit_sideload_image_data(), which is the exact code the
649 * `save_wp_images` AJAX action runs — including the oversized-image guard and the
650 * Elementor/block importer hash stamps. No logic is duplicated here.
651 *
652 * ⚠ THIS IS NOT IDEMPOTENT, and an earlier version of this comment implied it was.
653 * Measured against real WordPress: calling it twice with the same URL produces two
654 * attachments (`name.png`, `name-1.png`), because the handler calls
655 * media_sideload_image() unconditionally with no "already imported?" lookup. The hash
656 * stamps it writes stop *Elementor's* and *the block importer's* later re-download; they
657 * do not stop a second call to this function.
658 *
659 * That is the existing wizard's behaviour too — `save_wp_images` has always worked this
660 * way — so it is left alone rather than "fixed" here. The consequence for the runner:
661 * a page that fails AFTER its images are sideloaded and is then retried will leave the
662 * first attempt's attachments behind. Page-level retries are otherwise unaffected,
663 * because a completed page step is skipped in full and never re-sideloads.
664 *
665 * See the Phase 5 report; a URL→attachment lookup in this method would close it without
666 * touching the shared handler.
667 *
668 * @param string $url External image URL.
669 * @return array{success:bool,url:string,message:string}
670 */
671 public static function sideload( $url ) {
672 $failure = array(
673 'success' => false,
674 'url' => '',
675 'message' => __( 'No Image Provided', 'wdesignkit' ),
676 );
677
678 if ( ! is_string( $url ) || '' === trim( $url ) ) {
679 return $failure;
680 }
681
682 if ( function_exists( 'wdesignkit_validate_external_url' ) && ! wdesignkit_validate_external_url( $url ) ) {
683 return array(
684 'success' => false,
685 'url' => '',
686 'message' => __( 'Image URL is not allowed.', 'wdesignkit' ),
687 );
688 }
689
690 if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_sideload_image_data' ) ) {
691 return array(
692 'success' => false,
693 'url' => '',
694 'message' => __( 'Importer is not available.', 'wdesignkit' ),
695 );
696 }
697
698 $response = Wdkit_Api_Call::wdkit_sideload_image_data( $url );
699
700 return array(
701 'success' => ! empty( $response['success'] ),
702 'url' => isset( $response['url'] ) ? (string) $response['url'] : '',
703 'message' => isset( $response['message'] ) ? (string) $response['message'] : '',
704 );
705 }
706
707 /**
708 * Sideload a batch, returning an old => new map for the ones that worked.
709 *
710 * A failed image is simply absent from the map, which leaves the template's original
711 * URL in place. That is deliberate: a missing photo is a much smaller problem than a
712 * broken page, and it keeps one bad file from failing the whole import.
713 *
714 * @param string[] $urls External URLs.
715 * @return array{map:array,failed:string[]}
716 */
717 public static function sideload_batch( $urls ) {
718 $map = array();
719 $failed = array();
720
721 $urls = array_values( array_unique( array_filter( (array) $urls, 'is_string' ) ) );
722
723 /* Warm them all concurrently first. Each one that lands leaves an attachment stamped
724 * with sha1 of its source URL, so the loop below resolves it without a download.
725 * Anything this misses still goes through sideload() exactly as before - the serial
726 * path stays as the fallback, it just stops being the common case. */
727 if ( count( $urls ) > 1 ) {
728 self::prefetch_urls( $urls );
729 }
730
731 foreach ( $urls as $url ) {
732 $existing = self::existing_attachment_for( $url );
733
734 if ( $existing > 0 ) {
735 $local = wp_get_attachment_url( $existing );
736
737 if ( $local ) {
738 $map[ $url ] = $local;
739
740 continue;
741 }
742 }
743
744 $result = self::sideload( $url );
745
746 if ( ! empty( $result['success'] ) && '' !== $result['url'] ) {
747 $map[ $url ] = $result['url'];
748 } else {
749 $failed[] = $url;
750 }
751 }
752
753 return array(
754 'map' => $map,
755 'failed' => $failed,
756 );
757 }
758 /**
759 * Download every image a template needs, in parallel, before the importer asks for them.
760 *
761 * ── Why ────────────────────────────────────────────────────────────────────
762 *
763 * Media import is essentially the whole cost of an import. Measured on the Zion kit:
764 *
765 * a page with 0 images 0.9s
766 * a page with 37 images 92.5s (0.7s fetch, 91.8s media, 0.01s insert)
767 *
768 * Two serial costs per image, both measured over ~250 images:
769 *
770 * wp_remote_get() 0.40s each = 99s
771 * wp_generate_attachment_metadata() 0.41s each = 103s (7 registered sizes)
772 *
773 * This addresses both. The downloads run concurrently through curl_multi - 0.14s each
774 * at only 4 in flight - and the attachment is created WITHOUT intermediate sizes, which
775 * measured 0.08s instead of 0.41s. A cron event regenerates the sizes just after the
776 * import, so the wizard is not waiting on image resizing to tell the user it is done.
777 *
778 * ── How it stays out of the way ────────────────────────────────────────────
779 *
780 * Nothing about Wdkit_Import_Images changes. It skips an image when it finds an
781 * attachment whose `tpgb_source_image_key` is sha1 of the source URL, so stamping that
782 * key here turns its serial download loop into a series of database lookups. If this
783 * method fails, or is never called, the importer downloads exactly as it always has -
784 * this is a warm cache, not a new code path.
785 *
786 * @since 2.6.5
787 *
788 * @param mixed $tree Parsed blocks, element tree, or block markup.
789 * @param string $builder 'elementor'|'gutenberg'.
790 * @param int $concurrency Simultaneous downloads.
791 * @return array{urls:int,fetched:int,reused:int,failed:int,ms:int}
792 */
793 public static function prefetch( $tree, $builder = 'gutenberg', $concurrency = 12, $raw = '' ) {
794 $started = microtime( true );
795
796 $result = array(
797 'urls' => 0,
798 'fetched' => 0,
799 'reused' => 0,
800 'failed' => 0,
801 'ms' => 0,
802 );
803
804 if ( ! function_exists( 'curl_multi_init' ) ) {
805 /* No curl_multi on this host. The importer's own serial download still works. */
806 return $result;
807 }
808
809 $urls = self::extract_all_image_urls( $tree, $builder );
810 $urls = is_array( $urls ) ? array_values( array_filter( $urls, 'is_string' ) ) : array();
811
812 /* extract_all_image_urls() walks known attribute shapes, and the importer finds more
813 * than that: a URL can sit in a block's saved innerHTML, in a background control, or
814 * in a repeater this walk does not know about. Measured on the Zion kit it found 117
815 * of the 150 images actually imported, and the 33 it missed were downloaded one at a
816 * time by the serial path afterwards - 25 of the run's 77 seconds, for images the
817 * concurrent fetch could have had in about one.
818 *
819 * So the markup is also scanned directly. Crude on purpose: any image URL anywhere in
820 * the payload counts, because an over-fetched image is one wasted request and an
821 * under-fetched one is a second of serial download. */
822 $urls = array_merge( $urls, self::image_urls_in_text( $raw ) );
823 /* Size variants are NOT filtered out, and that is deliberate. It looks like waste -
824 * scanning markup turns one image into its original plus every srcset size, 66 URLs
825 * where the attribute walk found 39 - but the content genuinely references those
826 * `-150x150` URLs, so the serial importer downloads them either way. Dropping them here
827 * measured 66 -> 39 and would simply hand those 27 downloads back to the one-at-a-time
828 * path: it was widening discovery to include them that took the media phase from 25s to
829 * 1.8s in the first place. */
830 $urls = array_values( array_unique( $urls ) );
831
832 return self::prefetch_urls( $urls, $concurrency );
833 }
834
835 /**
836 * Fetch an explicit list of URLs concurrently into the media library.
837 *
838 * Split out of prefetch() so sideload_batch() can use it. That method is a plain
839 * `foreach` around media_sideload_image() - a download AND a full thumbnail pass per
840 * image, measured at roughly 0.8s each - and it runs inside image_map(), which is where
841 * 22.6 of a 56 second import was going once the page media was already parallel.
842 *
843 * @param string[] $urls Absolute image URLs.
844 * @param int $concurrency Simultaneous downloads.
845 * @return array{urls:int,fetched:int,reused:int,failed:int,ms:int}
846 */
847 public static function prefetch_urls( $urls, $concurrency = 12 ) {
848 $started = microtime( true );
849
850 $result = array(
851 'urls' => 0,
852 'fetched' => 0,
853 'reused' => 0,
854 'failed' => 0,
855 'ms' => 0,
856 );
857
858 if ( ! function_exists( 'curl_multi_init' ) ) {
859 return $result;
860 }
861
862 $urls = is_array( $urls ) ? array_values( array_unique( array_filter( $urls, 'is_string' ) ) ) : array();
863
864 $result['urls'] = count( $urls );
865
866 if ( empty( $urls ) ) {
867 return $result;
868 }
869
870 /* Already local from an earlier page or an earlier run. */
871 $todo = array();
872
873 foreach ( $urls as $url ) {
874 if ( self::existing_attachment_for( $url ) > 0 ) {
875 ++$result['reused'];
876
877 continue;
878 }
879
880 /* Same DNS + reserved-range gate the serial sideload path applies, failing
881 * closed on a host that cannot be resolved, and the same WP_HTTP_BLOCK_EXTERNAL
882 * / proxy policy Wdkit_Import_Images honours before it dials its own curl handle.
883 * Anything refused here still reaches the sideload fallback, which validates
884 * again and follows the site's HTTP configuration. */
885 if ( ! function_exists( 'wdesignkit_validate_external_url' ) || ! wdesignkit_validate_external_url( $url ) ) {
886 continue;
887 }
888
889 if ( ! self::prefetch_allowed_by_http_policy( $url ) ) {
890 continue;
891 }
892
893 $todo[] = $url;
894 }
895
896 if ( empty( $todo ) ) {
897 $result['ms'] = (int) round( ( microtime( true ) - $started ) * 1000 );
898
899 return $result;
900 }
901
902 /* Intermediate sizes are what make sideloading expensive, and nothing on the page
903 * needs them to render - WordPress falls back to the full file for a size that is
904 * not in the metadata. Regenerated on cron immediately after. */
905 $skip_sizes = function () {
906 return array();
907 };
908
909 add_filter( 'intermediate_image_sizes_advanced', $skip_sizes, 999 );
910
911 $retained = 0;
912
913 foreach ( array_chunk( $todo, max( 1, (int) $concurrency ) ) as $batch ) {
914 if ( $retained >= self::PREFETCH_MAX_TOTAL_BYTES ) {
915 break;
916 }
917
918 foreach ( self::fetch_batch( $batch ) as $url => $payload ) {
919 /* fetch_batch() already drops a non-200, an empty body and anything over
920 * PREFETCH_MAX_BYTES; this is the across-the-batch cap on top of the
921 * per-file one. */
922 if ( '' === $payload || ( $retained + strlen( $payload ) ) > self::PREFETCH_MAX_TOTAL_BYTES ) {
923 ++$result['failed'];
924
925 continue;
926 }
927
928 if ( self::store_prefetched( $url, $payload ) > 0 ) {
929 ++$result['fetched'];
930 $retained += strlen( $payload );
931 } else {
932 ++$result['failed'];
933 }
934 }
935 }
936
937 remove_filter( 'intermediate_image_sizes_advanced', $skip_sizes, 999 );
938
939 if ( $result['fetched'] > 0 ) {
940 self::schedule_thumbnail_regeneration();
941 }
942
943 $result['ms'] = (int) round( ( microtime( true ) - $started ) * 1000 );
944
945 return $result;
946 }
947
948 /**
949 * Every image URL in a blob of markup or JSON.
950 *
951 * Deliberately shape-agnostic - it does not care whether the URL is an attribute value, a
952 * srcset entry or embedded in innerHTML. Escaped slashes are handled because block
953 * attribute JSON stores them that way.
954 *
955 * @param string $text Raw content.
956 * @return string[]
957 */
958 private static function image_urls_in_text( $text ) {
959 if ( ! is_string( $text ) || '' === $text ) {
960 return array();
961 }
962
963 $text = str_replace( '\\/', '/', $text );
964
965 preg_match_all( '#https?://[^\s"\'<>\\\\()]+?\.(?:jpe?g|png|gif|svg|webp|avif)#i', $text, $matches );
966
967 if ( empty( $matches[0] ) ) {
968 return array();
969 }
970
971 $out = array();
972
973 foreach ( array_unique( $matches[0] ) as $url ) {
974 /* Only remote files. A URL already on this site needs no fetching, and a data: or
975 * relative reference is not fetchable. */
976 if ( false === strpos( $url, '://' ) ) {
977 continue;
978 }
979
980 if ( false !== strpos( $url, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) {
981 continue;
982 }
983
984 $out[] = $url;
985 }
986
987 return $out;
988 }
989
990 /**
991 * The attachment already holding this source URL, if any.
992 *
993 * Same key and same hash Wdkit_Import_Images uses, so the two agree on what "already
994 * imported" means.
995 *
996 * @param string $url Source URL.
997 * @return int Attachment id, or 0.
998 */
999 public static function existing_attachment_for( $url ) {
1000 global $wpdb;
1001
1002 return (int) $wpdb->get_var(
1003 $wpdb->prepare(
1004 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'tpgb_source_image_key' AND meta_value = %s LIMIT 1",
1005 sha1( $url )
1006 )
1007 );
1008 }
1009
1010 /**
1011 * A usable attachment already downloaded from exactly this URL, if any.
1012 *
1013 * existing_attachment_for() cannot answer this for a sideloaded file: the hash it
1014 * matches is stamped from the LOCAL url, so it never equals the source's. `_source_url`
1015 * holds the source itself - written by store_prefetched() below and by core's
1016 * media_sideload_image() - which is what lets a blog post reuse the picked image a page
1017 * already imported instead of downloading it again (a Taj Bakery run had eight copies
1018 * of one Pexels photo).
1019 *
1020 * @param string $url Source URL.
1021 * @return int Attachment id, or 0.
1022 */
1023 public static function attachment_for_source_url( $url ) {
1024 global $wpdb;
1025
1026 if ( ! is_string( $url ) || '' === $url ) {
1027 return 0;
1028 }
1029
1030 $id = (int) $wpdb->get_var(
1031 $wpdb->prepare(
1032 "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1033 WHERE pm.meta_key = '_source_url' AND pm.meta_value = %s AND p.post_type = 'attachment' ORDER BY pm.post_id ASC LIMIT 1",
1034 $url
1035 )
1036 );
1037
1038 if ( ! $id ) {
1039 return 0;
1040 }
1041
1042 $file = get_attached_file( $id );
1043
1044 return ( $file && file_exists( $file ) ) ? $id : 0;
1045 }
1046
1047 /**
1048 * Would WP_Http let this request out?
1049 *
1050 * The prefetch talks to curl directly, so none of the site's own HTTP configuration
1051 * applies to it. When the site blocks external HTTP (WP_HTTP_BLOCK_EXTERNAL, with
1052 * WP_ACCESSIBLE_HOSTS as the allowlist) or routes through a proxy, the answer is to
1053 * skip warming and let the serial sideload path — which goes through WP_Http — handle
1054 * the URL properly. Mirrors Wdkit_Import_Images::prefetch_allowed_by_http_policy().
1055 *
1056 * @param string $url URL about to be warmed.
1057 * @return bool True when the request may be made directly.
1058 */
1059 private static function prefetch_allowed_by_http_policy( $url ) {
1060 $host = (string) wp_parse_url( $url, PHP_URL_HOST );
1061
1062 if ( '' === $host ) {
1063 return false;
1064 }
1065
1066 if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ) {
1067 $accessible = defined( 'WP_ACCESSIBLE_HOSTS' ) ? preg_split( '|,\s*|', WP_ACCESSIBLE_HOSTS ) : array();
1068 $allowed = false;
1069
1070 foreach ( (array) $accessible as $pattern ) {
1071 $pattern = trim( (string) $pattern );
1072
1073 if ( '' === $pattern ) {
1074 continue;
1075 }
1076
1077 // WP_ACCESSIBLE_HOSTS supports a leading wildcard, e.g. *.example.com.
1078 $regex = '|^' . str_replace( '\*\.', '([a-zA-Z0-9-]+\.)*', preg_quote( $pattern, '|' ) ) . '$|i';
1079
1080 if ( preg_match( $regex, $host ) ) {
1081 $allowed = true;
1082 break;
1083 }
1084 }
1085
1086 if ( ! $allowed ) {
1087 return false;
1088 }
1089 }
1090
1091 if ( class_exists( 'WP_HTTP_Proxy' ) ) {
1092 $proxy = new WP_HTTP_Proxy();
1093
1094 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
1095 return false;
1096 }
1097 }
1098
1099 return true;
1100 }
1101
1102 /**
1103 * Fetch a batch of URLs concurrently.
1104 *
1105 * @param string[] $urls URLs.
1106 * @return array<string,string> url => body, empty string on failure.
1107 */
1108 private static function fetch_batch( $urls ) {
1109 $out = array();
1110 $multi = curl_multi_init();
1111 $handles = array();
1112
1113 $has_progress = defined( 'CURLOPT_XFERINFOFUNCTION' );
1114
1115 foreach ( $urls as $url ) {
1116 $handle = curl_init( $url );
1117
1118 $options = array(
1119 CURLOPT_RETURNTRANSFER => true,
1120 /* Redirects are NOT followed: wdesignkit_validate_external_url() checked the
1121 * URL we were given, and curl would not re-check a Location hop - so a 302
1122 * to http://169.254.169.254/ or an internal host would slip straight past
1123 * the gate. A redirecting URL is left uncached and falls through to the
1124 * serial sideload path, which re-validates every hop. */
1125 CURLOPT_FOLLOWLOCATION => false,
1126 CURLOPT_TIMEOUT => 30,
1127 CURLOPT_CONNECTTIMEOUT => 10,
1128 CURLOPT_SSL_VERIFYPEER => true,
1129 CURLOPT_SSL_VERIFYHOST => 2,
1130 CURLOPT_USERAGENT => 'WDesignKit/' . ( defined( 'WDKIT_VERSION' ) ? WDKIT_VERSION : '1' ),
1131 // Refuses up front when the server declares an oversized body.
1132 CURLOPT_MAXFILESIZE => self::PREFETCH_MAX_BYTES,
1133 );
1134
1135 /* And catches the servers that declare nothing: abort a chunked/undeclared
1136 * response once it passes the cap rather than buffering it in full. */
1137 if ( $has_progress ) {
1138 $options[ CURLOPT_NOPROGRESS ] = false;
1139 $options[ CURLOPT_XFERINFOFUNCTION ] = static function ( $handle, $expected, $received ) {
1140 unset( $handle, $expected );
1141
1142 return ( $received > self::PREFETCH_MAX_BYTES ) ? 1 : 0;
1143 };
1144 }
1145
1146 curl_setopt_array( $handle, $options );
1147
1148 curl_multi_add_handle( $multi, $handle );
1149
1150 $handles[ $url ] = $handle;
1151 }
1152
1153 do {
1154 $status = curl_multi_exec( $multi, $running );
1155
1156 if ( $running ) {
1157 curl_multi_select( $multi, 1.0 );
1158 }
1159 } while ( $running && CURLM_OK === $status );
1160
1161 foreach ( $handles as $url => $handle ) {
1162 $body = curl_multi_getcontent( $handle );
1163 $code = (int) curl_getinfo( $handle, CURLINFO_RESPONSE_CODE );
1164
1165 $out[ $url ] = ( 200 === $code && is_string( $body ) && '' !== $body && strlen( $body ) <= self::PREFETCH_MAX_BYTES ) ? $body : '';
1166
1167 curl_multi_remove_handle( $multi, $handle );
1168 curl_close( $handle );
1169 }
1170
1171 curl_multi_close( $multi );
1172
1173 return $out;
1174 }
1175
1176 /**
1177 * Write one downloaded payload into the media library.
1178 *
1179 * @param string $url Source URL.
1180 * @param string $payload File bytes.
1181 * @return int Attachment id, or 0.
1182 */
1183 private static function store_prefetched( $url, $payload ) {
1184 $name = self::filename_for( $url );
1185
1186 if ( '' === $name ) {
1187 return 0;
1188 }
1189
1190 $upload = wp_upload_bits( $name, null, $payload );
1191
1192 if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) {
1193 return 0;
1194 }
1195
1196 $type = wp_check_filetype( $upload['file'] );
1197
1198 if ( empty( $type['type'] ) ) {
1199 wp_delete_file( $upload['file'] );
1200
1201 return 0;
1202 }
1203
1204 $attachment_id = wp_insert_attachment(
1205 array(
1206 'post_title' => $name,
1207 'guid' => $upload['url'],
1208 'post_mime_type' => $type['type'],
1209 ),
1210 $upload['file']
1211 );
1212
1213 if ( is_wp_error( $attachment_id ) || ! $attachment_id ) {
1214 wp_delete_file( $upload['file'] );
1215
1216 return 0;
1217 }
1218
1219 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1220 require_once ABSPATH . 'wp-admin/includes/image.php';
1221 }
1222
1223 wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) );
1224
1225 /* Both importers key off these. Stamped for the SOURCE url so the serial loop finds
1226 * this file instead of downloading it, and for the LOCAL url because a later pass
1227 * over already-rewritten content looks it up that way. */
1228 update_post_meta( $attachment_id, 'tpgb_source_image_key', sha1( $url ) );
1229 update_post_meta( $attachment_id, '_elementor_source_image_hash', sha1( $url ) );
1230 update_post_meta( $attachment_id, '_source_url', $url );
1231 update_post_meta( $attachment_id, '_wdkit_thumbs_pending', 1 );
1232
1233 return (int) $attachment_id;
1234 }
1235
1236 /**
1237 * A safe upload filename for a source URL.
1238 *
1239 * @param string $url Source URL.
1240 * @return string
1241 */
1242 private static function filename_for( $url ) {
1243 $path = wp_parse_url( $url, PHP_URL_PATH );
1244 $name = is_string( $path ) ? wp_basename( $path ) : '';
1245 $name = sanitize_file_name( urldecode( $name ) );
1246
1247 return ( '' !== $name && false !== strpos( $name, '.' ) ) ? $name : '';
1248 }
1249
1250 /**
1251 * Ask cron to build the thumbnail sizes the import skipped.
1252 *
1253 * @return void
1254 */
1255 private static function schedule_thumbnail_regeneration() {
1256 if ( ! wp_next_scheduled( 'wdkit_regenerate_import_thumbnails' ) ) {
1257 wp_schedule_single_event( time() + 30, 'wdkit_regenerate_import_thumbnails' );
1258 }
1259 }
1260
1261 /**
1262 * Re-queue the thumbnail pass if attachments are still waiting on it and nothing is queued.
1263 *
1264 * The content lanes schedule it concurrently, and each wp_schedule_single_event() rewrites
1265 * the whole `cron` option, so a write from a lane holding a stale copy can drop it. The
1266 * runner's finalize stage - a single request - calls this so the pass cannot be lost.
1267 *
1268 * @return void
1269 */
1270 public static function ensure_thumbnail_regeneration() {
1271 global $wpdb;
1272
1273 if ( $wpdb->get_var( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wdkit_thumbs_pending' LIMIT 1" ) ) {
1274 self::schedule_thumbnail_regeneration();
1275 }
1276 }
1277
1278 /**
1279 * Build intermediate sizes for prefetched attachments, a batch at a time.
1280 *
1281 * Reschedules itself while any remain, so a large import does not try to resize several
1282 * hundred images inside one cron request.
1283 *
1284 * @return void
1285 */
1286 public static function regenerate_pending_thumbnails() {
1287 global $wpdb;
1288
1289 $ids = $wpdb->get_col(
1290 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wdkit_thumbs_pending' LIMIT 25"
1291 );
1292
1293 if ( empty( $ids ) ) {
1294 return;
1295 }
1296
1297 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1298 require_once ABSPATH . 'wp-admin/includes/image.php';
1299 }
1300
1301 /* Next pass booked BEFORE the work, not after it. Resizing large stock photos can
1302 * outrun the host's max_execution_time, and a pass that died mid-batch never reached
1303 * the old reschedule at the end - the remaining images then never got their sizes.
1304 * Seen on a 30s host: a fatal in the image editor, and the queue stopped there. */
1305 if ( ! wp_next_scheduled( 'wdkit_regenerate_import_thumbnails' ) ) {
1306 wp_schedule_single_event( time() + 10, 'wdkit_regenerate_import_thumbnails' );
1307 }
1308
1309 if ( function_exists( 'set_time_limit' ) ) {
1310 @set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- no-op where disabled.
1311 }
1312
1313 /* And stop well inside the default 30s, for hosts where the limit cannot be raised.
1314 * Whatever is left keeps its pending flag for the pass booked above. */
1315 $deadline = microtime( true ) + 20;
1316
1317 foreach ( $ids as $id ) {
1318 if ( microtime( true ) > $deadline ) {
1319 break;
1320 }
1321
1322 $file = get_attached_file( (int) $id );
1323
1324 if ( $file && file_exists( $file ) ) {
1325 wp_update_attachment_metadata( (int) $id, wp_generate_attachment_metadata( (int) $id, $file ) );
1326 }
1327
1328 delete_post_meta( (int) $id, '_wdkit_thumbs_pending' );
1329 }
1330
1331 /* Nothing left: drop the pass booked above rather than run an empty one. */
1332 if ( ! $wpdb->get_var( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wdkit_thumbs_pending' LIMIT 1" ) ) {
1333 wp_clear_scheduled_hook( 'wdkit_regenerate_import_thumbnails' );
1334 }
1335 }
1336
1337 }
1338 }
1339