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 / class-api.php

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

11,884 lines 437.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The file that defines the core plugin class
4 *
5 * @link https://posimyth.com/
6 * @since 1.0.0
7 *
8 * @package Wdesignkit
9 * @subpackage Wdesignkit/includes
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 use wdkit\Wdkit_Wdesignkit;
17 use wdkit\WdKit_enqueue\Wdkit_Enqueue;
18 use wdkit\wdkit_datahooks\Wdkit_Data_Hooks;
19
20 if ( ! class_exists( 'Wdkit_Api_Call' ) ) {
21
22 /**
23 * Main classs call for all api
24 *
25 * @link https://posimyth.com/
26 * @since 1.0.0
27 */
28 class Wdkit_Api_Call {
29
30 /**
31 * Member Variable
32 *
33 * @var instance
34 */
35 private static $instance;
36
37 /**
38 * Member Variable
39 *
40 * @var staring widgets_with_post_category
41 */
42 public $widgets_with_post_category = array(
43 'post_category', 'include_products',
44 );
45
46 /**
47 * Member Variable
48 *
49 * @var staring $wdkit_api
50 */
51 public $wdkit_api = WDKIT_SERVER_API_URL . 'api/wp/';
52
53 /**
54 * Member Variable
55 *
56 * @var staring $wdkit_api_v2
57 */
58 public $wdkit_api_v2 = WDKIT_SERVER_API_URL . 'api/v2/wp/';
59
60 /**
61 * Member Variable
62 *
63 * @var staring $widget_folder_u_r_l
64 */
65 public $widget_folder_u_r_l = '';
66
67 /**
68 * Member Variable
69 *
70 * @var staring $e_msg_login
71 */
72 public $e_msg_login = 'Login Error: Check your details and try again.';
73
74 /**
75 * Member Variable
76 *
77 * @var staring $e_desc_login
78 */
79 public $e_desc_login = 'Invalid Login Details';
80
81 /**
82 * Member Variable
83 *
84 * @var staring wdkit_onbording_api
85 */
86 public $wdkit_onbording_api = 'https://api.posimyth.com/wp-json/wdkit/v2/wdkit_store_user_data';
87
88 /**
89 * Member Variable
90 *
91 * @var staring wdkit_onbording_end
92 */
93 public $wdkit_onbording_end = 'wkit_onbording_end';
94
95 /**
96 * Initiator
97 */
98 public static function get_instance() {
99 if ( ! isset( self::$instance ) ) {
100 self::$instance = new self();
101 }
102
103 return self::$instance;
104 }
105
106 /**
107 * Define the core functionality of the plugin.
108 */
109 public function __construct() {
110 add_action( 'wp_ajax_get_wdesignkit', array( $this, 'wdkit_api_call' ) );
111
112 // Background half of the deferred-media-sideload flow (see
113 // wkit_schedule_deferred_media_sync()/import_page_section_content()): each kit page
114 // is created instantly with its source CDN image URLs still in place, and this cron
115 // event does the slow media_sideload_image() work afterwards, off the import request.
116 add_action( 'wdkit_async_sideload_page_images', array( $this, 'wdkit_async_sideload_page_images' ), 10, 3 );
117 }
118
119 /**
120 * Error JSON message
121 *
122 * @param array $data give array.
123 * @param string $status api code number.
124 *
125 * @since 1.0.0
126 * */
127 public function wdkit_error_msg( $data = null, $status = null ) {
128 wp_send_json_error( $data );
129 wp_die();
130 }
131
132 /**
133 * Success JSON message
134 *
135 * @param array $data give array.
136 * @param string $status api code number.
137 *
138 * @since 1.0.0
139 * */
140 public function wdkit_success_msg( $data = null, $status = null ) {
141 wp_send_json_success( $data, $status );
142 wp_die();
143 }
144
145
146 /**
147 * Memory headroom left for image work, in bytes. 0 means unlimited.
148 */
149 private static function wdkit_available_image_memory() {
150 $limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
151
152 if ( $limit <= 0 ) {
153 return 0;
154 }
155
156 return max( 0, $limit - memory_get_usage( true ) );
157 }
158
159 /**
160 * Stop WordPress decoding images that cannot fit in the memory available.
161 *
162 * Both filters are consulted by wp_create_image_subsizes() *before* it loads an image
163 * editor, so refusing here means the oversized image is never decoded:
164 *
165 * big_image_size_threshold -> falsy skips the "-scaled" copy (needs a full decode)
166 * intermediate_image_sizes_advanced -> empty makes _wp_make_subsizes() return early,
167 * ahead of its wp_get_image_editor() call
168 *
169 * The original file is still attached and usable; only the derived sizes are skipped.
170 * That trades ideal thumbnails for an import that completes, instead of a fatal that
171 * takes the whole page down and repeats on every retry.
172 *
173 * @since 2.6.2
174 */
175 private static function wdkit_guard_oversized_images() {
176 static $registered = false;
177
178 // Registering twice would stack duplicate closures on both filters.
179 if ( $registered ) {
180 return;
181 }
182
183 $registered = true;
184
185 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
186 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
187 }
188
189 add_filter(
190 'big_image_size_threshold',
191 function ( $threshold, $imagesize = array(), $file = '', $attachment_id = 0 ) {
192 if ( ! empty( $imagesize[0] ) && ! empty( $imagesize[1] )
193 && ! Wdkit_Image_Guard::decode_fits( $imagesize[0], $imagesize[1], self::wdkit_available_image_memory() )
194 ) {
195 return false;
196 }
197
198 return $threshold;
199 },
200 99,
201 4
202 );
203
204 add_filter(
205 'intermediate_image_sizes_advanced',
206 function ( $sizes, $image_meta = array(), $attachment_id = 0 ) {
207 if ( ! empty( $image_meta['width'] ) && ! empty( $image_meta['height'] )
208 && ! Wdkit_Image_Guard::decode_fits( $image_meta['width'], $image_meta['height'], self::wdkit_available_image_memory() )
209 ) {
210 return array();
211 }
212
213 return $sizes;
214 },
215 99,
216 3
217 );
218 }
219
220 /**
221 * Get Wdkit Api Call Ajax.
222 */
223 public function wdkit_api_call() {
224
225 check_ajax_referer( 'wdkit_nonce', 'kit_nonce' );
226
227 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
228 wp_send_json_error( array( 'content' => __( 'Insufficient permissions.', 'wdesignkit' ) ) );
229 }
230
231 $type = isset( $_POST['type'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['type'] ) ) ) : false;
232 if ( ! $type ) {
233 $this->wdkit_error_msg( __( 'Something went wrong.', 'wdesignkit' ) );
234 }
235
236 switch ( $type ) {
237 case 'onboarding_handler':
238 $data = $this->wdkit_onboarding_handler();
239 break;
240 case 'wkit_login':
241 $data = apply_filters( 'wp_wdkit_login_ajax', 'wkit_login' );
242 break;
243 case 'api_login':
244 $data = apply_filters( 'wp_wdkit_login_ajax', 'api_login' );
245 break;
246 case 'social_login':
247 $data = apply_filters( 'wp_wdkit_login_ajax', 'social_login' );
248 break;
249 case 'forgot_password':
250 $data = apply_filters( 'wp_wdkit_login_ajax', 'forgot_password' );
251 break;
252 case 'wdkit_user_signup':
253 $data = apply_filters( 'wp_wdkit_login_ajax', 'wdkit_user_signup' );
254 break;
255 case 'wkit_meta_data':
256 $data = $this->wdkit_meta_data();
257 break;
258 case 'get_user_info':
259 $data = $this->wdkit_get_user_info();
260 break;
261 case 'browse_page':
262 $data = $this->wdkit_browse_page();
263 break;
264 case 'kit_template':
265 $data = $this->wdkit_template();
266 break;
267 case 'wkit_preset_template':
268 $data = apply_filters( 'wp_wdkit_preset_ajax', 'wdkit_preset_template' );
269 break;
270 case 'wdkit_preset_dwnld_template':
271 $data = apply_filters( 'wp_wdkit_preset_ajax', 'wdkit_preset_dwnld_template' );
272 break;
273 case 'template_remove':
274 $data = $this->wdkit_template_remove();
275 break;
276 case 'save_template':
277 $data = $this->wdkit_put_save_template();
278 break;
279 case 'update_save_temp_image':
280 $data = $this->wdkit_update_save_temp_image();
281 break;
282 case 'save_wp_images':
283 $data = $this->wdkit_save_wp_images();
284 break;
285 case 'wkit_schedule_deferred_media_sync':
286 $data = $this->wkit_schedule_deferred_media_sync();
287 break;
288 case 'wkit_run_deferred_media_now':
289 $data = $this->wkit_run_deferred_media_now();
290 break;
291 case 'get_global_val':
292 $data = $this->wdkit_get_global_val();
293 break;
294 case 'update_global_val':
295 $data = $this->wdkit_update_global_val();
296 break;
297 case 'wdkit_get_site_setting':
298 $data = $this->wdkit_get_site_setting();
299 break;
300 case 'wdkit_update_site_setting':
301 $data = $this->wdkit_update_site_setting();
302 break;
303 case 'update_preset_setting':
304 $data = $this->wdkit_update_preset();
305 break;
306 case 'find_template':
307 $data = $this->wdkit_find_existing_template();
308 break;
309 case 'update_template':
310 $data = $this->wdkit_update_template();
311 break;
312 case 'manage_favorite':
313 $data = $this->wdkit_manage_favorite();
314 break;
315 case 'check_plugins_depends':
316 $data = $this->wdkit_check_plugins_depends();
317 break;
318 case 'install_plugins_depends':
319 $data = $this->wdkit_install_plugins_depends();
320 break;
321 case 'install_plugins_depends_batch':
322 $data = $this->wdkit_install_plugins_depends_batch();
323 break;
324 case 'generate_site_logo':
325 $data = $this->wkit_generate_site_logo();
326 break;
327 case 'generate_ai_content':
328 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content' );
329 break;
330 case 'generate_ai_content_batch':
331 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content_batch' );
332 break;
333 case 'reset_site':
334 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'reset_site' );
335 break;
336 case 'wdkit_nxt_thembuilder_reset':
337 $data = $this->wdkit_nxt_thembuilder_reset();
338 break;
339 case 'wdkit_check_user_credit':
340 $data = $this->wdkit_check_user_credit();
341 break;
342 case 'wdkit_remove_header_footer':
343 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wdkit_remove_header_footer' );
344 break;
345 case 'check_post_count':
346 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'check_post_count' );
347 break;
348 case 'wkit_check_product_count':
349 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_check_product_count' );
350 break;
351 case 'select_team_img':
352 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'select_team_img' );
353 break;
354 case 'wkit_ai_desc_keyword':
355 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_ai_desc_keyword' );
356 break;
357 case 'wkit_ai_credit_update':
358 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_ai_credit_update' );
359 break;
360 case 'wkit_generate_post_data':
361 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_generate_post_data' );
362 break;
363 case 'wkit_generate_product_data':
364 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_generate_product_data' );
365 break;
366 case 'wkit_cteate_product':
367 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_cteate_product' );
368 break;
369 case 'wkit_remove_dummy_post':
370 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'wkit_remove_dummy_post' );
371 break;
372 case 'update_latest_plugin':
373 $data = $this->wdkit_update_latest_plugin();
374 break;
375 case 'activate_container':
376 $data = $this->wdkit_activate_container();
377 break;
378 case 'import_taxonomy':
379 $data = $this->wdkit_import_taxonomy();
380 break;
381 case 'import_template':
382 $data = $this->wdkit_import_template();
383 break;
384 case 'import_multi_template':
385 $data = $this->wdkit_import_multi_template();
386 break;
387 case 'import_page_section':
388 $data = $this->import_page_section_content();
389 break;
390 case 'wdkit_import_stage':
391 $data = $this->wdkit_import_stage();
392 break;
393 case 'wkit_update_elementor_template':
394 $data = $this->wkit_update_elementor_template();
395 break;
396 case 'wdkit_update_page_content':
397 $data = $this->wdkit_update_page_content();
398 break;
399 case 'update_plugin_setting':
400 $data = $this->update_plugin_setting();
401 break;
402 case 'update_theme_setting':
403 $data = $this->update_theme_setting();
404 break;
405 case 'update_site_setting':
406 $data = $this->update_site_setting();
407 break;
408 case 'import_kit_template':
409 $data = $this->wdkit_import_kit_template();
410 break;
411 case 'wkit_import_kit_bundle':
412 $data = $this->wkit_import_kit_bundle();
413 break;
414 case 'wkit_fetch_site_bundle':
415 $data = $this->wkit_fetch_site_bundle();
416 break;
417 case 'wkit_import_site_bundle':
418 $data = $this->wkit_import_site_bundle();
419 break;
420 case 'enable_template_widgets':
421 $data = $this->wdkit_enable_template_widgets();
422 break;
423 case 'scan_nexter_widgets':
424 if ( ! empty( $_POST['blockNames'] ) && has_filter( 'nexter_block_list_merge' ) ) {
425
426 $posted_blocks = json_decode( stripslashes( $_POST['blockNames'] ), true );
427
428 if ( is_array( $posted_blocks ) ) {
429 $blockList = array_map( 'sanitize_text_field', $posted_blocks );
430
431 // अब filter call करो
432 $result = apply_filters( 'nexter_block_list_merge', $blockList );
433
434 wp_send_json( $result );
435 wp_die();
436 }
437 }
438
439 wp_send_json(
440 array(
441 'success' => false,
442 'message' => __( 'No block names received or filter not found.', 'wdesignkit' ),
443 'description' => 'Ensure blockNames are posted and the filter is attached.',
444 )
445 );
446 wp_die();
447 $data = '';
448 break;
449 case 'shared_with_me':
450 $data = $this->wdkit_shared_with_me();
451 break;
452 case 'manage_workspace':
453 $data = $this->wdkit_manage_workspace();
454 break;
455 case 'widget_browse_page':
456 $data = apply_filters( 'wp_wdkit_widget_ajax', 'widget_browse_page' );
457 break;
458 case 'wkit_create_widget':
459 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_create_widget' );
460 break;
461 case 'wkit_import_widget':
462 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_import_widget' );
463 break;
464 case 'wkit_export_widget':
465 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_export_widget' );
466 break;
467 case 'wkit_delete_widget':
468 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_delete_widget' );
469 break;
470 case 'wkit_widget_preview':
471 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_widget_preview' );
472 break;
473 case 'wkit_check_widget_versions':
474 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_check_widget_versions' );
475 break;
476 case 'wkit_plugin_download_get':
477 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_plugin_download_get' );
478 break;
479 case 'wkit_manage_widget_workspace':
480 $data = $this->wdkit_manage_widget_workspace();
481 break;
482 case 'wkit_activate_key':
483 $data = $this->wdkit_activate_key();
484 break;
485 case 'wkit_manage_widget_category':
486 $data = $this->wdkit_manage_widget_category();
487 break;
488 case 'wkit_widget_json':
489 $data = $this->wkit_widget_json();
490 break;
491 case 'wkit_download_widget':
492 $data = $this->wdkit_download_widget();
493 break;
494 case 'wkit_public_download_widget':
495 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_public_download_widget' );
496 break;
497 case 'wkit_add_widget':
498 $data = $this->wdkit_add_widget();
499 break;
500 case 'wkit_favourite_widget':
501 $data = $this->wdkit_favourite_widget();
502 break;
503 case 'wkit_setting_panel':
504 $data = $this->wdkit_setting_panel();
505 break;
506 case 'media_import':
507 $data = $this->wdkit_media_import();
508 break;
509 case 'active_licence':
510 $data = $this->wdkit_activate_licence();
511 break;
512 case 'delete_licence':
513 $data = $this->wdkit_delete_licence_key();
514 break;
515 case 'sync_licence':
516 $data = $this->wdkit_sync_licence_key();
517 break;
518 case 'get_wkit_version':
519 $data = $this->wdkit_prev_version();
520 break;
521 case 'rollback_wdkit':
522 $data = $this->wdkit_rollback_check();
523 break;
524 case 'wkit_logout':
525 $data = $this->wdkit_logout();
526 break;
527 case 'wkit_white_label':
528 $this->wkit_white_label();
529 break;
530 case 'wkit_reset_wl':
531 $data = $this->wkit_reset_wl();
532 break;
533 case 'wdkit_dark_mode':
534 $data = $this->wdkit_dark_mode();
535 break;
536 case 'wdkit_get_workspace_data':
537 $data = $this->wdkit_get_workspace_data();
538 break;
539 default:
540 $this->wdkit_error_msg( __( 'Unknown request type.', 'wdesignkit' ) );
541 return;
542 }
543
544 $this->wdkit_success_msg( $data );
545 // wp_die();
546 }
547
548 /**
549 *
550 * This Function is used for API call
551 *
552 * @since 1.0.0
553 *
554 * @param array $data give array.
555 * @param array $name store data.
556 * @param int $timeout optional HTTP timeout in seconds. Default 100.
557 */
558 protected function wkit_api_call( $data, $name, $timeout = 100 ) {
559 $u_r_l = $this->wdkit_api;
560
561 if ( empty( $u_r_l ) ) {
562 return array(
563 'massage' => esc_html__( 'API Not Found', 'wdesignkit' ),
564 'success' => false,
565 );
566 }
567
568 $args = array(
569 'method' => 'POST',
570 'body' => $data,
571 'timeout' => $timeout,
572 );
573 $response = wp_remote_post( $u_r_l . $name, $args );
574
575 if ( is_wp_error( $response ) ) {
576 $error_message = $response->get_error_message();
577
578 /* Translators: %s is a placeholder for the error message */
579 $error_message = sprintf( esc_html__( 'API request error: %s', 'wdesignkit' ), esc_html( $error_message ) );
580
581 return array(
582 'massage' => $error_message,
583 'success' => false,
584 );
585 }
586
587 $status_code = wp_remote_retrieve_response_code( $response );
588 if ( 200 === $status_code ) {
589
590 return array(
591 'data' => json_decode( wp_remote_retrieve_body( $response ) ),
592 'massage' => esc_html__( 'Success', 'wdesignkit' ),
593 'status' => $status_code,
594 'success' => true,
595 );
596 }
597
598 $error_message = sprintf( 'Server error: %d', esc_html( $status_code ) );
599
600 if ( isset( $error_data->message ) ) {
601 $error_message .= ' (' . $error_data->message . ')';
602 }
603
604 return array(
605 'massage' => $error_message,
606 'status' => $status_code,
607 'success' => false,
608 );
609 }
610
611 /**
612 *
613 * It is Use for handle onboarding data.
614 *
615 * @since 1.0.9
616 */
617 public function wdkit_onboarding_handler() {
618 $page_template = isset( $_POST['page_template'] ) ? json_decode( wp_unslash( $_POST['page_template'] ), true ) : '';
619 $page_builder = isset( $_POST['page_builder'] ) ? json_decode( wp_unslash( $_POST['page_builder'] ), true ) : '';
620
621 $elementor_plugin = isset( $_POST['elementor_plugin'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['elementor_plugin'] ) ) : 0;
622 $tpag_plugin = isset( $_POST['tpag_plugin'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['tpag_plugin'] ) ) : 0;
623 $bricks_theme = isset( $_POST['bricks_theme'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['bricks_theme'] ) ) : 0;
624 $site_info = isset( $_POST['info'] ) ? sanitize_text_field( wp_unslash( $_POST['info'] ) ) : false;
625
626 $server_software = ! empty( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '';
627
628 $web_server = $server_software;
629 $memory_limit = ini_get( 'memory_limit' );
630 $max_execution_time = ini_get( 'max_execution_time' );
631 $php_version = phpversion();
632 $wp_version = get_bloginfo( 'version' );
633 $email = get_option( 'admin_email' );
634 $siteurl = get_option( 'siteurl' );
635 $language = get_bloginfo( 'language' );
636
637 // Active Plugin Name.
638 $act_plugin = array();
639 $actplu = get_option( 'active_plugins' );
640 if ( ! function_exists( 'get_plugins' ) ) {
641 require_once ABSPATH . 'wp-admin/includes/plugin.php';
642 }
643
644 $plugins = get_plugins();
645 foreach ( $actplu as $p ) {
646 if ( isset( $plugins[ $p ] ) ) {
647 $act_plugin[] = $plugins[ $p ]['Name'];
648 }
649 }
650
651 $plugin = wp_json_encode( $act_plugin );
652
653 $theme = '';
654 $acthemeobj = wp_get_theme();
655 if ( $acthemeobj->get( 'Name' ) !== null && ! empty( $acthemeobj->get( 'Name' ) ) ) {
656 $theme = $acthemeobj->get( 'Name' );
657 }
658
659 $basic_requirements = array(
660 'elementor_install' => $elementor_plugin,
661 'tpag_install' => $tpag_plugin,
662 'bricks_install' => $bricks_theme,
663 );
664
665 if ( ! empty( $site_info ) ) {
666 $final = array(
667 'web_server' => $web_server,
668 'memory_limit' => $memory_limit,
669 'max_execution_time' => $max_execution_time,
670 'php_version' => $php_version,
671 'wp_version' => $wp_version,
672 'email' => $email,
673 'site_url' => $siteurl,
674 'site_language' => $language,
675 'theme' => $theme,
676 'plugins' => $act_plugin,
677 'basic_requirements' => $basic_requirements,
678 'page_template' => $page_template,
679 'page_builder' => $page_builder,
680 );
681 } else {
682 $final = array();
683 }
684
685 $response = wp_remote_post(
686 $this->wdkit_onbording_api,
687 array(
688 'method' => 'POST',
689 'body' => wp_json_encode( $final ),
690 )
691 );
692
693 $existing_value = get_option( $this->wdkit_onbording_end );
694 if ( false === $existing_value ) {
695 add_option( $this->wdkit_onbording_end, true );
696 }
697
698 if ( is_wp_error( $response ) ) {
699 $result = array(
700 'success' => false,
701 'messages' => 'Oops',
702 'description' => 'Description',
703 );
704
705 wp_send_json( $result );
706 } else {
707 $status_one = wp_remote_retrieve_response_code( $response );
708
709 if ( 200 === $status_one ) {
710 $get_data_one = wp_remote_retrieve_body( $response );
711
712 $get_res = json_decode( json_decode( $get_data_one, true ), true );
713
714 $result = array(
715 'success' => ! empty( $get_res['success'] ) ? $get_res['success'] : false,
716 'messages' => ! empty( $get_res['messages'] ) ? $get_res['messages'] : 'success',
717 'description' => ! empty( $get_res['description'] ) ? $get_res['description'] : 'Description',
718 );
719
720 wp_send_json( $result );
721 } else {
722
723 $result = array(
724 'success' => false,
725 'messages' => 'Oops',
726 'description' => 'description',
727 );
728
729 wp_send_json( $result );
730 }
731 }
732
733 wp_die();
734 }
735
736 /**
737 *
738 * It is Use for get meta data for non login user
739 *
740 * @since 1.0.0
741 */
742 protected function wdkit_meta_data() {
743 $type = isset( $_POST['meta_type'] ) ? sanitize_text_field( wp_unslash( $_POST['meta_type'] ) ) : '';
744 $data = array( 'type' => $type );
745
746 $response = $this->wkit_api_call( $data, 'meta' );
747 $success = ! empty( $response['success'] ) ? $response['success'] : false;
748 $status = ! empty( $response['status'] ) ? (int) $response['status'] : 400;
749
750 if ( empty( $success ) ) {
751 $result = array(
752 'plugin' => array(),
753 'builder' => array(),
754 'category' => array(),
755 'widgetscategory' => array(),
756 'tags' => array(),
757 'widgetbuilder' => array(),
758
759 'message' => esc_html__( 'Server Error', 'wdesignkit' ),
760 'description' => esc_html__( 'Server Error, Data Not Found', 'wdesignkit' ),
761 'success' => false,
762 );
763
764 wp_send_json( $result );
765 wp_die();
766 }
767
768 $get_data_one = ! empty( $response['data'] ) ? $response['data'] : array();
769 $statuscode = array( 'HTTP_CODE' => $status );
770
771 $final = json_decode( wp_json_encode( $get_data_one ), true );
772
773 $final['Setting'] = self::wkit_get_settings_panel();
774 $final['widget_list'] = $this->wkit_manage_widget_sequence( array() );
775
776 $final = array(
777 'data' => $final,
778 );
779
780 wp_send_json( $final );
781 wp_die();
782 }
783
784 /**
785 *
786 * It is Use for get activate license key data from tpae and nexter blocks.
787 *
788 * @since 1.1.6
789 */
790 protected function wkit_manage_license_data() {
791 $manage_licence = array();
792 $theplus_active_check = is_plugin_active( 'the-plus-addons-for-elementor-page-builder/theplus_elementor_addon.php' );
793 $nexter_active_check = is_plugin_active( 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php' );
794
795 $theplus_licence = get_option( 'tpaep_licence_data', array() );
796
797 // Also require the TPAE Pro plugin to be active (Pro defines THEPLUS_VERSION;
798 // the free plugin defines L_THEPLUS_VERSION). This hides the "found active
799 // key" notice when the Pro plugin is removed even though its licence option
800 // still lingers in the database.
801 if ( ! empty( $theplus_active_check ) && defined( 'THEPLUS_VERSION' ) && ! empty( $theplus_licence ) ) {
802 $manage_licence['tpae'] = $theplus_licence;
803 }
804
805 $nexter_licence = get_option( 'tpgb_activate', array() );
806
807 // Also require the Nexter Blocks Pro plugin to be active (Pro defines
808 // TPGBP_VERSION; the free plugin defines TPGB_VERSION), so the notice hides
809 // when the Pro plugin is removed but its licence option persists.
810 if ( ! empty( $nexter_active_check ) && defined( 'TPGBP_VERSION' ) && ! empty( $nexter_licence ) && ! empty( $nexter_licence['tpgb_activate_key'] ) ) {
811 $tpgb_license_status = get_option( 'tpgbp_license_status', array() );
812 $tpgb_license_status['license_key'] = $nexter_licence['tpgb_activate_key'];
813 $manage_licence['tpag'] = $tpgb_license_status;
814 }
815
816 return $manage_licence;
817 }
818
819 /**
820 *
821 * It is Use for get all info of user.
822 *
823 * @since 1.0.0
824 */
825 protected function wdkit_get_user_info() {
826 $token = isset( $_POST['token'] ) ? wp_unslash( $_POST['token'] ) : false;
827 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
828 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['builder'] ) ) ) : '';
829
830 $site_url = isset( $_POST['site_url'] ) ? esc_url_raw( wp_unslash( $_POST['site_url'] ) ) : '';
831
832 $response = array();
833
834 if ( empty( $token ) ) {
835 $response = array(
836 'success' => false,
837 'message' => $this->e_msg_login,
838 'description' => $this->e_desc_login,
839 );
840
841 wp_send_json( $response );
842 wp_die();
843 }
844
845 // $token = $this->wdkit_login_user_token( $email );
846 $args = array(
847 'token' => $token,
848 'builder' => $builder,
849 'site_url' => $site_url,
850 );
851
852 $response = WDesignKit_Data_Query::get_data( 'get_user_info', $args );
853
854 if ( is_wp_error( $response ) ) {
855 wp_send_json( array(
856 'success' => false,
857 'message' => $response->get_error_message(),
858 'description' => $response->get_error_message(),
859 ) );
860 wp_die();
861 }
862
863 $status = ( ! empty( $response['status'] ) ) ? sanitize_text_field( $response['status'] ) : 'error';
864 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
865
866 /**Condtion user for user logout & expire token*/
867 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
868 delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
869 // Clear stored license data when token expires so banner shows again
870 delete_option( 'wdkit_licence_data' );
871 }
872
873 if ( empty( $response ) ) {
874 $response['login_reset'] = 'yes';
875 }
876
877 // Store WDesignKit license data locally if present in response
878 if ( ! empty( $response['credits']['wdkit_licence'] ) && is_array( $response['credits']['wdkit_licence'] ) ) {
879 $wdkit_licence = $response['credits']['wdkit_licence'];
880 // Handle serialized data
881 if ( is_string( $wdkit_licence ) && is_serialized( $wdkit_licence ) ) {
882 $wdkit_licence = unserialize( $wdkit_licence, array( 'allowed_classes' => false ) );
883 }
884 if ( ! empty( $wdkit_licence ) && is_array( $wdkit_licence ) ) {
885 update_option( 'wdkit_licence_data', $wdkit_licence );
886 }
887 }
888
889 $response['Setting'] = $this->wkit_get_settings_panel();
890 $response['widget_list'] = $this->wkit_manage_widget_sequence( $response );
891 $response['manage_licence'] = $this->wkit_manage_license_data();
892
893 $response = array(
894 'data' => $response,
895 'success' => true,
896 );
897
898 wp_send_json( $response );
899 wp_die();
900 }
901
902 /**
903 *
904 * It is Use for get all widgets local and server.
905 *
906 * @since 1.0.19
907 * @param string $response userinfo store.
908 */
909 protected function wkit_manage_widget_sequence( $response = array() ) {
910
911 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
912 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
913 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
914
915 // Whether this call actually carried the server widget list that activation state is
916 // derived from. Captured before the loops below, which unset() matched $server_list
917 // entries as they go. wdkit_meta_data() calls this method with array(), and without
918 // this flag that call rebuilt $db_widget from local widgets only — every one of which
919 // is forced 'active' further down — and then wrote the empty result over
920 // wkit_deactivate_widgets, erasing every deactivation the user had made.
921 $has_server_widgets = ! empty( $server_list );
922
923 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
924
925 $local_list = $this->wdkit_get_local_widgets();
926
927 $server_w_unique = array_column( $server_list, 'w_unique' );
928
929 $idx_builder = array();
930 foreach ( $db_builder_list as $index => $value ) {
931 $builder_name = ! empty( $value['builder_slug'] ) ? $value['builder_slug'] : '';
932 $w_id = ! empty( $value['w_id'] ) ? $value['w_id'] : '';
933
934 if ( ! empty( $builder_name ) ) {
935 $idx_builder[ $w_id ] = strtolower( str_replace( ' ', '_', trim( $builder_name ) ) );
936 }
937 }
938
939 foreach ( $server_list as $index => $value ) {
940 $get_id = $server_list[ $index ]['builder'] ? $server_list[ $index ]['builder'] : '';
941
942 $server_list[ $index ]['type'] = 'server';
943 $server_list[ $index ]['builder'] = ! empty( $idx_builder[ $get_id ] ) ? $idx_builder[ $get_id ] : '';
944 }
945
946 $count = 0;
947
948 foreach ( $local_list as $key => $value ) {
949 $widget_id = ! empty( $value['widgetdata']['widget_id'] ) ? $value['widgetdata']['widget_id'] : '';
950 $allow_push = isset( $value['widgetdata']['allow_push'] ) ? $value['widgetdata']['allow_push'] : true;
951
952 if ( in_array( $widget_id, $server_w_unique ) ) {
953
954 $index = array_search( $widget_id, $server_w_unique );
955
956 $server_list[ $index ]['title'] = $local_list[ $key ]['widgetdata']['name'];
957 $server_list[ $index ]['w_version'] = $local_list[ $key ]['widgetdata']['widget_version'];
958 $server_list[ $index ]['allow_push'] = $allow_push;
959 $server_list[ $index ]['builder'] = $local_list[ $key ]['widgetdata']['type'];
960 $server_list[ $index ]['w_unique'] = $local_list[ $key ]['widgetdata']['widget_id'];
961 $server_list[ $index ]['image'] = ! empty( $local_list[ $key ]['widgetdata']['w_image'] ) ? $local_list[ $key ]['widgetdata']['w_image'] : $placeholderimg;
962
963 $local_list[ $key ] = $server_list[ $index ];
964
965 $local_list[ $key ]['type'] = 'done';
966
967 unset( $server_list[ $index ] );
968 } else {
969 $local_list[ $key ]['widgetdata']['builder'] = $local_list[ $key ]['widgetdata']['type'];
970 $local_list[ $key ]['widgetdata']['w_unique'] = $local_list[ $key ]['widgetdata']['widget_id'];
971 $local_list[ $key ]['widgetdata']['allow_push'] = $allow_push;
972 $local_list[ $key ]['widgetdata']['image'] = ! empty( $local_list[ $key ]['widgetdata']['w_image'] ) ? $local_list[ $key ]['widgetdata']['w_image'] : $placeholderimg;
973 $local_list[ $key ]['widgetdata']['is_activated'] = 'active';
974
975 $local_list[ $key ]['widgetdata']['type'] = 'plugin';
976
977 $local_list[ $key ]['widgetdata']['title'] = $local_list[ $key ]['widgetdata']['name'];
978 unset( $local_list[ $key ]['widgetdata']['name'] );
979 unset( $local_list[ $key ]['widgetdata']['widget_id'] );
980
981 $local_list[ $key ] = $local_list[ $key ]['widgetdata'];
982 }
983 }
984
985 $final = array_merge( $local_list, $server_list );
986
987 $db_widget = array();
988
989 foreach ( $final as $key => $self ) {
990 $is_activated = ! empty( $final[ $key ]['is_activated'] ) ? $final[ $key ]['is_activated'] : 'active';
991
992 if ( 'active' === $is_activated ) {
993 ++$count;
994 }
995
996 if ( ( $count > $credits ) && ( 'unlimited' !== $credits ) ) {
997 $final[ $key ]['is_activated'] = 'deactive';
998 }
999
1000 if ( ! empty( $self['is_activated'] ) && 'active' !== $self['is_activated'] ) {
1001 $db_widget[] = array(
1002 'w_unique' => $self['w_unique'],
1003 'builder' => $self['builder'],
1004 'title' => $self['title'],
1005 'is_activated' => $self['is_activated'],
1006 );
1007 }
1008 }
1009
1010 // Only persist activation state when the server list it is derived from was actually
1011 // supplied. See $has_server_widgets above.
1012 if ( $has_server_widgets ) {
1013 // update_option() creates the row when it is missing, so it covers both cases.
1014 // The previous add_option()/update_option() split was chosen on empty( $option ),
1015 // but wdkit_db_widgetlist() creates this row as an empty array on every install —
1016 // so the empty branch ran while the row already existed, and add_option() is a
1017 // no-op for an existing option. Deactivating from the My Widgets screen was
1018 // therefore silently discarded on effectively every site. Autoload stays 'yes',
1019 // matching the original add_option() call and wdkit_db_widgetlist().
1020 update_option( 'wkit_deactivate_widgets', $db_widget, 'yes' );
1021
1022 // The cached widget registry bakes in wkit_deactivate_widgets membership and is
1023 // stored as a no-expiry transient, so it never self-heals. Without this the
1024 // loaders kept registering a widget the user had just switched off (and kept
1025 // hiding one they had switched back on) until the transient was flushed by hand.
1026 // The write above is not per-builder — one save can change any builder's set, and
1027 // a widget can move between builders — so clear all four.
1028 if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
1029 foreach ( array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' ) as $builder_slug ) {
1030 wdesignkit_invalidate_widget_registry( $builder_slug );
1031 }
1032 }
1033 }
1034
1035 return $final;
1036 }
1037
1038 /**
1039 * Browse Page Filter
1040 *
1041 * @since 1.0.0
1042 */
1043 protected function wdkit_browse_page() {
1044 $args = $this->wdkit_parse_args( $_POST );
1045
1046 $response = WDesignKit_Data_Query::get_data( 'browse_page', $args );
1047
1048 if ( is_wp_error( $response ) ) {
1049 wp_send_json( array(
1050 'success' => false,
1051 'message' => $response->get_error_message(),
1052 ) );
1053 wp_die();
1054 }
1055
1056 $manage_licence = array();
1057 $manage_licence['theplus_elementor_addon'] = ! empty( defined( 'THEPLUS_VERSION' ) ) ? true : false;
1058 $manage_licence['tpag'] = ! empty( defined( 'TPGBP_VERSION' ) ) ? true : false;
1059 $manage_licence['elementor-pro'] = ! empty( defined( 'ELEMENTOR_PRO_VERSION' ) ) ? true : false;
1060 $response['manage_licence'] = $manage_licence;
1061
1062 wp_send_json( $response );
1063 wp_die();
1064 }
1065
1066 /**
1067 *
1068 * It is Use for get template from kit.
1069 *
1070 * @since 1.0.0
1071 */
1072 protected function wdkit_template() {
1073 $args = $this->wdkit_parse_args( $_POST );
1074
1075 $response = WDesignKit_Data_Query::get_data( 'kit_template', $args );
1076
1077 wp_send_json( $response );
1078 wp_die();
1079 }
1080
1081 /**
1082 *
1083 * It is Use for remove or delete template.
1084 *
1085 * @since 1.0.0
1086 */
1087 protected function wdkit_template_remove() {
1088 $args = $this->wdkit_parse_args( $_POST );
1089
1090 $user_email = strtolower( sanitize_email( $args['email'] ) );
1091 $response = '';
1092
1093 // Bug D fix: response()->json() is Laravel syntax — causes PHP fatal. Use plain array.
1094 if ( empty( $user_email ) || empty( $args['template_id'] ) ) {
1095 $response = array(
1096 'message' => $this->e_msg_login,
1097 'description' => $this->e_desc_login,
1098 'success' => false,
1099 );
1100
1101 wp_send_json( $response );
1102 wp_die();
1103 }
1104
1105 $args['token'] = $this->wdkit_login_user_token( $user_email );
1106
1107 unset( $user_email );
1108 $response = WDesignKit_Data_Query::get_data( 'template_remove', $args );
1109
1110 wp_send_json( $response );
1111 wp_die();
1112 }
1113
1114 /**
1115 *
1116 * It is Use for save template from page builder.
1117 *
1118 * @since 1.0.0
1119 */
1120 protected function wdkit_put_save_template() {
1121 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
1122 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( wp_unslash( $_POST['post_id'] ) ) : '';
1123 $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
1124
1125 $response = '';
1126
1127 if ( empty( $email ) ) {
1128 $response = array(
1129 'id' => 0,
1130 'editpage' => '',
1131 'message' => $this->e_msg_login,
1132 'description' => $this->e_desc_login,
1133 'success' => false,
1134 );
1135
1136 wp_send_json( $response );
1137 wp_die();
1138 }
1139
1140 $args = $this->wdkit_parse_args( $_POST );
1141 $args['token'] = $this->wdkit_login_user_token( $email );
1142 unset( $args['email'] );
1143
1144 if( 'elementor' === $builder ){
1145 $args['data'] = base64_decode( $args['data'] );
1146 } else if ( 'gutenberg' === $builder ) {
1147 $args['data'] = base64_decode( $args['data'] );
1148 }
1149
1150 global $post;
1151
1152 $custom_fields = array();
1153 if ( ! empty( $post_id ) ) {
1154 $meta_fields = get_post_custom( $post_id );
1155
1156 foreach ( $meta_fields as $key => $value ) {
1157 if ( str_contains( $key, 'nxt-' ) ) {
1158 $custom_fields[ $key ] = $value;
1159 }
1160 }
1161
1162 if ( ! empty( $custom_fields ) ) {
1163 $data = json_decode( $args['data'], true );
1164 $data['custom_meta'] = $custom_fields;
1165 $args['data'] = wp_json_encode( $data );
1166 }
1167 }
1168
1169 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1170
1171 /**
1172 * The cloud call can come back as a WP_Error (timeout, DNS, refused) or with an
1173 * empty / unparsable body, which json_decode()s to null. Forwarding that as-is
1174 * makes admin-ajax answer with a literal `null` that the editor then reads
1175 * `.id` off, killing the whole app. Normalise it to the failure shape used above.
1176 */
1177 if ( is_wp_error( $response ) || ! is_array( $response ) ) {
1178 $response = array(
1179 'id' => 0,
1180 'editpage' => '',
1181 'message' => esc_html__( 'Template Not Saved !', 'wdesignkit' ),
1182 'description' => is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'Could not reach the WDesignKit server. Please try again.', 'wdesignkit' ),
1183 'success' => false,
1184 );
1185 }
1186
1187 wp_send_json( $response );
1188 wp_die();
1189 }
1190
1191 /**
1192 *
1193 * It is Use for update save template image.
1194 *
1195 * @since 2.0.6
1196 */
1197 protected function wdkit_update_save_temp_image() {
1198 $temp_content = isset( $_POST['temp_content'] ) ? esc_url_raw( $_POST['temp_content'] ) : '';
1199 $content_name = isset( $_POST['content_name'] ) ? sanitize_text_field( $_POST['content_name'] ) : '';
1200 $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
1201 $user_type = isset( $_POST['user_type'] ) ? sanitize_text_field( $_POST['user_type'] ) : '';
1202 $type = isset( $_POST['content_type'] ) ? sanitize_text_field( $_POST['content_type'] ) : '';
1203 $id = isset( $_POST['id'] ) ? sanitize_text_field( $_POST['id'] ) : '';
1204 if ( empty( $temp_content ) || empty( $user_type ) || empty( $id ) || empty( $token ) ) {
1205 $response = array(
1206 'message' => __( 'Data not found', 'wdesignkit' ),
1207 'description' => __( 'Data not found', 'wdesignkit' ),
1208 'success' => false,
1209 );
1210 } else {
1211 $temp_content = str_replace( '\\', '', $temp_content );
1212 // SSRF guard (CWE-918): validate the resolved host before fetching a caller-supplied URL.
1213 $fetched = wdesignkit_safe_remote_get( $temp_content );
1214 $temp_content = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
1215 $temp_content = base64_encode( $temp_content );
1216
1217 $args = array(
1218 'token' => $token,
1219 'template_id' => $id,
1220 'name' => $content_name,
1221 'content' => $temp_content,
1222 'type' => $type,
1223 );
1224
1225 $response = $this->wkit_api_call( $args, 'save_images' );
1226 $success = ! empty( $response['success'] ) ? $response['success'] : false;
1227
1228 if ( $success ) {
1229 $response = array(
1230 'data' => $response['data'],
1231 'success' => true,
1232 );
1233 } else {
1234 $response = array(
1235 'message' => __( 'API Error', 'wdesignkit' ),
1236 'description' => __( 'API Error', 'wdesignkit' ),
1237 'data' => $response['data'],
1238 'success' => false,
1239 );
1240 }
1241 }
1242
1243 wp_send_json( $response );
1244 wp_die();
1245 }
1246
1247 /**
1248 *
1249 * It is Use for save image to WordPress Media Library.
1250 *
1251 * @since 2.3.3
1252 */
1253 protected function wdkit_save_wp_images() {
1254
1255 $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1256
1257 $response = self::wdkit_sideload_image_data( $image_url );
1258
1259 wp_send_json( $response );
1260 wp_die();
1261 }
1262
1263 /**
1264 * Sideload one image into the media library and describe the outcome.
1265 *
1266 * Extracted verbatim from wdkit_save_wp_images() so the same code can be reached
1267 * without `$_POST` — Wdkit_Import_Media::sideload() calls this, which is why the PHP
1268 * runner does not need its own copy of the guard or the importer hash stamps.
1269 *
1270 * The AJAX action above is now a thin adapter over this and its response is byte-for-byte
1271 * what it was before.
1272 *
1273 * @since 2.6.5
1274 *
1275 * @param string $image_url External image URL.
1276 * @return array{message:string,description:string,success:bool,url?:string}
1277 */
1278 public static function wdkit_sideload_image_data( $image_url ) {
1279
1280 // media_sideload_image() generates every registered thumbnail size, which decodes
1281 // the full source bitmap. Same guard as the page import.
1282 self::wdkit_guard_oversized_images();
1283
1284 $image_url = is_string( $image_url ) ? $image_url : '';
1285
1286 // And the same time-limit headroom the import requests take. A full-resolution stock
1287 // original can spend more than PHP's default 30s inside Imagick generating subsizes
1288 // on its own, and this endpoint is called once per picked image - so without this a
1289 // single large pick fatals the request and the image is silently never copied.
1290 // Harmless no-op where set_time_limit() is disabled.
1291 if ( function_exists( 'set_time_limit' ) ) {
1292 @set_time_limit( 300 );
1293 }
1294
1295 // The URL arrives as the argument, never from $_POST. wdkit_save_wp_images() is the
1296 // only caller that has a request to read, and it passes what it read; re-reading it
1297 // here blanked the URL for every other caller - Wdkit_Import_Media::sideload() runs
1298 // on cron, where there is no $_POST at all, so every blog-post featured image was
1299 // silently skipped.
1300 if ( empty( $image_url ) ) {
1301 $response = array(
1302 'message' => __( 'No Image Provided', 'wdesignkit' ),
1303 'description' => __( 'No Image URL provided for save.', 'wdesignkit' ),
1304 'success' => false,
1305 );
1306 } else {
1307
1308 // Cap first, then validate, then fetch - so the exact string handed to the
1309 // fetcher is the string that was checked. Validating before cap_pexels_source()
1310 // rewrites the URL would leave a gap the moment the rewriter changes.
1311 $image_url = Wdkit_Image_Guard::cap_pexels_source( $image_url );
1312
1313 // The URL is client-controlled and goes straight to an outbound fetch that
1314 // writes into uploads, so it needs the same DNS + reserved-range check as
1315 // every other fetch in the plugin. sanitize_text_field() on the caller's side
1316 // is a formatting function and permits http://127.0.0.1/, http://[::1]/ and
1317 // internal hostnames.
1318 //
1319 // Returns rather than wp_send_json(): this is now a shared helper whose
1320 // contract is "describe the outcome", and half its callers are not serving a
1321 // request. Dying here would have taken down the cron worker mid-import.
1322 if ( ! function_exists( 'wdesignkit_validate_external_url' ) || ! wdesignkit_validate_external_url( $image_url ) ) {
1323 return array(
1324 'message' => __( 'Upload Failed', 'wdesignkit' ),
1325 'description' => __( 'That image URL was rejected.', 'wdesignkit' ),
1326 'success' => false,
1327 );
1328 }
1329
1330 /* media_sideload_image() lives in wp-admin/includes/media.php and reaches
1331 * download_url() in wp-admin/includes/file.php. An admin-ajax request already has
1332 * both, which is why the browser importer never needed this — but a cron request
1333 * has neither, so every image this touched died with "Call to undefined function
1334 * download_url()". On a remote import that meant all six blog posts failed while
1335 * the run still reported success. The sibling call site in
1336 * class-wdkit-import-temp-ajax.php has always loaded these three. */
1337 require_once ABSPATH . 'wp-admin/includes/file.php';
1338 require_once ABSPATH . 'wp-admin/includes/media.php';
1339 require_once ABSPATH . 'wp-admin/includes/image.php';
1340
1341 $attachment_id = media_sideload_image( $image_url, 0, null, 'id' );
1342
1343 if ( is_wp_error( $attachment_id ) ) {
1344 $response = array(
1345 'message' => __( 'Upload Failed', 'wdesignkit' ),
1346 'description' => $attachment_id->get_error_message(),
1347 'success' => false,
1348 );
1349 } else {
1350 $saved_url = wp_get_attachment_url( $attachment_id );
1351
1352 // Elementor's importer skips an image only when it finds
1353 // _elementor_source_image_hash matching sha1 of the URL it is given. The
1354 // content we hand it now carries this local URL, so stamp the hash of that
1355 // URL too - without it Elementor re-downloads a file already on disk and
1356 // leaves a "-1" duplicate behind for every image on every page that uses it.
1357 if ( $saved_url ) {
1358 update_post_meta( $attachment_id, '_elementor_source_image_hash', sha1( $saved_url ) );
1359
1360 // Same purpose for the block importer, which keys off its own meta.
1361 update_post_meta( $attachment_id, 'tpgb_source_image_key', sha1( $saved_url ) );
1362 }
1363
1364 $response = array(
1365 'message' => __( 'Image Saved', 'wdesignkit' ),
1366 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1367 'success' => true,
1368 'url' => $saved_url,
1369 'id' => $attachment_id,
1370 );
1371 }
1372
1373 }
1374
1375 return $response;
1376 }
1377
1378 /**
1379 * The two bundle/media primitives the PHP import runner needs, reachable without a
1380 * request.
1381 *
1382 * Both of these are the optimisations that made the browser import fast, and both were
1383 * private to the AJAX actions that introduced them - so the runner could not use them
1384 * and grew its own slower equivalents instead: one cloud round trip per template rather
1385 * than one for the whole kit, and every image downloaded inside the import request
1386 * rather than swept up afterwards. These wrappers are the whole of what it took to
1387 * share them; the implementations are untouched and the AJAX actions still call them
1388 * exactly as before.
1389 *
1390 * @since 2.7.2
1391 *
1392 * @param array $request Same fields the `wkit_fetch_site_bundle` action reads, except
1393 * `template_ids` may be an array. Pass `widgets_only` to have the
1394 * assembled bundle parked in the transient for later stages.
1395 * @param string $token Cloud token the caller already resolved.
1396 * @return array|WP_Error Decoded bundle.
1397 */
1398 public function wdkit_site_bundle_for( $request, $token = '' ) {
1399 return $this->wdkit_fetch_site_bundle( is_array( $request ) ? $request : array(), $token );
1400 }
1401
1402 /**
1403 * Queue the background media sweep for pages the runner just created.
1404 *
1405 * @since 2.7.2
1406 *
1407 * @param array $pages List of {post_id, builder, image_urls}.
1408 * @return array Same result the AJAX action returns.
1409 */
1410 public function wdkit_schedule_media_sync_for( $pages ) {
1411 return $this->wkit_schedule_deferred_media_sync( is_array( $pages ) ? $pages : array() );
1412 }
1413
1414 /**
1415 * Run one stage of the PHP import runner for the Kit Import wizard.
1416 *
1417 * The wizard's replacement for its ~25-call orchestration: four calls, one per stage,
1418 * on this same action with the same nonce and the same `manage_options` check enforced
1419 * by the router above. Nothing about authentication changes, and no endpoint is added —
1420 * this is one more `case` on a router that already had 86.
1421 *
1422 * The response is shaped by Wdkit_Import_Wizard so the existing progress UI can consume
1423 * it without new widgets. See that class for the field-by-field mapping.
1424 *
1425 * @since 2.6.5
1426 *
1427 * @return array
1428 */
1429 protected function wdkit_import_stage() {
1430 if ( ! class_exists( 'Wdkit_Import_Wizard' ) ) {
1431 return array(
1432 'success' => false,
1433 'engine' => 'unavailable',
1434 'message' => esc_html__( 'Import engine is not available.', 'wdesignkit' ),
1435 );
1436 }
1437
1438 $site_obj = isset( $_POST['site_obj'] ) ? json_decode( wp_unslash( $_POST['site_obj'] ), true ) : array();
1439 $templates = isset( $_POST['templates'] ) ? json_decode( wp_unslash( $_POST['templates'] ), true ) : array();
1440 $catalogue = isset( $_POST['plugin_catalogue'] ) ? json_decode( wp_unslash( $_POST['plugin_catalogue'] ), true ) : array();
1441 $document = isset( $_POST['ai_document'] ) ? json_decode( wp_unslash( $_POST['ai_document'] ), true ) : null;
1442
1443 return Wdkit_Import_Wizard::run_stage(
1444 array(
1445 'stage' => isset( $_POST['stage'] ) ? sanitize_text_field( wp_unslash( $_POST['stage'] ) ) : '',
1446 'session_id' => isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '',
1447 'retry' => ! empty( $_POST['retry'] ) && 'yes' === sanitize_text_field( wp_unslash( $_POST['retry'] ) ),
1448 'kit_id' => isset( $_POST['kit_id'] ) ? sanitize_text_field( wp_unslash( $_POST['kit_id'] ) ) : '',
1449 'builder' => isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : 'elementor',
1450 'site_obj' => is_array( $site_obj ) ? $site_obj : array(),
1451 'templates' => is_array( $templates ) ? $templates : array(),
1452 'plugin_catalogue' => is_array( $catalogue ) ? $catalogue : array(),
1453 'ai_document' => $document,
1454 )
1455 );
1456 }
1457
1458 /**
1459 * Register the pages a kit import just created for background media sideloading.
1460 *
1461 * Called once, after every page in the kit has been inserted with its content still
1462 * pointing at the source CDN's image URLs (see the `defer_media` branch of
1463 * import_page_section_content()/wdkit_media_import()). Nothing here downloads anything -
1464 * it only schedules one wp-cron event per page, so the import request itself returns
1465 * immediately and the user sees "Site Ready" without waiting on image processing.
1466 *
1467 * @since 2.6.6
1468 *
1469 * @return array Response payload.
1470 */
1471 protected function wkit_schedule_deferred_media_sync( $pages = null ) {
1472 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the AJAX router verified the nonce; the runner passes its own array and has no request to verify.
1473 if ( ! is_array( $pages ) ) {
1474 $pages = isset( $_POST['pages'] ) ? json_decode( wp_unslash( $_POST['pages'] ), true ) : array();
1475 }
1476
1477 if ( ! is_array( $pages ) || empty( $pages ) ) {
1478 return array(
1479 'success' => false,
1480 'message' => __( 'No pages to sync.', 'wdesignkit' ),
1481 );
1482 }
1483
1484 $scheduled = 0;
1485 $stagger = 0;
1486 $order = array();
1487
1488 // The home page goes first, with no delay.
1489 //
1490 // Every page used to be staggered two seconds apart, so on a nine-page kit the last
1491 // event was not even due until ~18s after the import finished - and nothing spawns
1492 // cron, so the queue sat until the next request arrived. That next request is the
1493 // user clicking "Preview Site", which is why the first look showed media that had
1494 // not been fetched yet and a refresh appeared to fix it. The home page is the one
1495 // they land on, so it is fetched first and immediately; the rest keep the stagger so
1496 // they do not all pile into one cron tick.
1497 // page_on_front is usually still unset here: this runs before the finalize request
1498 // that assigns it. So fall back to the same title rule the importer itself uses to
1499 // choose the front page (see "Front page and shop page" in wkit_import_site_bundle).
1500 $front_id = (int) get_option( 'page_on_front' );
1501
1502 if ( ! $front_id ) {
1503 foreach ( $pages as $page ) {
1504 $candidate = isset( $page['post_id'] ) ? (int) $page['post_id'] : 0;
1505
1506 if ( ! $candidate ) {
1507 continue;
1508 }
1509
1510 $title_lower = strtolower( (string) get_the_title( $candidate ) );
1511
1512 if ( false !== strpos( $title_lower, 'home' ) || false !== strpos( $title_lower, 'landing' ) ) {
1513 $front_id = $candidate;
1514 break;
1515 }
1516 }
1517 }
1518
1519 usort(
1520 $pages,
1521 function ( $a, $b ) use ( $front_id ) {
1522 $a_id = isset( $a['post_id'] ) ? (int) $a['post_id'] : 0;
1523 $b_id = isset( $b['post_id'] ) ? (int) $b['post_id'] : 0;
1524
1525 if ( $front_id && $a_id === $front_id ) {
1526 return -1;
1527 }
1528
1529 if ( $front_id && $b_id === $front_id ) {
1530 return 1;
1531 }
1532
1533 return 0;
1534 }
1535 );
1536
1537 foreach ( $pages as $page ) {
1538 $post_id = isset( $page['post_id'] ) ? (int) $page['post_id'] : 0;
1539 $builder = isset( $page['builder'] ) ? sanitize_key( $page['builder'] ) : '';
1540
1541 $image_urls = ( isset( $page['image_urls'] ) && is_array( $page['image_urls'] ) )
1542 ? array_values( array_filter( array_map( 'esc_url_raw', $page['image_urls'] ) ) )
1543 : array();
1544
1545 // esc_url_raw() formats, it does not authorise. This list came from the browser.
1546 $image_urls = self::wdkit_filter_fetchable_urls( $image_urls );
1547
1548 if ( ! $post_id || empty( $image_urls ) || ! get_post( $post_id ) ) {
1549 continue;
1550 }
1551
1552 // Stagger each page's event a couple of seconds apart instead of firing every
1553 // page's sideload loop in the very same wp-cron tick - except the home page,
1554 // which is due right away because it is what the user previews.
1555 $is_front = ( $front_id && $post_id === $front_id );
1556
1557 wp_schedule_single_event(
1558 $is_front ? time() : ( time() + 2 + $stagger ),
1559 'wdkit_async_sideload_page_images',
1560 array( $post_id, $builder, $image_urls )
1561 );
1562
1563 if ( ! $is_front ) {
1564 $stagger += 2;
1565 }
1566
1567 $order[] = $post_id;
1568 ++$scheduled;
1569 }
1570
1571 return array(
1572 'success' => true,
1573 'message' => __( 'Media sync scheduled.', 'wdesignkit' ),
1574 'scheduled' => $scheduled,
1575 // The order they were queued in, home page first. Returned so the success
1576 // screen's own drain uses the same priority instead of deciding again.
1577 'order' => $order,
1578 );
1579 }
1580
1581 /**
1582 * Sideload one deferred page's media now, instead of waiting for wp-cron.
1583 *
1584 * The import deliberately leaves media to a background pass so the kit imports fast, and
1585 * that is worth keeping. What it cost was the first preview: nothing spawns cron, so the
1586 * queue waited for the next request - the user's own "Preview Site" click - and the page
1587 * they landed on still pointed at the source CDN with some references not written yet.
1588 * Refreshing appeared to fix it because that first view was what started the queue.
1589 *
1590 * So the success screen drives the queue itself over this endpoint once the import is
1591 * already reported done. It adds nothing to the import: by then the UI says complete and
1592 * the user is reading it. The scheduled cron events stay as the fallback for anyone who
1593 * navigates away, and each is cleared as its page is handled here so the work is not done
1594 * twice. It also covers hosts where a loopback cron spawn would never fire at all -
1595 * DISABLE_WP_CRON, or a system cron that only runs every few minutes.
1596 *
1597 * @since 2.7.1
1598 *
1599 * @return array
1600 */
1601 protected function wkit_run_deferred_media_now() {
1602
1603 $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
1604 $builder = isset( $_POST['builder'] ) ? sanitize_key( wp_unslash( $_POST['builder'] ) ) : '';
1605
1606 $image_urls = isset( $_POST['image_urls'] ) ? json_decode( wp_unslash( $_POST['image_urls'] ), true ) : array();
1607 $image_urls = is_array( $image_urls )
1608 ? array_values( array_filter( array_map( 'esc_url_raw', $image_urls ) ) )
1609 : array();
1610
1611 // esc_url_raw() formats, it does not authorise - same gate the scheduler applies,
1612 // because this list comes from the browser just the same.
1613 $image_urls = self::wdkit_filter_fetchable_urls( $image_urls );
1614
1615 if ( ! $post_id || ! get_post( $post_id ) || empty( $image_urls ) ) {
1616 return array(
1617 'success' => false,
1618 'message' => __( 'Nothing to sync for this page.', 'wdesignkit' ),
1619 );
1620 }
1621
1622 // Drop the queued event first, so a cron tick landing mid-run cannot repeat it.
1623 wp_clear_scheduled_hook( 'wdkit_async_sideload_page_images', array( $post_id, $builder, $image_urls ) );
1624
1625 $this->wdkit_async_sideload_page_images( $post_id, $builder, $image_urls );
1626
1627 return array(
1628 'success' => true,
1629 'message' => __( 'Media synced.', 'wdesignkit' ),
1630 'post_id' => $post_id,
1631 );
1632 }
1633
1634 /**
1635 * Walk Elementor `elements` data replacing every deferred CDN media reference with the
1636 * local attachment sideloaded for it.
1637 *
1638 * Handles two shapes: a media control (`{ id, url }` siblings, e.g. an image control or a
1639 * has_sizes background-image control) gets both keys corrected, matching what
1640 * wdkit_repair_attachment_ids() does for the synchronous path. Any other string simply
1641 * gets the source URL substring swapped, which catches URLs embedded in HTML/text
1642 * controls that never had an `id` sibling to begin with.
1643 *
1644 * @since 2.6.6
1645 *
1646 * @param mixed $node Elementor data, walked recursively.
1647 * @param array $url_map Source URL => array( 'id' => local attachment ID, 'url' => local URL ).
1648 * @return mixed Data with local media references.
1649 */
1650 /**
1651 * Every spelling of one URL that can appear in stored content.
1652 *
1653 * A URL with a query string does not survive the round trip through the browser and
1654 * WordPress's own escaping as the raw string this code holds. `&` comes back HTML-entity
1655 * encoded as `&#038;`, sometimes `&amp;`, and - where a value is escaped twice on the way
1656 * into post content - as `&amp;#038;`. So the picked stock images were downloaded and
1657 * stored correctly while the page kept the remote reference: the rewriters matched the
1658 * raw key, the content held an encoded one, and the substring test never fired. Measured:
1659 * 3 attachments created, 12 remote references still served on the landing page.
1660 *
1661 * Longest first, so a replacement pass cannot rewrite the `&amp;` inside `&amp;#038;`
1662 * and strand the remainder.
1663 *
1664 * @since 2.7.1
1665 *
1666 * @param string $url Canonical URL as this code holds it.
1667 * @return array<string> Distinct variants to search for, longest first.
1668 */
1669 private static function wdkit_url_encoding_variants( $url ) {
1670
1671 if ( ! is_string( $url ) || '' === $url || false === strpos( $url, '&' ) ) {
1672 return array( (string) $url );
1673 }
1674
1675 $variants = array(
1676 str_replace( '&', '&amp;#038;', $url ),
1677 str_replace( '&', '&#038;', $url ),
1678 str_replace( '&', '&amp;amp;', $url ),
1679 str_replace( '&', '&amp;', $url ),
1680 $url,
1681 );
1682
1683 $variants = array_values( array_unique( $variants ) );
1684
1685 usort(
1686 $variants,
1687 static function ( $a, $b ) {
1688 return strlen( $b ) - strlen( $a );
1689 }
1690 );
1691
1692 return $variants;
1693 }
1694
1695 /**
1696 * Replace every encoding of $source_url in $subject with $replacement.
1697 *
1698 * @since 2.7.1
1699 *
1700 * @param string $subject Content to rewrite.
1701 * @param string $source_url URL to look for, in any encoding.
1702 * @param string $replacement Local URL to write in its place.
1703 * @return string
1704 */
1705 private static function wdkit_replace_url_all_encodings( $subject, $source_url, $replacement ) {
1706
1707 foreach ( self::wdkit_url_encoding_variants( $source_url ) as $variant ) {
1708 if ( false !== strpos( $subject, $variant ) ) {
1709 $subject = str_replace( $variant, $replacement, $subject );
1710 }
1711 }
1712
1713 return $subject;
1714 }
1715
1716 /**
1717 * Re-point any media control still holding a foreign URL at the local file, when one
1718 * already exists.
1719 *
1720 * Downloads nothing. It only asks "has this exact source URL already been sideloaded?"
1721 * via the same _wdkit_deferred_source_url lookup the cron uses for de-duplication, and
1722 * rewrites url + id when the answer is yes.
1723 *
1724 * Why this is needed as a separate pass: the finalize sweep deliberately skips any page
1725 * with media still queued (see wdkit_sweep_attachment_ids() - sweeping there would turn
1726 * the import request into the media importer), and nothing re-sweeps once the queue
1727 * drains. wdkit_repair_attachment_ids() cannot cover it either - it is restricted to
1728 * local URLs by design. So a control whose rewrite was missed had nothing left to fix it.
1729 *
1730 * Observed on a Taj Bakery import: a tp-video-player kept
1731 * etemplates.wdesignkit.com/...mp4 in mp4_link.url with its id blanked, while the file
1732 * sat in the library as attachment 173 with a byte-identical _wdkit_deferred_source_url.
1733 * Every image on the same page had been rewritten; only that control was left behind.
1734 *
1735 * @since 2.7.1
1736 *
1737 * @param mixed $node Elementor data, walked recursively.
1738 * @param int $fixed Running count of controls repaired, by reference.
1739 * @return mixed The data with any resolvable foreign reference pointed local.
1740 */
1741 /**
1742 * Apply a url_map to every OTHER post that still references one of its source URLs.
1743 *
1744 * A deferred URL is scheduled per page, but the same file is often referenced by several
1745 * pages, and the de-duplication in this callback means only the first event actually
1746 * downloads it. Every event rewrites its own $post_id and nothing else - so a sibling
1747 * page whose own event did not carry that URL (a scheduling gap, or a page whose list was
1748 * assembled before the reference existed) keeps the remote URL with no later pass to fix
1749 * it: the finalize sweep skips queued pages by design, and wdkit_repair_attachment_ids()
1750 * only looks at local URLs.
1751 *
1752 * Measured on a Taj Bakery import: the About Us page kept
1753 * etemplates.wdesignkit.com/...mp4 in a tp-video-player while the file sat in the library
1754 * as a video/mp4 attachment. Handing that page's own callback the URL rewrote it
1755 * correctly, which is what showed the pipeline was sound and the routing was not.
1756 *
1757 * Bounded and cheap: one LIKE query per distinct source URL in this event's map, and only
1758 * posts that actually contain the string are touched. Downloads nothing.
1759 *
1760 * @since 2.7.1
1761 *
1762 * @param array $url_map Source URL => array( id, url ), as built by this callback.
1763 * @param int $skip_id The post this callback already rewrote.
1764 * @return int Number of sibling posts rewritten.
1765 */
1766 private function wdkit_apply_url_map_to_siblings( array $url_map, $skip_id ) {
1767
1768 global $wpdb;
1769
1770 if ( empty( $url_map ) ) {
1771 return 0;
1772 }
1773
1774 $candidates = array();
1775
1776 foreach ( array_keys( $url_map ) as $source_url ) {
1777 // Both spellings: raw in post_content, slash-escaped inside _elementor_data JSON.
1778 foreach ( array( $source_url, str_replace( '/', '\/', $source_url ) ) as $needle ) {
1779 $ids = $wpdb->get_col(
1780 $wpdb->prepare(
1781 "SELECT DISTINCT p.ID FROM {$wpdb->posts} p
1782 LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_elementor_data'
1783 WHERE p.post_status = 'publish'
1784 AND p.post_type IN ( 'page', 'post', 'nxt_builder', 'elementor_library' )
1785 AND p.ID <> %d
1786 AND ( p.post_content LIKE %s OR m.meta_value LIKE %s )
1787 LIMIT 50",
1788 (int) $skip_id,
1789 '%' . $wpdb->esc_like( $needle ) . '%',
1790 '%' . $wpdb->esc_like( $needle ) . '%'
1791 )
1792 );
1793
1794 foreach ( (array) $ids as $id ) {
1795 $candidates[ (int) $id ] = true;
1796 }
1797 }
1798 }
1799
1800 if ( empty( $candidates ) ) {
1801 return 0;
1802 }
1803
1804 $rewritten = 0;
1805
1806 foreach ( array_keys( $candidates ) as $sibling_id ) {
1807 $changed = false;
1808 $raw = get_post_meta( $sibling_id, '_elementor_data', true );
1809 $data = is_array( $raw ) ? $raw : json_decode( (string) $raw, true );
1810
1811 if ( is_array( $data ) ) {
1812 $data = $this->wdkit_replace_deferred_media( $data, $url_map );
1813
1814 $relinked = 0;
1815 $data = self::wdkit_relink_known_foreign_media( $data, $relinked );
1816
1817 // wp_slash() for the same reason as the primary write above.
1818 update_post_meta( $sibling_id, '_elementor_data', wp_slash( wp_json_encode( $data ) ) );
1819 $changed = true;
1820 }
1821
1822 $post = get_post( $sibling_id );
1823
1824 if ( $post && ! empty( $post->post_content ) ) {
1825 $content = $post->post_content;
1826
1827 foreach ( $url_map as $source_url => $local ) {
1828 $content = self::wdkit_replace_url_all_encodings( $content, $source_url, $local['url'] );
1829 }
1830
1831 if ( $content !== $post->post_content ) {
1832 self::wdkit_write_post_content( $sibling_id, $content );
1833 $changed = true;
1834 }
1835 }
1836
1837 if ( $changed ) {
1838 self::wdkit_invalidate_elementor_page_cache( $sibling_id );
1839 ++$rewritten;
1840 }
1841 }
1842
1843 return $rewritten;
1844 }
1845
1846 private static function wdkit_relink_known_foreign_media( $node, &$fixed = 0 ) {
1847
1848 if ( ! is_array( $node ) ) {
1849 return $node;
1850 }
1851
1852 if ( isset( $node['url'] ) && is_string( $node['url'] ) && '' !== $node['url']
1853 && array_key_exists( 'id', $node ) && self::wdkit_is_foreign_media_url( $node['url'] )
1854 ) {
1855 $existing = self::wdkit_deferred_attachment_for_url( $node['url'] );
1856
1857 if ( $existing && self::wdkit_is_usable_attachment( $existing ) ) {
1858 $local = wp_get_attachment_url( $existing );
1859
1860 if ( $local ) {
1861 $node['url'] = $local;
1862 $node['id'] = (int) $existing;
1863 ++$fixed;
1864 }
1865 }
1866 }
1867
1868 foreach ( $node as $key => $value ) {
1869 if ( is_array( $value ) ) {
1870 $node[ $key ] = self::wdkit_relink_known_foreign_media( $value, $fixed );
1871 }
1872 }
1873
1874 return $node;
1875 }
1876
1877 private function wdkit_replace_deferred_media( $node, array $url_map ) {
1878
1879 if ( is_array( $node ) ) {
1880
1881 if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
1882
1883 $hit = isset( $url_map[ $node['url'] ] ) ? $node['url'] : null;
1884
1885 // The node's own url can be entity-encoded too, so an exact-key lookup alone
1886 // misses it - and this is the branch that repairs the `id`, without which the
1887 // widget resolves nothing even once the url is right.
1888 if ( null === $hit ) {
1889 $decoded = html_entity_decode( $node['url'], ENT_QUOTES, 'UTF-8' );
1890 $decoded = html_entity_decode( $decoded, ENT_QUOTES, 'UTF-8' );
1891
1892 if ( isset( $url_map[ $decoded ] ) ) {
1893 $hit = $decoded;
1894 }
1895 }
1896
1897 if ( null !== $hit ) {
1898 $node['id'] = $url_map[ $hit ]['id'];
1899 $node['url'] = $url_map[ $hit ]['url'];
1900 }
1901 }
1902
1903 foreach ( $node as $key => $value ) {
1904 $node[ $key ] = $this->wdkit_replace_deferred_media( $value, $url_map );
1905 }
1906
1907 return $node;
1908 }
1909
1910 if ( is_string( $node ) && '' !== $node ) {
1911 foreach ( $url_map as $source_url => $local ) {
1912 $node = self::wdkit_replace_url_all_encodings( $node, $source_url, $local['url'] );
1913 }
1914 }
1915
1916 return $node;
1917 }
1918
1919 /**
1920 * Find the attachment a previous wdkit_async_sideload_page_images() run already made
1921 * for this exact source URL.
1922 *
1923 * @since 2.6.6
1924 *
1925 * @param string $source_url Source CDN/stock-provider URL.
1926 * @return int Attachment ID, or 0 when none exists yet.
1927 */
1928 private static function wdkit_deferred_attachment_for_url( $source_url ) {
1929 $existing = get_posts(
1930 array(
1931 'post_type' => 'attachment',
1932 'post_status' => 'inherit',
1933 'posts_per_page' => 5,
1934 'fields' => 'ids',
1935 'no_found_rows' => true,
1936 'meta_key' => '_wdkit_deferred_source_url',
1937 'meta_value' => $source_url,
1938 )
1939 );
1940
1941 // The row is not the answer - the file is.
1942 //
1943 // This used to return (int) $existing[0] straight out of the query, and every caller
1944 // then tested wp_get_attachment_url(), which is truthy for a record with nothing
1945 // behind it: with no _wp_attached_file it falls back to the guid, and wp_insert_post()
1946 // defaults an attachment's guid to its attachment-page permalink. So a dead record
1947 // answered the lookup with http://site/hand25-png/, the reuse branch took it as a hit
1948 // and skipped the download, and the page shipped an <img src> pointing at an HTML
1949 // page. Measured on all three shapes such a record comes in - no file with metadata,
1950 // no file without metadata, no _wp_attached_file at all - wp_get_attachment_url()
1951 // returned a truthy url for every one.
1952 //
1953 // A site that imported a kit on a build before Wdkit_Import_Images::import() started
1954 // refusing unknown file types is full of these, which is why the fixed build still
1955 // rendered broken images: the import adopted the old rows instead of downloading
1956 // again, and re-importing reused them just the same. Rejecting them here is what makes
1957 // a re-import repair the page - the caller falls through to its normal download and
1958 // then rewrites url + id from the attachment it actually created.
1959 //
1960 // Validated in the lookup rather than at each call site because there are three, they
1961 // were not all guarded, and a fourth would not be either.
1962 foreach ( (array) $existing as $candidate ) {
1963 if ( self::wdkit_is_usable_attachment( $candidate ) ) {
1964 return (int) $candidate;
1965 }
1966
1967 self::wdkit_forget_reuse_keys( $candidate );
1968 }
1969
1970 return 0;
1971 }
1972
1973 /**
1974 * Take a broken attachment out of every reuse index this pipeline consults.
1975 *
1976 * Gating the lookup stops this plugin adopting a dead record, but the record keeps
1977 * answering the other two indexes - `_elementor_source_image_hash`, which Elementor's own
1978 * Import_Images::import() resolves before it downloads anything, and
1979 * `tpgb_source_image_key`, which the block importer uses the same way. Left in place it
1980 * would still be handed back through them.
1981 *
1982 * Deletes the meta, never the attachment. A row with no file is worthless to this
1983 * importer, but it is not this importer's to remove: a media-offload plugin legitimately
1984 * has no local file, and a customer's library is not something an import routine should be
1985 * deleting from. Dropping it out of the indexes is enough to make the next import fetch a
1986 * fresh copy.
1987 *
1988 * @since 2.7.1
1989 *
1990 * @param int $id Attachment that failed wdkit_is_usable_attachment().
1991 * @return void
1992 */
1993 private static function wdkit_forget_reuse_keys( $id ) {
1994
1995 $id = (int) $id;
1996
1997 if ( ! $id ) {
1998 return;
1999 }
2000
2001 foreach ( array( '_wdkit_deferred_source_url', '_elementor_source_image_hash', 'tpgb_source_image_key' ) as $key ) {
2002 delete_post_meta( $id, $key );
2003 }
2004 }
2005
2006 /**
2007 * Largest media file the background pass will pull down, in bytes.
2008 *
2009 * Video is the only thing a kit references that can plausibly run to hundreds of megabytes.
2010 * Nothing decodes it - download_url() streams to a temp file - so this is not about memory;
2011 * it is about one oversized file monopolising a cron event's time limit and disk while
2012 * every URL queued behind it goes unprocessed.
2013 *
2014 * @since 2.7.1
2015 */
2016 const WDKIT_DEFERRED_MEDIA_MAX_BYTES = 52428800; // 50 MB.
2017
2018 /**
2019 * Sideload one deferred media URL, choosing the right primitive for what it is.
2020 *
2021 * media_sideload_image() hard-rejects any extension outside jpg|jpeg|jpe|png|gif|webp
2022 * ("Invalid image URL") before it downloads anything, so it can never localise a video -
2023 * nor, as it happens, an .avif, which this importer has been collecting for the background
2024 * pass all along and which was therefore being dropped just as silently.
2025 *
2026 * Images keep going through media_sideload_image() exactly as before: it is the path that
2027 * has been exercised on every import, and nothing here is worth changing about it. Anything
2028 * else goes through download_url() + media_handle_sideload(), which is generic, validates
2029 * the real type with wp_check_filetype_and_ext(), and is already what this file uses for
2030 * featured images.
2031 *
2032 * @since 2.7.1
2033 *
2034 * @param string $source_url Remote URL.
2035 * @return int|WP_Error Attachment ID, or the error that stopped it.
2036 */
2037 /**
2038 * Keep only the URLs this site is willing to fetch.
2039 *
2040 * Applied where a client-supplied `image_urls` list is accepted, so a URL that points at
2041 * an internal address is never written into the cron table in the first place. The cron
2042 * callback validates again before fetching - that is the authoritative gate, since a cron
2043 * arg can outlive the request that created it.
2044 *
2045 * Drops only what is *provably* unsafe, which is deliberately narrower than the sink.
2046 * wdesignkit_validate_external_url() fails closed on a host it cannot resolve ("an
2047 * unresolvable host cannot be proven public"), and that is the right answer immediately
2048 * before a fetch - but the wrong one here. An import resolves dozens of hosts at once, so
2049 * a transient DNS failure during that burst is ordinary; refusing on it would drop the URL
2050 * before it was ever scheduled, and nothing would retry it - the image would stay remote
2051 * permanently. Deferring that case to the cron costs nothing: the sink re-checks and fails
2052 * closed there, by which time DNS has usually recovered.
2053 *
2054 * The threat this closes is an internal URL being persisted into a cron argument, and such
2055 * a URL resolves by definition - to a private or reserved address - so it is caught here.
2056 *
2057 * @since 2.7.1
2058 *
2059 * @param array $urls Candidate URLs, already esc_url_raw()'d.
2060 * @return array<string> The subset that is not provably unsafe.
2061 */
2062 private static function wdkit_filter_fetchable_urls( $urls ) {
2063
2064 if ( ! is_array( $urls ) || empty( $urls ) ) {
2065 return array();
2066 }
2067
2068 // One verdict per host, not per URL. A kit page carries well over a hundred media
2069 // URLs across a handful of hosts, and both the validator and the resolve check below
2070 // hit DNS - so without this, a page's worth of images costs a hundred-plus lookups
2071 // inside the import request, and on a host with slow resolution that is time the user
2072 // spends watching a progress bar. The decision is a property of the host, so caching
2073 // it changes nothing about the outcome. Request-scoped: a static here lives exactly
2074 // as long as the import request that built it.
2075 static $verdict = array();
2076
2077 $allowed = array();
2078
2079 foreach ( $urls as $url ) {
2080 if ( ! is_string( $url ) || '' === $url ) {
2081 continue;
2082 }
2083
2084 $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
2085 $host = (string) wp_parse_url( $url, PHP_URL_HOST );
2086
2087 // Nothing downstream can make sense of these, whatever DNS says.
2088 if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) {
2089 continue;
2090 }
2091
2092 if ( ! function_exists( 'wdesignkit_validate_external_url' ) ) {
2093 // No validator loaded: keep the URL and let the sink - which requires the
2094 // validator and refuses without it - make the call. Dropping everything here
2095 // would silently disable deferred media rather than secure it.
2096 $allowed[] = $url;
2097 continue;
2098 }
2099
2100 $key = strtolower( $host );
2101
2102 if ( ! isset( $verdict[ $key ] ) ) {
2103 // The validator takes a URL, but everything it decides on - resolution and
2104 // address range - depends only on the host, so a bare origin is a faithful
2105 // probe for the whole host and the answer applies to every URL on it.
2106 $probe = $scheme . '://' . $host . '/';
2107
2108 // Keep it unless it is provably unsafe: rejected AND resolvable, which
2109 // together mean it resolved to an address we refuse to reach. A rejection
2110 // with no resolution is DNS being briefly unavailable - the sink re-checks
2111 // and fails closed there, so deferring that call loses nothing.
2112 $verdict[ $key ] = wdesignkit_validate_external_url( $probe )
2113 || ! self::wdkit_host_resolves( $host );
2114 }
2115
2116 if ( $verdict[ $key ] ) {
2117 $allowed[] = $url;
2118 }
2119 }
2120
2121 return $allowed;
2122 }
2123
2124 /**
2125 * Does this host resolve to anything right now?
2126 *
2127 * Used only to tell a validation failure caused by a private address apart from one
2128 * caused by DNS being briefly unavailable. A literal IP always counts as resolved.
2129 *
2130 * @since 2.7.1
2131 *
2132 * @param string $host Hostname from the URL.
2133 * @return bool True when at least one address came back.
2134 */
2135 private static function wdkit_host_resolves( $host ) {
2136
2137 // wp_parse_url() returns an IPv6 literal still wrapped in its URL brackets
2138 // (`[::1]`), which is not valid IP syntax - so without this the address fails the
2139 // test below, resolves to nothing, and is misread as "DNS is down" rather than
2140 // "this is loopback".
2141 $host = trim( $host, '[]' );
2142
2143 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
2144 return true;
2145 }
2146
2147 if ( function_exists( 'gethostbynamel' ) ) {
2148 $v4 = gethostbynamel( $host );
2149
2150 if ( is_array( $v4 ) && ! empty( $v4 ) ) {
2151 return true;
2152 }
2153 }
2154
2155 if ( function_exists( 'dns_get_record' ) ) {
2156 $v6 = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a lookup failure is the answer, not an error.
2157
2158 if ( is_array( $v6 ) && ! empty( $v6 ) ) {
2159 return true;
2160 }
2161 }
2162
2163 $resolved = gethostbyname( $host ); // Returns the host unchanged on failure.
2164
2165 return ( $resolved && $resolved !== $host && (bool) filter_var( $resolved, FILTER_VALIDATE_IP ) );
2166 }
2167
2168 private static function wdkit_sideload_deferred_media( $source_url ) {
2169
2170 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
2171 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
2172 }
2173
2174 /**
2175 * Largest deferred media file to download, in bytes.
2176 *
2177 * Filterable because the right ceiling is a property of the host, not of the kit:
2178 * shared hosting with a 30s cap and a slow link needs a lower one than a VPS.
2179 *
2180 * @since 2.7.1
2181 *
2182 * @param int $max_bytes Default limit.
2183 * @param string $source_url URL being considered.
2184 */
2185 $max_bytes = (int) apply_filters( 'wdkit_deferred_media_max_bytes', self::WDKIT_DEFERRED_MEDIA_MAX_BYTES, $source_url );
2186
2187 // Same SSRF check every other outbound fetch in this plugin goes through - DNS
2188 // resolution plus a reserved-range test, so private, loopback and link-local
2189 // addresses are rejected. It gates BOTH branches below, and has to: this URL list
2190 // arrives from the browser in $_POST (see the `image_urls` contract on the bundle and
2191 // pages routes), so it is client-controlled, and esc_url_raw() is a formatting
2192 // function, not an access-control one. Running in a cron event does not make the
2193 // origin of the URL any more trustworthy - it removes the last chance to ask.
2194 // Nonce + manage_options do not close this: they establish who is asking, not where
2195 // the server may be pointed, and neither stops a CSRF-assisted request. download_url()
2196 // blocks only the plain 169.254.169.254 metadata case, leaving multi-A-record hosts,
2197 // internal IPv6 and same-host URLs reachable.
2198 if ( ! function_exists( 'wdesignkit_validate_external_url' ) || ! wdesignkit_validate_external_url( $source_url ) ) {
2199 return new WP_Error( 'wdkit_media_url_rejected', 'Refusing to fetch a URL that failed validation.' );
2200 }
2201
2202 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $source_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
2203
2204 // The set WordPress's own image sideloader accepts. Everything it accepts, it keeps.
2205 if ( in_array( $ext, array( 'jpg', 'jpeg', 'jpe', 'png', 'gif', 'webp' ), true ) ) {
2206 return media_sideload_image( $source_url, 0, null, 'id' );
2207 }
2208
2209 // Ask before fetching. A HEAD that answers with a size lets an oversized file be skipped
2210 // for the cost of one request; a host that does not answer with one is not a reason to
2211 // refuse, so the download proceeds and the on-disk size below is the real backstop.
2212 $head = wp_remote_head( $source_url, array( 'timeout' => 15, 'redirection' => 3 ) );
2213
2214 if ( ! is_wp_error( $head ) ) {
2215 $length = (int) wp_remote_retrieve_header( $head, 'content-length' );
2216
2217 if ( $length > $max_bytes ) {
2218 return new WP_Error(
2219 'wdkit_media_too_large',
2220 sprintf( 'Skipped %s: %d bytes exceeds the %d byte limit.', $source_url, $length, $max_bytes )
2221 );
2222 }
2223 }
2224
2225 $tmp = download_url( $source_url, 300 );
2226
2227 if ( is_wp_error( $tmp ) ) {
2228 return $tmp;
2229 }
2230
2231 // The backstop for a host that sent no Content-Length, or lied about it.
2232 $size = (int) @filesize( $tmp );
2233
2234 if ( $size > $max_bytes ) {
2235 @unlink( $tmp );
2236
2237 return new WP_Error(
2238 'wdkit_media_too_large',
2239 sprintf( 'Skipped %s: %d bytes on disk exceeds the %d byte limit.', $source_url, $size, $max_bytes )
2240 );
2241 }
2242
2243 // From the URL *path*, so a query string never ends up in the filename - the same reason
2244 // Wdkit_Import_Images does this rather than basename() the whole URL.
2245 $file_name = Wdkit_Image_Guard::filename_from_url( $source_url );
2246
2247 // filename_from_url() only matches a path that already names a known media type, so
2248 // it returns '' for the extensionless CDN URLs this branch exists to handle. Fall back
2249 // to the last path segment and let the sniffing below name the type.
2250 if ( '' === $file_name ) {
2251 $path = (string) wp_parse_url( $source_url, PHP_URL_PATH );
2252 $file_name = sanitize_file_name( (string) wp_basename( rtrim( $path, '/' ) ) );
2253 }
2254
2255 if ( '' === $file_name ) {
2256 @unlink( $tmp );
2257
2258 return new WP_Error( 'wdkit_media_no_filename', 'Could not derive a filename from the URL.' );
2259 }
2260
2261 // The template CDN serves media from extensionless URLs and declares the type in the
2262 // response, so there is nothing in the path to name the file after. Left as-is,
2263 // wp_check_filetype_and_ext() below has no extension to check and refuses the upload -
2264 // which is why extensionless media was never localised even once it was collected.
2265 // The type is read from the bytes on disk, not from the URL or a response header, so a
2266 // mislabelled file still cannot smuggle in an extension it does not match.
2267 if ( '' === (string) pathinfo( $file_name, PATHINFO_EXTENSION ) ) {
2268 $sniffed = wp_check_filetype_and_ext( $tmp, $file_name . '.jpg' );
2269 $type = ! empty( $sniffed['type'] ) ? $sniffed['type'] : '';
2270
2271 if ( '' === $type && function_exists( 'getimagesize' ) ) {
2272 $info = @getimagesize( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed input is the case being detected.
2273 $type = ( is_array( $info ) && ! empty( $info['mime'] ) ) ? $info['mime'] : '';
2274 }
2275
2276 $ext = '';
2277
2278 foreach ( wp_get_mime_types() as $exts => $mime ) {
2279 if ( $mime === $type ) {
2280 $ext = strtok( $exts, '|' );
2281 break;
2282 }
2283 }
2284
2285 if ( '' === $ext ) {
2286 @unlink( $tmp );
2287
2288 return new WP_Error(
2289 'wdkit_media_unknown_type',
2290 sprintf( 'Skipped %s: no extension in the URL and the downloaded file is not a recognised media type.', $source_url )
2291 );
2292 }
2293
2294 $file_name .= '.' . $ext;
2295 }
2296
2297 // media_handle_sideload() runs the upload through wp_check_filetype_and_ext(), so a file
2298 // whose contents do not match its extension is refused here rather than stored.
2299 $attachment_id = media_handle_sideload(
2300 array(
2301 'name' => $file_name,
2302 'tmp_name' => $tmp,
2303 ),
2304 0
2305 );
2306
2307 // On failure media_handle_sideload() has already removed the temp file; on success it
2308 // moved it. This only catches the path where it returned early without doing either.
2309 if ( is_wp_error( $attachment_id ) && file_exists( $tmp ) ) {
2310 @unlink( $tmp );
2311 }
2312
2313 return $attachment_id;
2314 }
2315
2316 /**
2317 * The original-image URL behind a WordPress size variant, or '' when it is not one.
2318 *
2319 * `photo-300x298.png` -> `photo.png`. Only the trailing -WIDTHxHEIGHT before the extension
2320 * is removed; query strings are kept.
2321 *
2322 * @param string $url Image URL.
2323 * @return string
2324 */
2325 private static function wdkit_size_variant_of( $url ) {
2326 $original = preg_replace( '/-\d+x\d+(\.(?:jpe?g|png|gif|webp|avif))(?=$|\?)/i', '$1', (string) $url, 1 );
2327
2328 return ( is_string( $original ) && $original !== $url ) ? $original : '';
2329 }
2330
2331 /**
2332 * Background half of the deferred media sideload: download every image a kit page's
2333 * content deferred at create time and repoint the saved page content at the local copies.
2334 *
2335 * Runs off a `wp_schedule_single_event()` (registered in the constructor), well after the
2336 * import request that created the page has already returned. Updates post meta / the
2337 * post row directly rather than going through Elementor's/the block importer's normal
2338 * save path, so this never re-runs widget ID generation or reflows the layout.
2339 *
2340 * @since 2.6.6
2341 *
2342 * @param int $post_id Page created with source CDN URLs still in its content.
2343 * @param string $builder 'elementor' or anything else (treated as Gutenberg/HTML content).
2344 * @param array $image_urls Source CDN image URLs referenced by that page.
2345 */
2346 public function wdkit_async_sideload_page_images( $post_id, $builder, $image_urls ) {
2347
2348 $post_id = (int) $post_id;
2349 $image_urls = is_array( $image_urls ) ? $image_urls : array();
2350
2351 if ( ! $post_id || empty( $image_urls ) || ! get_post( $post_id ) ) {
2352 return;
2353 }
2354
2355 if ( ! function_exists( 'media_sideload_image' ) ) {
2356 require_once ABSPATH . 'wp-admin/includes/media.php';
2357 require_once ABSPATH . 'wp-admin/includes/file.php';
2358 require_once ABSPATH . 'wp-admin/includes/image.php';
2359 }
2360
2361 // The synchronous import paths all extend this (see import_page_section_content(),
2362 // wkit_import_site_bundle()) for the same reason: Imagick thumbnail generation on an
2363 // oversized source image can outrun PHP's default 30s limit on its own, before this
2364 // loop even gets to a second URL. Unlike those requests, nothing is waiting on this
2365 // one - it is a wp-cron callback - so there is no reason not to give it the same
2366 // headroom; without it, a fatal here kills the whole event mid-loop (no unschedule,
2367 // no url_map, no content rewrite) and every image after the slow one in $image_urls
2368 // is left pointing at the source CDN with nothing left to ever retry it.
2369 if ( function_exists( 'set_time_limit' ) ) {
2370 @set_time_limit( 300 );
2371 }
2372
2373 // Same guard the synchronous path uses - a background run is no less able to blow
2374 // through the memory limit on an oversized source image.
2375 self::wdkit_guard_oversized_images();
2376
2377 $url_map = array();
2378
2379 /* Attachment ids sideloaded or found in this run, keyed by fetch URL, so an image the
2380 * kit references in several sizes is fetched once. */
2381 $attached = array();
2382
2383 /* Sideload one URL (or reuse an earlier copy), returning its attachment id or 0. */
2384 $attach = function ( $fetch_url ) use ( &$attached ) {
2385 if ( isset( $attached[ $fetch_url ] ) ) {
2386 return $attached[ $fetch_url ];
2387 }
2388
2389 $attachment_id = self::wdkit_deferred_attachment_for_url( $fetch_url );
2390
2391 if ( ! $attachment_id ) {
2392 $attachment_id = self::wdkit_sideload_deferred_media( $fetch_url );
2393
2394 if ( is_wp_error( $attachment_id ) ) {
2395 if ( 'wdkit_media_too_large' === $attachment_id->get_error_code() ) {
2396 error_log( 'WDKIT deferred media: ' . $attachment_id->get_error_message() );
2397 $attached[ $fetch_url ] = 0;
2398 return 0;
2399 }
2400
2401 usleep( 300000 );
2402 $attachment_id = self::wdkit_sideload_deferred_media( $fetch_url );
2403 }
2404
2405 if ( is_wp_error( $attachment_id ) || ! $attachment_id ) {
2406 $attached[ $fetch_url ] = 0;
2407 return 0;
2408 }
2409
2410 $local_url = wp_get_attachment_url( $attachment_id );
2411
2412 if ( $local_url ) {
2413 update_post_meta( $attachment_id, '_elementor_source_image_hash', sha1( $local_url ) );
2414 update_post_meta( $attachment_id, 'tpgb_source_image_key', sha1( $local_url ) );
2415 }
2416
2417 update_post_meta( $attachment_id, '_wdkit_deferred_source_url', $fetch_url );
2418 }
2419
2420 $attached[ $fetch_url ] = (int) $attachment_id;
2421
2422 return (int) $attachment_id;
2423 };
2424
2425 $urls = array_values( array_unique( array_filter( array_map( 'esc_url_raw', $image_urls ) ) ) );
2426
2427 /* Full-size images first, so a size variant further down the list finds its original
2428 * already in the library instead of starting another download. */
2429 usort(
2430 $urls,
2431 function ( $a, $b ) {
2432 return (int) ( '' !== self::wdkit_size_variant_of( $a ) ) - (int) ( '' !== self::wdkit_size_variant_of( $b ) );
2433 }
2434 );
2435
2436 foreach ( $urls as $source_url ) {
2437 $fetch_url = Wdkit_Image_Guard::cap_pexels_source( $source_url );
2438 $map_keys = ( $fetch_url !== $source_url ) ? array( $source_url, $fetch_url ) : array( $source_url );
2439
2440 /* A WordPress size variant (photo-300x298.png) of an image whose original
2441 * (photo.png) can be fetched: import the original once and point this reference at
2442 * the closest size WordPress generates for it. A kit page carried 31 such variants
2443 * among 50 images, each downloaded and resized on its own (ClickUp single-template
2444 * speed-up). Falls back to the variant itself when the original is unavailable. */
2445 $original = self::wdkit_size_variant_of( $fetch_url );
2446
2447 if ( '' !== $original ) {
2448 $original_id = $attach( $original );
2449
2450 if ( $original_id ) {
2451 preg_match( '/-(\d+)x(\d+)\.[a-z0-9]+(?:$|\?)/i', $fetch_url, $dims );
2452
2453 $sized_url = ! empty( $dims )
2454 ? wp_get_attachment_image_url( $original_id, array( (int) $dims[1], (int) $dims[2] ) )
2455 : '';
2456 $sized_url = $sized_url ? $sized_url : wp_get_attachment_url( $original_id );
2457
2458 if ( $sized_url ) {
2459 foreach ( $map_keys as $map_key ) {
2460 $url_map[ $map_key ] = array(
2461 'id' => $original_id,
2462 'url' => $sized_url,
2463 );
2464 }
2465
2466 continue;
2467 }
2468 }
2469 }
2470
2471 $attachment_id = $attach( $fetch_url );
2472 $local_url = $attachment_id ? wp_get_attachment_url( $attachment_id ) : '';
2473
2474 if ( ! $local_url ) {
2475 continue;
2476 }
2477
2478 foreach ( $map_keys as $map_key ) {
2479 $url_map[ $map_key ] = array(
2480 'id' => $attachment_id,
2481 'url' => $local_url,
2482 );
2483 }
2484 }
2485
2486 if ( empty( $url_map ) ) {
2487 return;
2488 }
2489
2490 if ( 'elementor' === $builder && did_action( 'elementor/loaded' ) ) {
2491 $raw = get_post_meta( $post_id, '_elementor_data', true );
2492 $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
2493
2494 if ( is_array( $data ) ) {
2495 $data = $this->wdkit_replace_deferred_media( $data, $url_map );
2496
2497 // The finalize sweep deliberately skips a page while its media is queued here
2498 // (see wdkit_sweep_attachment_ids()), and the remap above only touches the URLs
2499 // this event was handed. Anything else on the page that already had a local URL
2500 // beside a stale id - the picked stock images the AI path substitutes by URL
2501 // alone, for instance - would otherwise never get its id corrected by anyone:
2502 // the sweep passed the page over, and this callback never looked. Same walk the
2503 // sweep would have done, restricted to local URLs so nothing downloads from here.
2504 $data = self::wdkit_repair_attachment_ids( $data, true );
2505
2506 // Last resort for anything the rewrite above missed: point it at a file that
2507 // already exists locally. Costs one indexed meta lookup per foreign control
2508 // and never downloads, so it is safe to run on every event.
2509 $relinked = 0;
2510 $data = self::wdkit_relink_known_foreign_media( $data, $relinked );
2511
2512 if ( $relinked && defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
2513 error_log( sprintf( '[WDKIT] relinked %d already-local media reference(s) on post %d', $relinked, $post_id ) );
2514 }
2515
2516 // wp_slash() is not optional here. update_post_meta() runs wp_unslash() on
2517 // the value, so handing it raw JSON strips every backslash the encoder put
2518 // in - `\/`, `\"`, `\uXXXX` - and what lands in the row is no longer
2519 // parseable. Elementor then reads zero elements off the page: it regenerates
2520 // post-N.css as empty, enqueues nothing, and the theme falls back to the
2521 // post_content mirror, so the page renders its text completely unstyled.
2522 // Elementor's own writer does the same thing for the same reason - see the
2523 // wp_slash() in Document::save_elements().
2524 update_post_meta( $post_id, '_elementor_data', wp_slash( wp_json_encode( $data ) ) );
2525
2526 // Elementor mirrors the rendered output into post_content as well, and that
2527 // copy is what themes and search read. Rewriting only _elementor_data leaves
2528 // it pointing at the template's own site - invisible in the editor, but the
2529 // front end still requests those images from the source CDN. Same substring
2530 // swap the non-Elementor branch below performs, and through the same writer,
2531 // so the markup is not put through kses in this userless context.
2532 $post = get_post( $post_id );
2533
2534 if ( $post && ! empty( $post->post_content ) ) {
2535 $content = $post->post_content;
2536
2537 foreach ( $url_map as $source_url => $local ) {
2538 $content = self::wdkit_replace_url_all_encodings( $content, $source_url, $local['url'] );
2539 }
2540
2541 // Same last-resort relink as the builder data above. post_content is the
2542 // copy the theme actually renders, so a reference left here is visible
2543 // even when _elementor_data is correct.
2544 foreach ( self::wdkit_collect_foreign_media_urls( $content ) as $stray ) {
2545 $existing = self::wdkit_deferred_attachment_for_url( $stray );
2546
2547 if ( $existing && self::wdkit_is_usable_attachment( $existing ) ) {
2548 $local_stray = wp_get_attachment_url( $existing );
2549
2550 if ( $local_stray ) {
2551 $content = self::wdkit_replace_url_all_encodings( $content, $stray, $local_stray );
2552 }
2553 }
2554 }
2555
2556 if ( $content !== $post->post_content ) {
2557 self::wdkit_write_post_content( $post_id, $content );
2558 }
2559 }
2560
2561 // Invalidate last, and only for this page. Last, because a render that
2562 // starts between the two writes above would otherwise regenerate its CSS
2563 // and element cache from half-rewritten data and then cache that. Only this
2564 // page, because the alternative is a site-wide flush - see
2565 // wdkit_invalidate_elementor_page_cache().
2566 self::wdkit_invalidate_elementor_page_cache( $post_id );
2567
2568 // Then fix any OTHER page referencing the same files. Only the first event to
2569 // see a URL downloads it; without this, a sibling that references the same
2570 // file but whose own event never carried that URL keeps the remote reference
2571 // permanently. See wdkit_apply_url_map_to_siblings().
2572 $siblings = $this->wdkit_apply_url_map_to_siblings( $url_map, $post_id );
2573
2574 if ( $siblings && defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
2575 error_log( sprintf( '[WDKIT] repaired %d sibling page(s) sharing media with post %d', $siblings, $post_id ) );
2576 }
2577 }
2578 } else {
2579 $post = get_post( $post_id );
2580
2581 if ( $post ) {
2582 $content = $post->post_content;
2583
2584 foreach ( $url_map as $source_url => $local ) {
2585 $content = self::wdkit_replace_url_all_encodings( $content, $source_url, $local['url'] );
2586 }
2587
2588 if ( $content !== $post->post_content ) {
2589 // The replace above fixes every `url` but leaves the `id` sitting beside it
2590 // still pointing at the source site's attachment. Blocks that render from
2591 // the url look correct either way; the ones that render through the
2592 // attachment id (tp-team-listing, tp-testimonials, tp-image, and
2593 // tp-container backgrounds) resolve nothing and render empty. Declining
2594 // returns null, in which case the url-only content above is kept exactly
2595 // as it is today.
2596 $remapped = $this->wdkit_remap_attachment_ids( $content, $url_map );
2597
2598 if ( null !== $remapped ) {
2599 $content = $remapped;
2600 }
2601
2602 // Separate concern, separate pass: the id remap above fixes the block
2603 // attribute, but process_image() also stamped the id into the rendered
2604 // <img> tag's class at import time, when the image was still remote.
2605 // Declining leaves the content exactly as the step above produced it.
2606 $classes_fixed = $this->wdkit_remap_wp_image_classes( $content, $url_map );
2607
2608 if ( null !== $classes_fixed ) {
2609 $content = $classes_fixed;
2610 }
2611
2612 self::wdkit_write_post_content( $post_id, $content );
2613
2614 // The page's per-post CSS file (backgrounds, and anything else the block
2615 // CSS generator resolves through an attachment id) was built synchronously
2616 // at import time, before this deferred pass ever ran - so it still reads
2617 // whatever the deferred images' source urls/ids were back then. Rebuild it
2618 // now that post_content points at the local copies, the same way the
2619 // synchronous create path already does right after wp_insert_post().
2620 self::wdkit_rebuild_block_css( $post_id );
2621 }
2622 }
2623 }
2624
2625 clean_post_cache( $post_id );
2626 }
2627
2628 /**
2629 * Throw away Elementor's cached render of one page, and only that page.
2630 *
2631 * The deferred pass rewrites `_elementor_data` behind Elementor's back, so three
2632 * per-post caches are left describing the version that still pointed at the source
2633 * CDN: the generated CSS file (background-image rules resolve through the attachment
2634 * id), `_elementor_element_cache` (the rendered element markup) and
2635 * `_elementor_page_assets`. All three have to go for the next render to be correct.
2636 *
2637 * This used to be `files_manager->clear_cache()`, which is not a per-page call at all:
2638 * it globs and unlinks *every* file in uploads/elementor/css, then runs
2639 * delete_post_meta_by_key() for all three keys across *every* post, and drops the
2640 * global CSS option. One call measured here destroyed 16 CSS files and 15 meta rows.
2641 *
2642 * That is actively harmful in this context, because one of these events is scheduled
2643 * per imported page - eleven of them, two seconds apart, on a kit this size. A visitor
2644 * who loads any page during that window gets HTML whose `<link>` points at
2645 * post-N.css, and the file is deleted out from under the browser before it is fetched:
2646 * the page renders completely unstyled. It self-heals on a later render, which is
2647 * exactly why it reads as "the layout breaks when I refresh".
2648 *
2649 * Scoping it also means pages the deferred pass never touched keep their CSS instead
2650 * of being made to regenerate for nothing.
2651 *
2652 * @since 2.6.6
2653 *
2654 * @param int $post_id Page whose cached render is now stale.
2655 * @return void
2656 */
2657 public static function wdkit_invalidate_elementor_page_cache( $post_id ) {
2658
2659 $post_id = (int) $post_id;
2660
2661 if ( ! $post_id ) {
2662 return;
2663 }
2664
2665 // Same call Elementor's own Document::save() makes - deletes the css file and the
2666 // _elementor_css meta for this one post.
2667 if ( class_exists( '\\Elementor\\Core\\Files\\CSS\\Post' ) ) {
2668 \Elementor\Core\Files\CSS\Post::create( $post_id )->delete();
2669 } else {
2670 // Older Elementor without the CSS file classes: the meta alone still forces a
2671 // regeneration, which is what actually matters here.
2672 delete_post_meta( $post_id, '_elementor_css' );
2673 }
2674
2675 // Document::save() pairs the css delete with delete_cache(); that method is
2676 // protected, and these are the keys it clears.
2677 delete_post_meta( $post_id, '_elementor_element_cache' );
2678 delete_post_meta( $post_id, '_elementor_page_assets' );
2679 }
2680
2681 /**
2682 *
2683 * Get Elementor Global color and Typography.
2684 *
2685 * @since 1.1.16
2686 */
2687 /**
2688 * Kit settings holding The Plus Addons' own globals.
2689 *
2690 * These sit in the Elementor kit's `_elementor_page_settings` alongside Elementor's
2691 * system_colors / system_typography, but the save flow only ever collected the four
2692 * Elementor keys. Widgets reference an entry in these lists by its `_id` through a
2693 * `tp_global_preset` setting, so a template saved without them travels with the
2694 * reference but not the definition - which is why imported sections come in missing
2695 * their button styling, radii and shadows.
2696 *
2697 * @since 2.6.4
2698 *
2699 * @return array Kit setting keys.
2700 */
2701 private function wdkit_tp_global_kit_keys() {
2702 return array(
2703 'tp_global_button_style_list',
2704 'tp_global_dimensions_list',
2705 'tp_global_box_shadow_list',
2706 'tp_global_gradient_list',
2707 'tp_global_gsap_list',
2708 'tp_global_scroll_animation_list',
2709 'tp_text_global_gsap_list',
2710 'tp_image_global_gsap_list',
2711 );
2712 }
2713
2714 /**
2715 * Merge incoming Plus globals into the active kit, keyed by `_id`.
2716 *
2717 * Entries are matched on their `_id`, never on position: an existing entry is always
2718 * left as it is, and only genuinely new ones are appended. That matters because
2719 * widgets - and the entries themselves, a button style points at dimension and shadow
2720 * entries - resolve by `_id`. Renumbering or overwriting would repoint references on
2721 * the destination site's own content.
2722 *
2723 * @since 2.6.4
2724 *
2725 * @param array $incoming Lists captured with the template.
2726 * @return bool True when the kit was changed.
2727 */
2728 /**
2729 * Global colour / typography ids this site already defines.
2730 *
2731 * @since 2.6.4
2732 *
2733 * @param array $kit_meta Kit `_elementor_page_settings`.
2734 * @return array{color:array<string,bool>,typography:array<string,bool>}
2735 */
2736 private function wdkit_known_global_ids( $kit_meta ) {
2737 $known = array(
2738 'color' => array(),
2739 'typography' => array(),
2740 );
2741
2742 $sources = array(
2743 'color' => array( 'system_colors', 'custom_colors' ),
2744 'typography' => array( 'system_typography', 'custom_typography' ),
2745 );
2746
2747 foreach ( $sources as $kind => $keys ) {
2748 foreach ( $keys as $key ) {
2749 if ( empty( $kit_meta[ $key ] ) || ! is_array( $kit_meta[ $key ] ) ) {
2750 continue;
2751 }
2752
2753 foreach ( $kit_meta[ $key ] as $entry ) {
2754 if ( ! empty( $entry['_id'] ) ) {
2755 $known[ $kind ][ $entry['_id'] ] = true;
2756 }
2757 }
2758 }
2759 }
2760
2761 return $known;
2762 }
2763
2764 /**
2765 * Make one incoming Plus global's colour / font references resolvable here.
2766 *
2767 * A Plus global can point at an Elementor global: the "Primary Button" entry holds
2768 * `__globals__: { text_color: "globals/colors?id=72e09b4", … }`, which The Plus Addons
2769 * turns into `var(--e-global-color-72e09b4)`. Elementor only emits that variable for ids
2770 * present in the kit, so on a site without `72e09b4` the button renders with no colour.
2771 *
2772 * Two cases, and the difference is deliberate:
2773 *
2774 * - The site ALREADY defines that id — leave the reference alone. The button then picks
2775 * up the destination's own colour, which is the point of a global. Their palette is
2776 * never read from or written to beyond this check.
2777 * - The site does NOT define it — write the captured value straight into the entry and
2778 * drop the reference, so it renders as designed.
2779 *
2780 * Nothing is ever added to the user's global colours or fonts. An earlier version injected
2781 * the missing definitions into their palette, which made the reference resolve but grew
2782 * their Site Settings by every colour an imported template happened to use.
2783 *
2784 * @since 2.6.4
2785 *
2786 * @param array $entry One repeater entry.
2787 * @param array $refs Definitions captured with the template.
2788 * @param array $known Ids this site defines, from wdkit_known_global_ids().
2789 * @return array Entry, with unresolvable references replaced by their values.
2790 */
2791 private function wdkit_resolve_entry_globals( $entry, $refs, $known ) {
2792 if ( empty( $entry['__globals__'] ) || ! is_array( $entry['__globals__'] ) ) {
2793 return $entry;
2794 }
2795
2796 foreach ( $entry['__globals__'] as $control => $ref ) {
2797 if ( ! is_string( $ref ) || false === strpos( $ref, 'id=' ) ) {
2798 continue;
2799 }
2800
2801 if ( false !== strpos( $ref, 'globals/colors' ) ) {
2802 $kind = 'color';
2803 } elseif ( false !== strpos( $ref, 'globals/typography' ) ) {
2804 $kind = 'typography';
2805 } else {
2806 continue;
2807 }
2808
2809 $id = substr( $ref, strpos( $ref, 'id=' ) + 3 );
2810 if ( '' === $id || isset( $known[ $kind ][ $id ] ) ) {
2811 // Defined here already — their value wins.
2812 continue;
2813 }
2814
2815 $definition = null;
2816 foreach ( ( $refs[ $kind ] ?? array() ) as $candidate ) {
2817 if ( is_array( $candidate ) && ( $candidate['_id'] ?? '' ) === $id ) {
2818 $definition = $candidate;
2819 break;
2820 }
2821 }
2822
2823 if ( null === $definition ) {
2824 // Nothing captured for it, so leave the reference rather than blank the field.
2825 continue;
2826 }
2827
2828 if ( 'color' === $kind ) {
2829 if ( empty( $definition['color'] ) ) {
2830 continue;
2831 }
2832
2833 $entry[ $control ] = $definition['color'];
2834 } else {
2835 // A typography global expands into its own set of controls: the reference is
2836 // held under e.g. `typography_typography`, and each definition key replaces
2837 // that suffix — `typography_font_family`, `typography_font_weight`, and so on.
2838 foreach ( $definition as $def_key => $def_value ) {
2839 if ( '_id' === $def_key || 'title' === $def_key ) {
2840 continue;
2841 }
2842
2843 $entry[ str_replace( 'typography_typography', $def_key, $control ) ] = $def_value;
2844 }
2845 }
2846
2847 unset( $entry['__globals__'][ $control ] );
2848 }
2849
2850 return $entry;
2851 }
2852
2853 private function wdkit_merge_tp_globals( $incoming, $refs = array() ) {
2854 if ( empty( $incoming ) || ! is_array( $incoming ) ) {
2855 return false;
2856 }
2857
2858 $kit_id = get_option( 'elementor_active_kit' );
2859 if ( empty( $kit_id ) ) {
2860 return false;
2861 }
2862
2863 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
2864 if ( ! is_array( $kit_meta ) ) {
2865 $kit_meta = array();
2866 }
2867
2868 // Which global ids this site already defines. The Plus Addons turns a reference into
2869 // var(--e-global-color-<_id>), and Elementor only emits that variable for ids in the
2870 // kit — so a reference the destination does not define resolves to nothing at all.
2871 $known = $this->wdkit_known_global_ids( $kit_meta );
2872
2873 $changed = false;
2874
2875 foreach ( $this->wdkit_tp_global_kit_keys() as $key ) {
2876 if ( empty( $incoming[ $key ] ) || ! is_array( $incoming[ $key ] ) ) {
2877 continue;
2878 }
2879
2880 $existing = ( ! empty( $kit_meta[ $key ] ) && is_array( $kit_meta[ $key ] ) ) ? $kit_meta[ $key ] : array();
2881
2882 $seen = array();
2883 foreach ( $existing as $entry ) {
2884 if ( ! empty( $entry['_id'] ) ) {
2885 $seen[ $entry['_id'] ] = true;
2886 }
2887 }
2888
2889 foreach ( $incoming[ $key ] as $entry ) {
2890 if ( ! is_array( $entry ) || empty( $entry['_id'] ) || isset( $seen[ $entry['_id'] ] ) ) {
2891 continue;
2892 }
2893
2894 // Only ever rewrite the entry being added — never one already in the kit.
2895 $existing[] = $this->wdkit_resolve_entry_globals( $entry, $refs, $known );
2896 $seen[ $entry['_id'] ] = true;
2897 $changed = true;
2898 }
2899
2900 $kit_meta[ $key ] = array_values( $existing );
2901 }
2902
2903 if ( $changed ) {
2904 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
2905
2906 // Writing kit meta directly does not rebuild the kit stylesheet, so the
2907 // merged globals would never reach the frontend.
2908 $this->wdkit_regenerate_elementor_kit_css();
2909 }
2910
2911 return $changed;
2912 }
2913
2914 protected function wdkit_get_global_val() {
2915
2916 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
2917
2918 /* Answered as-is for a builder that is neither of the two below - previously
2919 * `$response` was never set on that path and wp_send_json() read an undefined
2920 * variable (PHP warning, ClickUp 14ynqxz2tru). */
2921 $response = array(
2922 'message' => __( 'Unknown builder', 'wdesignkit' ),
2923 'description' => __( 'The builder must be elementor or gutenberg.', 'wdesignkit' ),
2924 'success' => false,
2925 );
2926
2927 if ( 'elementor' === $builder ) {
2928 $kit_id = get_option( 'elementor_active_kit' );
2929 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
2930 /* Elementor was installed moments ago in this import, and its kit is only
2931 * created by its own activation hook - which may not have run in a request
2932 * that loaded it (ClickUp 14ynqxz2tru). Same fallback as
2933 * wdkit_apply_site_globals_data(). */
2934 \Elementor\Core\Kits\Manager::create_default_kit();
2935 $kit_id = get_option( 'elementor_active_kit' );
2936 }
2937 if ( empty( $kit_id ) ) {
2938 $response = array(
2939 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
2940 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
2941 'success' => false,
2942 );
2943
2944 wp_send_json( $response );
2945 wp_die();
2946 }
2947
2948 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
2949 if ( empty( $kit_meta['system_colors'] ) ) {
2950 $static_meta = array(
2951 'system_colors' => array(
2952 0 => array(
2953 '_id' => 'primary',
2954 'title' => 'Primary',
2955 'color' => '#6EC1E4',
2956 ),
2957 1 => array(
2958 '_id' => 'secondary',
2959 'title' => 'Secondary',
2960 'color' => '#54595F',
2961 ),
2962 2 => array(
2963 '_id' => 'text',
2964 'title' => 'Text',
2965 'color' => '#7A7A7A',
2966 ),
2967 3 => array(
2968 '_id' => 'accent',
2969 'title' => 'Accent',
2970 'color' => '#61CE70',
2971 ),
2972 ),
2973 'custom_colors' => array(),
2974 'system_typography' => array(
2975 0 => array(
2976 '_id' => 'primary',
2977 'title' => 'Primary',
2978 'typography_typography' => 'custom',
2979 'typography_font_family' => 'Roboto',
2980 'typography_font_weight' => '600',
2981 ),
2982 1 => array(
2983 '_id' => 'secondary',
2984 'title' => 'Secondary',
2985 'typography_typography' => 'custom',
2986 'typography_font_family' => 'Roboto Slab',
2987 'typography_font_weight' => '400',
2988 ),
2989 2 => array(
2990 '_id' => 'text',
2991 'title' => 'Text',
2992 'typography_typography' => 'custom',
2993 'typography_font_family' => 'Roboto',
2994 'typography_font_weight' => '400',
2995 ),
2996 3 => array(
2997 '_id' => 'accent',
2998 'title' => 'Accent',
2999 'typography_typography' => 'custom',
3000 'typography_font_family' => 'Roboto',
3001 'typography_font_weight' => '500',
3002 ),
3003 ),
3004 'custom_typography' => array(),
3005 'default_generic_fonts' => 'Sans-serif',
3006 'site_name' => ! empty( get_bloginfo( 'name' ) ) ? get_bloginfo( 'name' ) : '',
3007 'page_title_selector' => 'h1.entry-title',
3008 'activeItemIndex' => 1,
3009 'viewport_md' => 768,
3010 'viewport_lg' => 1025,
3011 );
3012
3013 /* Fill in what is MISSING. Do not replace the kit.
3014 *
3015 * The test above is about ONE key — `system_colors`, absent on a kit Elementor
3016 * has not finished setting up — but this used to answer it with
3017 * `$kit_meta = $static_meta`, throwing away every other key the kit had.
3018 * `custom_colors` and `custom_typography` are in that set and are exactly
3019 * where a site's own global palette lives, so any site with custom Elementor
3020 * globals but no system colours lost all of them the moment this read endpoint
3021 * ran. A *read* endpoint. It is reached from the import wizard and from the
3022 * globals screen, so the loss looked like it came from importing.
3023 *
3024 * A key is filled when it is absent, or present but empty while the default
3025 * has something to offer — so a kit that already has custom colours keeps
3026 * them, a customised `page_title_selector` is not reset, and a genuinely bare
3027 * kit still comes out of here with the stock palette the UI needs to render.
3028 */
3029 $kit_meta = is_array( $kit_meta ) ? $kit_meta : array();
3030 $filled = false;
3031
3032 foreach ( $static_meta as $meta_key => $meta_value ) {
3033 $missing = ! isset( $kit_meta[ $meta_key ] )
3034 || ( empty( $kit_meta[ $meta_key ] ) && ! empty( $meta_value ) );
3035
3036 if ( $missing ) {
3037 $kit_meta[ $meta_key ] = $meta_value;
3038 $filled = true;
3039 }
3040 }
3041
3042 if ( $filled ) {
3043 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
3044 }
3045 }
3046
3047 $system_colors = ! empty( $kit_meta['system_colors'] ) ? $kit_meta['system_colors'] : array();
3048 $custom_colors = ! empty( $kit_meta['custom_colors'] ) ? $kit_meta['custom_colors'] : array();
3049 $system_typography = ! empty( $kit_meta['system_typography'] ) ? $kit_meta['system_typography'] : array();
3050 $custom_typography = ! empty( $kit_meta['custom_typography'] ) ? $kit_meta['custom_typography'] : array();
3051
3052 $color_array = array_merge( $system_colors, $custom_colors );
3053 $typo_array = array_merge( $system_typography, $custom_typography );
3054
3055 $global_data = array(
3056 'color' => $color_array,
3057 'typography' => $typo_array,
3058 );
3059
3060 $response = array(
3061 'message' => __( 'Global data Found', 'wdesignkit' ),
3062 'description' => __( 'Global Color and Typography found', 'wdesignkit' ),
3063 'data' => $global_data,
3064 'success' => true,
3065 );
3066
3067 } elseif ( 'gutenberg' === $builder ) {
3068
3069 $plus_settings = get_option( 'tpgb_global_options', false );
3070 $plus_settings = ! empty( $plus_settings ) ? json_decode( $plus_settings, true ) : json_decode( '[]' );
3071
3072 if ( empty( $plus_settings ) ) {
3073
3074 $static_meta = self::wdkit_default_gutenberg_globals();
3075
3076 update_option( 'tpgb_global_options', json_encode( $static_meta ) );
3077 $plus_settings = $static_meta;
3078 }
3079
3080 $active_id = ! empty( $plus_settings['active'] ) ? $plus_settings['active'] : '';
3081 $preset_array = ! empty( $plus_settings['presets'] ) ? $plus_settings['presets'] : array();
3082 $act_preset = ! empty( $plus_settings['presets'][ $active_id ] ) ? $plus_settings['presets'][ $active_id ] : array();
3083
3084 foreach ( $act_preset['colors'] as $index => &$item ) {
3085 $item['id'] = $index + 1;
3086 }
3087 unset( $item );
3088
3089 foreach ( $act_preset['typography'] as $index => &$item ) {
3090 $item['id'] = $index + 1;
3091 }
3092 unset( $item );
3093
3094 $act_preset['color'] = $act_preset['colors'];
3095 unset( $act_preset['colors'] );
3096
3097 $response = array(
3098 'message' => __( 'Global data Found', 'wdesignkit' ),
3099 'description' => __( 'Global Color and Typography found', 'wdesignkit' ),
3100 'data' => $act_preset,
3101 'success' => true,
3102 );
3103 }
3104
3105 wp_send_json( $response );
3106 wp_die();
3107 }
3108
3109 /**
3110 *
3111 * Get site settings.
3112 *
3113 * @since 2.1.3
3114 */
3115 protected function wdkit_get_site_setting() {
3116
3117 $kit_id = get_option( 'elementor_active_kit' );
3118 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
3119 /* Same fallback as wdkit_get_global_val() (ClickUp 14ynqxz2tru). */
3120 \Elementor\Core\Kits\Manager::create_default_kit();
3121 $kit_id = get_option( 'elementor_active_kit' );
3122 }
3123
3124 if ( empty( $kit_id ) ) {
3125 $response = array(
3126 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
3127 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
3128 'success' => false,
3129 );
3130
3131 wp_send_json( $response );
3132 wp_die();
3133 }
3134
3135 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
3136
3137 $container_width = ! empty( $kit_meta['container_width'] ) ? $kit_meta['container_width'] : array();
3138 $globals = ! empty( $kit_meta['__globals__'] ) ? $kit_meta['__globals__'] : array();
3139 $body_background_color = ! empty( $kit_meta['body_background_color'] ) ? $kit_meta['body_background_color'] : array();
3140
3141 $site_globals = array(
3142 'body_background_color' => $body_background_color,
3143 'container_width' => $container_width,
3144 'globals' => $globals,
3145 );
3146
3147 $response = array(
3148 'message' => __( 'Global data Found', 'wdesignkit' ),
3149 'description' => __( 'Global Color and Typography found', 'wdesignkit' ),
3150 'data' => $site_globals,
3151 'success' => true,
3152 );
3153
3154 wp_send_json( $response );
3155 wp_die();
3156 }
3157
3158 /**
3159 *
3160 * update site settings.
3161 *
3162 * @since 2.1.3
3163 */
3164 protected function wdkit_update_site_setting() {
3165
3166 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
3167 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
3168
3169 $response = $this->wdkit_apply_site_globals_data( $builder, $site_data );
3170
3171 wp_send_json( $response );
3172 wp_die();
3173 }
3174
3175 /**
3176 * Apply a kit's site-level globals: container width, body background, `__globals__`.
3177 *
3178 * Extracted from wdkit_update_site_setting() so the PHP import runner can reach it
3179 * without `$_POST`. Direct assignment rather than a merge, so running it twice leaves
3180 * the same result — the browser behaves identically here.
3181 *
3182 * The AJAX action above is now a thin adapter over this and its response is unchanged.
3183 *
3184 * @since 2.6.5
3185 *
3186 * @param string $builder 'elementor'|'gutenberg'.
3187 * @param array $site_data Global values collected from the kit's templates.
3188 * @return array Response array, exactly as the AJAX action used to emit.
3189 */
3190 public function wdkit_apply_site_globals_data( $builder, $site_data ) {
3191 $builder = is_string( $builder ) ? $builder : '';
3192 $site_data = is_array( $site_data ) ? $site_data : array();
3193
3194 if ( 'elementor' == $builder ) {
3195 $kit_id = get_option( 'elementor_active_kit' );
3196 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
3197 \Elementor\Core\Kits\Manager::create_default_kit();
3198 $kit_id = get_option( 'elementor_active_kit' );
3199 }
3200
3201 if ( ! $kit_id ) {
3202 $response = array(
3203 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
3204 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
3205 'success' => false,
3206 );
3207
3208 return $response;
3209 }
3210
3211 // A freshly created kit has no `_elementor_page_settings` meta yet,
3212 // so an empty result here is a valid starting point, not an error.
3213 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
3214 if ( ! is_array( $kit_meta ) ) {
3215 $kit_meta = array();
3216 }
3217
3218 $kit_meta['container_width'] = ! empty( $site_data['container_width'] ) ? $site_data['container_width'] : array();
3219 $kit_meta['__globals__'] = ! empty( $site_data['globals'] ) ? $site_data['globals'] : array();
3220 $kit_meta['body_background_color'] = ! empty( $site_data['body_background_color'] ) ? $site_data['body_background_color'] : array();
3221
3222 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
3223
3224 // Regenerate Elementor's cached CSS. Writing the kit meta directly does
3225 // not rebuild the kit stylesheet, so the imported body background colour
3226 // and container width would otherwise never render on the frontend.
3227 $this->wdkit_regenerate_elementor_kit_css();
3228
3229 $response = array(
3230 'message' => __( 'Site data Updated', 'wdesignkit' ),
3231 'description' => __( 'Site Globals Updated', 'wdesignkit' ),
3232 'success' => true,
3233 );
3234
3235 } elseif ( 'gutenberg' == $builder ) {
3236 $plus_settings = get_option( 'tpgb_global_options' );
3237
3238 $site_preset = json_decode( $plus_settings, true );
3239 $site_preset['globalContainer'] = $site_data;
3240
3241 update_option( 'tpgb_global_options', json_encode( $site_preset ) );
3242
3243 $response = array(
3244 'message' => __( 'Site data Updated', 'wdesignkit' ),
3245 'description' => __( 'Site Globals Updated', 'wdesignkit' ),
3246 'success' => true,
3247 );
3248
3249 } else {
3250 $response = array(
3251 'message' => __( 'Builder Not Found !', 'wdesignkit' ),
3252 'description' => __( 'Template Builder not Found', 'wdesignkit' ),
3253 'success' => true,
3254 );
3255 }
3256
3257 return $response;
3258 }
3259
3260 /**
3261 *
3262 * Update Elementor Global color and Typography.
3263 *
3264 * @since 1.1.20
3265 */
3266 protected function wdkit_update_global_val() {
3267
3268 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
3269
3270 $g_color = ! empty( $_POST['g_color'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_color'] ) ), true ) : array();
3271 $g_typo = ! empty( $_POST['g_typography'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_typography'] ) ), true ) : array();
3272 $new_preset = ! empty( $_POST['new_preset'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['new_preset'] ) ), true ) : array();
3273
3274 $response = $this->wdkit_apply_global_values_data( $builder, $g_color, $g_typo, $new_preset );
3275
3276 wp_send_json( $response );
3277 wp_die();
3278 }
3279
3280 /**
3281 * Apply a kit's global colours and typography.
3282 *
3283 * Extracted from wdkit_update_global_val() so the PHP import runner can reach it
3284 * without `$_POST`.
3285 *
3286 * BEHAVIOUR NOTE — the Elementor branch merges with `array_merge( $new, $existing )` on
3287 * numeric-keyed lists, which appends rather than replaces. Running it twice therefore
3288 * accumulates duplicate entries. That is preserved here exactly, because it is the
3289 * shipped browser behaviour and this method backs the AJAX action. Callers that need an
3290 * idempotent apply should use Wdkit_Import_Globals::apply(), which de-duplicates by
3291 * `_id` before calling in.
3292 *
3293 * @since 2.6.5
3294 *
3295 * @param string $builder 'elementor'|'gutenberg'.
3296 * @param array $g_color Global colour entries.
3297 * @param array $g_typo Global typography entries.
3298 * @param array $new_preset Gutenberg preset {key, ...}.
3299 * @return array Response array, exactly as the AJAX action used to emit.
3300 */
3301 public function wdkit_apply_global_values_data( $builder, $g_color = array(), $g_typo = array(), $new_preset = array() ) {
3302 $builder = is_string( $builder ) ? $builder : '';
3303
3304 if ( 'elementor' == $builder ) {
3305
3306 $g_color = is_array( $g_color ) ? $g_color : array();
3307 $g_typo = is_array( $g_typo ) ? $g_typo : array();
3308
3309 // Get colors from Elementor Site Kit
3310 $kit_id = get_option( 'elementor_active_kit' );
3311 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
3312 // No kit has ever been created on this site (the option is only
3313 // ever populated by Elementor's own activation hook). Create one
3314 // via Elementor's own helper so the import has somewhere to write.
3315 \Elementor\Core\Kits\Manager::create_default_kit();
3316 $kit_id = get_option( 'elementor_active_kit' );
3317 }
3318
3319 if ( ! $kit_id ) {
3320 $response = array(
3321 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
3322 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
3323 'success' => false,
3324 );
3325
3326 return $response;
3327 }
3328
3329 // A freshly created kit has no `_elementor_page_settings` meta yet,
3330 // so an empty result here is a valid starting point, not an error.
3331 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
3332 if ( ! is_array( $kit_meta ) ) {
3333 $kit_meta = array();
3334 }
3335
3336 $kit_meta['custom_colors'] = array_merge( $g_color, $kit_meta['custom_colors'] ?? array() );
3337 $kit_meta['custom_typography'] = array_merge( $g_typo, $kit_meta['custom_typography'] ?? array() );
3338
3339 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
3340
3341 // Regenerate Elementor's cached CSS. Writing the kit meta directly does
3342 // not rebuild the kit stylesheet, so the imported global colours and
3343 // fonts would otherwise never render on the frontend.
3344 $this->wdkit_regenerate_elementor_kit_css();
3345
3346 $response = array(
3347 'message' => __( 'Global data Updated', 'wdesignkit' ),
3348 'description' => __( 'Global Color and Typography Updated', 'wdesignkit' ),
3349 'success' => true,
3350 );
3351
3352 } elseif ( 'gutenberg' == $builder ) {
3353 $new_preset = is_array( $new_preset ) ? $new_preset : array();
3354 $new_preset_id = ! empty( $new_preset['key'] ) ? $new_preset['key'] : '';
3355 $plus_settings = get_option( 'tpgb_global_options' );
3356 $site_preset = json_decode( $plus_settings, true );
3357
3358 $site_preset['presets'][ $new_preset_id ] = $new_preset;
3359 $site_preset['active'] = $new_preset_id;
3360
3361 update_option( 'tpgb_global_options', json_encode( $site_preset ) );
3362
3363 $response = array(
3364 'message' => __( 'Global data Updated', 'wdesignkit' ),
3365 'description' => __( 'Global Color and Typography Updated', 'wdesignkit' ),
3366 'success' => true,
3367 );
3368
3369 } else {
3370 $response = array(
3371 'message' => __( 'Builder Not Found !', 'wdesignkit' ),
3372 'description' => __( 'Template Builder not Found', 'wdesignkit' ),
3373 'success' => true,
3374 );
3375 }
3376
3377 return $response;
3378 }
3379
3380 /**
3381 * Regenerate Elementor's cached CSS files after the active kit's
3382 * `_elementor_page_settings` meta has been changed directly.
3383 *
3384 * Elementor renders global colours, global fonts and the body background
3385 * colour into a cached kit stylesheet. Updating the meta via
3386 * update_post_meta() does not rebuild that stylesheet, so imported site
3387 * settings never reach the frontend until the cache is cleared. This
3388 * mirrors the clear_cache() call already used by the page/section import.
3389 *
3390 * @since 2.3.2
3391 *
3392 * @return void
3393 */
3394 protected function wdkit_regenerate_elementor_kit_css() {
3395 if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) ) {
3396 \Elementor\Plugin::$instance->files_manager->clear_cache();
3397 }
3398 }
3399
3400 /**
3401 *
3402 * Create Gutenberg page and save for re-generate css file.
3403 *
3404 * @since 1.2.3
3405 */
3406 protected function wdkit_update_preset() {
3407
3408 $act_type = ! empty( $_POST['act_type'] ) ? sanitize_text_field( $_POST['act_type'] ) : '';
3409 $post_id = ! empty( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
3410
3411 if ( 'create' == $act_type ) {
3412
3413 $page_id = wp_insert_post(
3414 array(
3415 'post_title' => 'WDesignKit Gutenberg',
3416 'post_status' => 'publish',
3417 'post_type' => 'post',
3418 'post_name' => sanitize_title( 'wdesignkit' ),
3419 'post_content' => '<!-- wp:heading --><h2 class="wp-block-heading">Add Your Heading Text Here<h2><!-- /wp:heading -->',
3420 'meta_input' => array(
3421 'gutenberg_preview' => true,
3422 '_wp_page_template' => 'default',
3423 ),
3424 )
3425 );
3426
3427 if ( is_wp_error( $page_id ) || ! $page_id ) {
3428 $response = array(
3429 'success' => true,
3430 'message' => esc_html__( 'Page Not Found!', 'wdesignkit' ),
3431 'description' => esc_html__( 'Page Not Found!', 'wdesignkit' ),
3432 );
3433
3434 wp_send_json( $response );
3435 wp_die();
3436 }
3437
3438 update_post_meta( $page_id, '_edit_lock', time() . ':1' );
3439 update_post_meta( $page_id, '_edit_last', get_current_user_id() );
3440
3441 $preview_url = admin_url( 'post.php?post=' . $page_id . '&action=edit' );
3442
3443 $response = array(
3444 'success' => true,
3445 'post_id' => $page_id,
3446 'preview_url' => $preview_url,
3447 'message' => esc_html__( 'Page Created', 'wdesignkit' ),
3448 'description' => esc_html__( 'Page Created', 'wdesignkit' ),
3449 );
3450
3451 wp_send_json( $response );
3452 wp_die();
3453
3454 }
3455
3456 if ( ! empty( $post_id ) && ( $act_type == 'remove' ) ) {
3457
3458 wp_delete_post( $post_id, true );
3459
3460 $response = array(
3461 'success' => true,
3462 'message' => esc_html__( 'post deleted', 'wdesignkit' ),
3463 'description' => esc_html__( 'post deleted', 'wdesignkit' ),
3464 );
3465
3466 wp_send_json( $response );
3467 wp_die();
3468 }
3469 }
3470
3471 /**
3472 *
3473 * It is For Find User Existing template List.
3474 *
3475 * @since 1.0.6
3476 */
3477 protected function wdkit_find_existing_template() {
3478 $array_data = array(
3479 'search' => isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '',
3480 'token' => isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '',
3481 'type' => isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : '',
3482 'u_id' => isset( $_POST['u_id'] ) ? sanitize_text_field( wp_unslash( $_POST['u_id'] ) ) : '',
3483 'builder' => isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '',
3484 'parpage' => isset( $_POST['parpage'] ) ? sanitize_text_field( wp_unslash( $_POST['parpage'] ) ) : 12,
3485 );
3486
3487 $response = $this->wkit_api_call( $array_data, 'existing_template' );
3488
3489 wp_send_json( $response );
3490 wp_die();
3491 }
3492
3493 /**
3494 *
3495 * It is For Update User Existing template List.
3496 *
3497 * @since 1.0.6
3498 */
3499 protected function wdkit_update_template() {
3500 $array_data = array(
3501 'data' => isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : '',
3502 'post_id' => isset( $_POST['post_id'] ) ? sanitize_text_field( wp_unslash( $_POST['post_id'] ) ) : '',
3503 'token' => isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '',
3504 'type' => isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : '',
3505 'id' => isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '',
3506 'global_data' => isset( $_POST['global_data'] ) ? wp_unslash( $_POST['global_data'] ) : array(),
3507 // 'global_font_family' => isset( $_POST['global_font_family'] ) ? wp_unslash( $_POST['global_font_family'] ) : array(),
3508 // 'global_color' => isset( $_POST['global_color'] ) ? wp_unslash( $_POST['global_color'] ) : array(),
3509 );
3510
3511 if ( ! empty( $array_data['post_id'] ) ) {
3512 $custom_fields = array();
3513 $post_id = $array_data['post_id'];
3514
3515 $meta_fields = get_post_custom( $post_id );
3516
3517 foreach ( $meta_fields as $key => $value ) {
3518 if ( str_contains( $key, 'nxt-' ) ) {
3519 $custom_fields[ $key ] = $value;
3520 }
3521 }
3522
3523 if ( ! empty( $custom_fields ) ) {
3524 $data = json_decode( $array_data['data'], true );
3525 $data['custom_meta'] = $custom_fields;
3526 $array_data['data'] = wp_json_encode( $data );
3527 }
3528 }
3529
3530 $array_data['remove'] = 'yes';
3531 $response = $this->wkit_api_call( $array_data, 'existing_template' );
3532 wp_send_json( $response );
3533 wp_die();
3534 }
3535
3536 /**
3537 *
3538 * It is Use for manage favourite template.
3539 *
3540 * @since 1.0.0
3541 */
3542 protected function wdkit_manage_favorite() {
3543 $template_id = isset( $_POST['template_id'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['template_id'] ) ) ) : 0;
3544 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
3545
3546 if ( empty( $email ) || empty( $template_id ) ) {
3547 $response = array(
3548 'message' => $this->e_msg_login,
3549 'description' => $this->e_desc_login,
3550 'success' => false,
3551 );
3552
3553 wp_send_json( $response );
3554 wp_die();
3555 }
3556
3557 $args = $this->wdkit_parse_args( $_POST );
3558 $args['token'] = $this->wdkit_login_user_token( $email );
3559
3560 unset( $args['email'] );
3561 $response = WDesignKit_Data_Query::get_data( 'manage_favorite', $args );
3562
3563 wp_send_json( $response );
3564 wp_die();
3565 }
3566
3567 /**
3568 *
3569 * It is Use for Check Plugin Dependency of template.
3570 *
3571 * @since 1.0.0
3572 * @version 1.0.9
3573 */
3574 protected function wdkit_check_plugins_depends() {
3575 $plugins = isset( $_POST['plugins'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['plugins'] ) ) ) : array();
3576 $update_plugin = array();
3577 $update_theme = array();
3578
3579 if ( empty( $plugins ) || ! is_array( $plugins ) ) {
3580 $this->wdkit_error_msg( array( 'plugins' => 'No Plugins' ) );
3581 }
3582
3583 $all_plugins = $this->get_plugins();
3584 foreach ( $plugins as $plugin ) {
3585 $pluginslug = ! empty( $plugin->plugin_slug ) ? sanitize_text_field( wp_unslash( $plugin->plugin_slug ) ) : '';
3586 $free_pro = ! empty( $plugin->freepro ) ? sanitize_text_field( wp_unslash( $plugin->freepro ) ) : '0';
3587 $type = ! empty( $plugin->type ) ? sanitize_text_field( wp_unslash( $plugin->type ) ) : 'plugin';
3588
3589 if ( is_null( $pluginslug ) ) {
3590 $plugin->status = 'warning';
3591 $update_plugin[] = $plugin;
3592 $update_theme[] = $plugin;
3593
3594 continue;
3595 }
3596
3597 if ( 'plugin' === $type ) {
3598 if ( ! is_plugin_active( $pluginslug ) ) {
3599 if ( ! isset( $all_plugins[ $pluginslug ] ) ) {
3600 if ( isset( $free_pro ) && '1' === $free_pro ) {
3601 $plugin->status = 'manually';
3602 } else {
3603 $plugin->status = 'unavailable';
3604 }
3605 } else {
3606 $plugin->status = 'inactive';
3607 }
3608
3609 $update_plugin[] = $plugin;
3610 } elseif ( is_plugin_active( $pluginslug ) ) {
3611 $plugin->status = 'active';
3612 $update_plugin[] = $plugin;
3613 }
3614 } elseif ( 'theme' === $type ) {
3615 $theme_array = array_keys( wp_get_themes() );
3616 $theme_slug = get_stylesheet();
3617 $parent_theme_slug = get_template();
3618
3619 if ( $theme_slug === $plugin->original_slug || $parent_theme_slug === $plugin->original_slug ) {
3620
3621 $plugin->status = 'active';
3622 } else {
3623 $theme_name = $plugin->original_slug;
3624 if ( ! in_array( $theme_name, $theme_array ) ) {
3625 if ( isset( $free_pro ) && '1' === $free_pro ) {
3626 $plugin->status = 'manually';
3627 } else {
3628 $plugin->status = 'unavailable';
3629 }
3630 } else {
3631 $plugin->status = 'inactive';
3632 }
3633 }
3634
3635 $update_theme[] = $plugin;
3636 }
3637 }
3638
3639 $response = array(
3640 'plugins' => $update_plugin,
3641 'theme' => $update_theme,
3642 'ele_container' => get_option( 'elementor_experiment-container', false ),
3643 );
3644
3645 $this->wdkit_success_msg( $response );
3646 }
3647
3648 /**
3649 *
3650 * It is Use for Install dependent plugin.
3651 *
3652 * @since 1.0.0
3653 * @version 1.0.9
3654 */
3655 protected function wdkit_install_plugins_depends() {
3656 $plugins = isset( $_POST['plugins'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['plugins'] ) ), true ) : array();
3657
3658 $responce = $this->wdkit_install_dependency_data( $plugins );
3659
3660 wp_send_json( $responce );
3661 wp_die();
3662 }
3663
3664 /**
3665 * Install or activate one kit dependency and describe the outcome.
3666 *
3667 * Extracted from wdkit_install_plugins_depends() so the PHP import runner can install
3668 * dependencies without `$_POST`. The plugin branch delegates to Wdkit_Depends_Installer
3669 * and the theme branch keeps its own activation path — themes are NOT installed through
3670 * the plugin installer, and routing one there would try to install a theme as a plugin.
3671 *
3672 * The AJAX action above is now a thin adapter over this and its response is unchanged.
3673 *
3674 * @since 2.6.5
3675 *
3676 * @param array $plugins Dependency record: {type, p_id, original_slug, plugin_slug, ...}.
3677 * @return array Response array, exactly as the AJAX action used to emit.
3678 */
3679 public function wdkit_install_dependency_data( $plugins ) {
3680 $plugins = is_array( $plugins ) ? $plugins : array();
3681 $type = ! empty( $plugins['type'] ) ? $plugins['type'] : 'plugin';
3682 $p_id = ! empty( $plugins['p_id'] ) ? $plugins['p_id'] : 'plugin';
3683
3684 $responce = '';
3685 if ( 'plugin' === $type ) {
3686 $responce = Wdkit_Depends_Installer::get_instance()->wdkit_install_plugin( $plugins );
3687 } elseif ( 'theme' === $type ) {
3688 $theme_name = ! empty( $plugins['original_slug'] ) ? $plugins['original_slug'] : '';
3689 if ( ! empty( $theme_name ) ) {
3690
3691 $theme_array = array_keys( wp_get_themes() );
3692 $theme_slug = get_stylesheet();
3693
3694 if ( in_array( $theme_name, $theme_array ) ) {
3695 $activate_result = switch_theme( $theme_name );
3696
3697 if ( ! is_wp_error( $activate_result ) ) {
3698 $responce = array(
3699 'message' => esc_html__( 'Theme activated successfully', 'wdesignkit' ),
3700 'description' => esc_html__( 'Theme successfully activated', 'wdesignkit' ),
3701 'slug' => 'nexter',
3702 'p_id' => $p_id,
3703 'status' => 'active',
3704 'success' => true,
3705 );
3706 } else {
3707 $responce = array(
3708 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
3709 'description' => $activate_result->get_error_message(),
3710 'status' => 'inactive',
3711 'p_id' => $p_id,
3712 'success' => false,
3713 );
3714 }
3715 } else {
3716 $result = $this->wdkit_install_theme_depends( $theme_name );
3717
3718 $message = ! empty( $result['message'] ) ? $result['message'] : esc_html__( 'Somthing Wrong', 'wdesignkit' );
3719 $description = ! empty( $result['description'] ) ? $result['description'] : esc_html__( 'Error Somthing Wrong', 'wdesignkit' );
3720 $status = ! empty( $result['status'] ) ? $result['status'] : esc_html__( 'inactive', 'wdesignkit' );
3721 $success = ! empty( $result['success'] ) ? $result['success'] : false;
3722
3723 $responce = array(
3724 'message' => $message,
3725 'description' => $description,
3726 'p_id' => $p_id,
3727 'status' => $status,
3728 'success' => $success,
3729 );
3730 }
3731 } else {
3732 $responce = array(
3733 'message' => esc_html__( 'Theme Name not Found', 'wdesignkit' ),
3734 'description' => esc_html__( 'Can Not Found Theme Name you Enterd.', 'wdesignkit' ),
3735 'success' => false,
3736 );
3737 }
3738 }
3739
3740 return $responce;
3741 }
3742
3743 /**
3744 * Install/activate every plugin and theme dependency in one request.
3745 *
3746 * wdkit_install_plugins_depends() above installs exactly one item per admin-ajax call,
3747 * which is what the browser used to drive in a sequential chain (install one, wait,
3748 * install the next) - one WP bootstrap + nonce check + network round trip per dependency
3749 * on top of the install itself. This reuses the identical per-item logic - the same
3750 * Wdkit_Depends_Installer::wdkit_install_plugin() for plugins and the same
3751 * switch_theme()/wdkit_install_theme_depends() branch for themes - just looped inside a
3752 * single PHP execution instead of one call per item.
3753 *
3754 * @since 2.7.0
3755 */
3756 protected function wdkit_install_plugins_depends_batch() {
3757 $items = isset( $_POST['plugins'] ) ? json_decode( wp_unslash( $_POST['plugins'] ), true ) : array();
3758
3759 if ( empty( $items ) || ! is_array( $items ) ) {
3760 wp_send_json(
3761 array(
3762 'success' => false,
3763 'message' => esc_html__( 'No plugins supplied', 'wdesignkit' ),
3764 )
3765 );
3766 wp_die();
3767 }
3768
3769 // Several downloads + unzips + activations in one request; harmless no-op where
3770 // set_time_limit() is disabled.
3771 if ( function_exists( 'set_time_limit' ) ) {
3772 @set_time_limit( 300 );
3773 }
3774
3775 $t_start = microtime( true );
3776 $results = array();
3777
3778 foreach ( $items as $item ) {
3779 $item = (array) $item;
3780 $type = ! empty( $item['type'] ) ? $item['type'] : 'plugin';
3781 $p_id = ! empty( $item['p_id'] ) ? $item['p_id'] : '';
3782
3783 if ( 'theme' === $type ) {
3784 $theme_name = ! empty( $item['original_slug'] ) ? $item['original_slug'] : '';
3785
3786 if ( empty( $theme_name ) ) {
3787 $results[] = array(
3788 'p_id' => $p_id,
3789 'success' => false,
3790 'status' => 'inactive',
3791 'message' => esc_html__( 'Theme name not found', 'wdesignkit' ),
3792 );
3793 continue;
3794 }
3795
3796 $theme_array = array_keys( wp_get_themes() );
3797
3798 if ( in_array( $theme_name, $theme_array, true ) ) {
3799 $activate_result = switch_theme( $theme_name );
3800
3801 $results[] = is_wp_error( $activate_result )
3802 ? array(
3803 'p_id' => $p_id,
3804 'success' => false,
3805 'status' => 'inactive',
3806 'message' => $activate_result->get_error_message(),
3807 )
3808 : array(
3809 'p_id' => $p_id,
3810 'success' => true,
3811 'status' => 'active',
3812 'message' => esc_html__( 'Theme activated successfully', 'wdesignkit' ),
3813 );
3814 } else {
3815 $result = $this->wdkit_install_theme_depends( $theme_name );
3816 $results[] = array(
3817 'p_id' => $p_id,
3818 'success' => ! empty( $result['success'] ) ? $result['success'] : false,
3819 'status' => ! empty( $result['status'] ) ? $result['status'] : 'inactive',
3820 'message' => ! empty( $result['message'] ) ? $result['message'] : esc_html__( 'Something went wrong', 'wdesignkit' ),
3821 );
3822 }
3823
3824 continue;
3825 }
3826
3827 $results[] = Wdkit_Depends_Installer::get_instance()->wdkit_install_plugin( $item );
3828 }
3829
3830 self::wdkit_clear_activation_redirects();
3831
3832 $timing_ms = (int) round( ( microtime( true ) - $t_start ) * 1000 );
3833
3834 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
3835 error_log( 'WDKIT dependency batch timing: ' . $timing_ms . 'ms for ' . count( $results ) . ' item(s)' );
3836 }
3837
3838 wp_send_json(
3839 array(
3840 'success' => true,
3841 'results' => $results,
3842 'timing_ms' => $timing_ms,
3843 )
3844 );
3845 wp_die();
3846 }
3847
3848 /**
3849 * Disarm the "welcome screen" redirects the plugins we just activated leave armed.
3850 *
3851 * Several plugins set a short-lived transient on activation and, on the next `admin_init`,
3852 * redirect to their own setup wizard. Rank Math's guard is the clearest example - it checks
3853 * the transient, the current page and the capability, but never wp_doing_ajax():
3854 *
3855 * set_transient( '_rank_math_activation_redirect', 1, 30 ); // class-installer.php
3856 * $this->action( 'admin_init', 'redirect_to_welcome' ); // class-registration.php
3857 *
3858 * `admin_init` fires on admin-ajax.php too, so a wizard request landing in that window is
3859 * answered with an HTML admin page instead of its JSON, and the step that made the call
3860 * fails with nothing the user can act on.
3861 *
3862 * NOTE: this is a hardening measure, not a proven fix for any specific report. The
3863 * Global-Settings failure in QA 14ynqxyugbu still reproduces with this in place, and no
3864 * wp_redirect() is fired during it - so whatever hijacks that request does not go through
3865 * the transients below (a raw header() call would not). Clearing these remains correct on
3866 * its own terms; it is not the answer to that ticket.
3867 *
3868 * Deleting these is what an importer that activates plugins on the user's behalf has to do:
3869 * nobody asked for a setup wizard, and the user is mid-import. Each is a one-shot flag the
3870 * owning plugin deletes itself on first read, so removing it takes nothing else with it.
3871 *
3872 * The list is every such transient set by the plugins this importer installs - grep the
3873 * dependency set for `set_transient` before adding to it, rather than guessing at names.
3874 *
3875 * @since 2.7.1
3876 *
3877 * @return void
3878 */
3879 public static function wdkit_clear_activation_redirects() {
3880
3881 $transients = array(
3882 '_rank_math_activation_redirect',
3883 '_wc_activation_redirect',
3884 'elementor_activation_redirect',
3885 );
3886
3887 foreach ( $transients as $transient ) {
3888 delete_transient( $transient );
3889 }
3890 }
3891
3892 protected function wdkit_install_theme_depends( $name = 'nexter' ) {
3893
3894 if ( ! current_user_can( 'install_themes' ) ) {
3895 $response = $this->tpae_set_response( false, 'Invalid nonce.', 'The security check failed. Please refresh the page and try again.' );
3896 return $response;
3897 }
3898
3899 $theme_slug = $name;
3900 $theme_api_url = 'https://api.wordpress.org/themes/info/1.0/';
3901
3902 // Parameters for the request
3903 $args = array(
3904 'body' => array(
3905 'action' => 'theme_information',
3906 'request' => serialize(
3907 (object) array(
3908 'slug' => $name,
3909 'fields' => array(
3910 'description' => false,
3911 'sections' => false,
3912 'rating' => true,
3913 'ratings' => false,
3914 'downloaded' => true,
3915 'download_link' => true,
3916 'last_updated' => true,
3917 'homepage' => true,
3918 'tags' => true,
3919 'template' => true,
3920 'active_installs' => false,
3921 'parent' => false,
3922 'versions' => false,
3923 'screenshot_url' => true,
3924 'active_installs' => false,
3925 ),
3926 )
3927 ),
3928 ),
3929 );
3930
3931 // Make the request
3932 $response = wp_remote_post( $theme_api_url, $args );
3933 // Check for errors
3934 if ( is_wp_error( $response ) ) {
3935 $error_message = $response->get_error_message();
3936
3937 $result = $this->tpae_set_response( false, 'oops', 'oops', '' );
3938 } else {
3939 // api.wordpress.org's theme_information response is a serialized stdClass
3940 // (accessed below via ->name / ->download_link). allowed_classes => false
3941 // blocks stdClass too, turning it into an __PHP_Incomplete_Class whose
3942 // properties silently don't exist — allow only stdClass, still refusing any
3943 // other (potentially dangerous) class the payload might reference.
3944 $theme_info = unserialize( $response['body'], array( 'allowed_classes' => array( 'stdClass' ) ) );
3945 $theme_name = $theme_info->name;
3946 $theme_zip_url = $theme_info->download_link;
3947
3948 // SSRF guard (CWE-918): validate the resolved host before fetching the ZIP
3949 // referenced by the external theme_info response.
3950 if ( ! wdesignkit_validate_external_url( $theme_zip_url ) ) {
3951 return array(
3952 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
3953 'description' => esc_html__( 'The theme package URL is not allowed.', 'wdesignkit' ),
3954 'status' => 'inactive',
3955 'success' => false,
3956 );
3957 }
3958
3959 if ( ! function_exists( 'WP_Filesystem' ) ) {
3960 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
3961 }
3962
3963 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/class-wp-upgrader.php' );
3964 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/theme.php' );
3965
3966 WP_Filesystem();
3967
3968 $active_theme = wp_get_theme();
3969 $theme_name = $active_theme->get( 'Name' );
3970
3971 // Install via WordPress core's Theme_Upgrader instead of manually fetching and
3972 // ZipArchive::extractTo()'ing the remote package: core already performs the
3973 // standard download -> unpack -> validate-package-structure -> move-into-place
3974 // flow (including cleanup on failure) used for every trusted theme install.
3975 $upgrader = new Theme_Upgrader( new Automatic_Upgrader_Skin() );
3976 $install = $upgrader->install( $theme_zip_url );
3977
3978 if ( is_wp_error( $install ) || ! $install ) {
3979 return array(
3980 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
3981 'description' => is_wp_error( $install ) ? $install->get_error_message() : esc_html__( 'Theme could not be installed.', 'wdesignkit' ),
3982 'status' => 'inactive',
3983 'success' => false,
3984 );
3985 }
3986
3987 $activate_result = switch_theme( $name );
3988
3989 if ( ! is_wp_error( $activate_result ) ) {
3990 $response = array(
3991 'message' => esc_html__( 'Theme activated successfully', 'wdesignkit' ),
3992 'description' => esc_html__( 'Theme successfully activated', 'wdesignkit' ),
3993 'status' => 'active',
3994 'success' => true,
3995 );
3996 } else {
3997 $response = array(
3998 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
3999 'description' => $activate_result->get_error_message(),
4000 'status' => 'inactive',
4001 'success' => false,
4002 );
4003 }
4004 }
4005
4006 return $response;
4007 }
4008
4009 /**
4010 *
4011 * It is Use Update WDesignKit plugin latest version.
4012 *
4013 * @since 1.0.17
4014 */
4015 protected function wdkit_update_latest_plugin() {
4016
4017 return Wdkit_Depends_Installer::get_instance()->wdkit_update_plugin();
4018 }
4019
4020 /**
4021 *
4022 * It is Use for get plugin list.
4023 *
4024 * @since 1.0.0
4025 */
4026 private function get_plugins() {
4027 if ( ! function_exists( 'get_plugins' ) ) {
4028 require_once \ABSPATH . 'wp-admin/includes/plugin.php';
4029 }
4030
4031 return get_plugins();
4032 }
4033
4034 /**
4035 * Get Download Template Content
4036 *
4037 * @since 1.0.0
4038 */
4039 protected function wdkit_activate_container() {
4040
4041 $option_value = get_option( 'elementor_experiment-container', false );
4042
4043 if ( $option_value === false ) {
4044 add_option( 'elementor_experiment-container', 'active' );
4045 } else {
4046 update_option( 'elementor_experiment-container', 'active' );
4047 }
4048
4049 $result = array(
4050 'message' => esc_html__( 'Container Activated Successfully', 'wdesignkit' ),
4051 'description' => esc_html__( 'Elementor Container Activated Successfully.', 'wdesignkit' ),
4052 'success' => true,
4053 );
4054
4055 wp_send_json( $response );
4056 wp_die();
4057 }
4058
4059 /**
4060 * import category and tags for post
4061 *
4062 * @since 2.0.0
4063 */
4064 protected function wdkit_import_taxonomy() {
4065 $category = isset( $_POST['category'] ) ? json_decode( wp_unslash( $_POST['category'] ) ) : array();
4066 $tags = isset( $_POST['tags'] ) ? json_decode( wp_unslash( $_POST['tags'] ) ) : array();
4067
4068 $response = $this->wdkit_import_taxonomy_data( $category, $tags );
4069
4070 /* Emitted here so this action's output stays byte-for-byte what it was at
4071 * HEAD, where wp_send_json()/wp_die() sat at the end of this same method. */
4072 wp_send_json( $response );
4073 wp_die();
4074 }
4075
4076 /**
4077 * Create the categories and tags a kit's posts need, returning their term ids.
4078 *
4079 * Extracted from wdkit_import_taxonomy() so the PHP import runner can reach it without
4080 * `$_POST`. Already idempotent before this change and still is: every term is looked up
4081 * with term_exists() before an insert is attempted, so running it twice returns the
4082 * same ids rather than creating duplicates.
4083 *
4084 * The AJAX action above is now a thin adapter over this and its return value — which
4085 * the router passes to wdkit_success_msg() — is unchanged.
4086 *
4087 * @since 2.6.5
4088 *
4089 * @param array $category Category names.
4090 * @param array $tags Tag names.
4091 * @return array {success, categories:[{name,term_id|error}], tags:[...]}
4092 */
4093 public function wdkit_import_taxonomy_data( $category = array(), $tags = array() ) {
4094 $category = is_array( $category ) ? $category : array();
4095 $tags = is_array( $tags ) ? $tags : array();
4096
4097 $response = array(
4098 'success' => false,
4099 'categories' => array(),
4100 'tags' => array(),
4101 );
4102
4103 if ( ! empty( $category ) && count( $category ) > 0 ) {
4104 foreach ( $category as $category_name ) {
4105 $category_name = sanitize_text_field( $category_name );
4106
4107 $term_exists = term_exists( $category_name, 'category' );
4108 if ( ! $term_exists ) {
4109 $result = wp_insert_term( $category_name, 'category' );
4110 if ( ! is_wp_error( $result ) ) {
4111 $response['categories'][] = array(
4112 'name' => $category_name,
4113 'term_id' => $result['term_id'],
4114 );
4115 } else {
4116 $response['categories'][] = array(
4117 'name' => $category_name,
4118 'error' => $result->get_error_message(),
4119 );
4120 }
4121 } else {
4122 $term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
4123 $response['categories'][] = array(
4124 'name' => $category_name,
4125 'term_id' => $term_id,
4126 );
4127 }
4128 }
4129
4130 $response['success'] = true;
4131 }
4132
4133 if ( ! empty( $tags ) && count( $tags ) > 0 ) {
4134 foreach ( $tags as $tags_name ) {
4135 $tags_name = sanitize_text_field( $tags_name );
4136
4137 $term_exists = term_exists( $tags_name, 'post_tag' );
4138 if ( ! $term_exists ) {
4139 $result = wp_insert_term( $tags_name, 'post_tag' );
4140 if ( ! is_wp_error( $result ) ) {
4141 $response['tags'][] = array(
4142 'name' => $tags_name,
4143 'term_id' => $result['term_id'],
4144 );
4145 } else {
4146 $response['tags'][] = array(
4147 'name' => $tags_name,
4148 'error' => $result->get_error_message(),
4149 );
4150 }
4151 } else {
4152 $term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
4153 $response['tags'][] = array(
4154 'name' => $tags_name,
4155 'term_id' => $term_id,
4156 );
4157 }
4158 }
4159
4160 $response['success'] = true;
4161 }
4162
4163 /* Returns. It must NOT emit: the PHP runner calls this directly, and a
4164 * wp_send_json() here would print JSON and wp_die() in the middle of an
4165 * import. Found by running the runner against real WordPress -- see the
4166 * Phase 5 report. The AJAX response is unaffected, because
4167 * wdkit_import_taxonomy() emits this same array at the same point. */
4168 return $response;
4169 }
4170
4171 /**
4172 * Get Download Template Content
4173 *
4174 * @since 1.0.0
4175 */
4176 protected function wdkit_import_template() {
4177 $args = $this->wdkit_parse_args( $_POST );
4178 $api_type = isset( $_POST['api_type'] ) ? sanitize_text_field( wp_unslash( $_POST['api_type'] ) ) : 'import_template';
4179
4180 $response = '';
4181 if ( empty( $args['template_id'] ) ) {
4182 $result = array(
4183 'content' => '',
4184 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
4185 'description' => esc_html__( 'Invalid import: Check your details and try again.', 'wdesignkit' ),
4186 'success' => false,
4187 );
4188
4189 wp_send_json( $response );
4190 wp_die();
4191 }
4192
4193 $args['token'] = $this->wdkit_login_user_token( $args['email'] );
4194
4195 unset( $args['email'] );
4196 $args['unique_id'] = get_option( 'wdkit_unique_id' ) ?? '';
4197
4198 /* Importing one template from the library, rather than a whole kit. A sandbox has no
4199 * login for wdkit_login_user_token() to find, so without this the token is empty and
4200 * the cloud refuses - the same gap the kit paths already close. Merged last, so the
4201 * site's own identity wins over anything the request tried to supply. */
4202 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
4203 $args = wdkit_kit_import_with_site_identity( $args );
4204 }
4205
4206 $response = WDesignKit_Data_Query::get_data( $api_type, $args );
4207
4208 if ( is_wp_error( $response ) ) {
4209 wp_send_json( array(
4210 'success' => false,
4211 'message' => $response->get_error_message(),
4212 ) );
4213 wp_die();
4214 }
4215
4216 $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
4217
4218 /** Custom meta Field */
4219 if ( ! empty( $custom_meta ) && 'true' === $custom_meta && ! empty( $response ) && ! empty( $response['content'] ) ) {
4220
4221 $res_content = json_decode( $response['content'], true );
4222 if ( isset( $res_content['custom_meta'] ) && ! empty( $res_content['custom_meta'] ) ) {
4223 $meta_data = $res_content['custom_meta'];
4224
4225 if ( ! empty( $meta_data ) ) {
4226 foreach ( $meta_data as $meta_key => $meta_val ) {
4227 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
4228 $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
4229 }
4230
4231 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
4232 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
4233 } else {
4234 update_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
4235 }
4236 }
4237 }
4238 }
4239 }
4240
4241 /**
4242 * Fires after a template has been imported from the cloud.
4243 *
4244 * WDesignKit's templates live in the cloud, so nothing local records that an import
4245 * happened — there is no post type, no option, nothing to count after the fact. This is the
4246 * only moment the information exists.
4247 *
4248 * @since 2.6.4
4249 *
4250 * @param string $kind 'single' or 'kit'.
4251 * @param string $builder Builder the template was imported for, e.g. 'elementor'.
4252 * @param int $count How many templates this import brought in.
4253 */
4254 // Only a completed import counts. The cloud's failure shape for this endpoint family sets
4255 // content => 'error' (see the sibling check in wdkit_import_kit_template() above) — that is
4256 // non-empty, so the previous `||` fired the counter on failed imports too. Require success
4257 // AND an absent/non-'error' content instead.
4258 if ( ! empty( $response['success'] ) && ( ! isset( $response['content'] ) || 'error' !== $response['content'] ) ) {
4259 do_action(
4260 'wdkit_template_imported',
4261 'import_kit_template' === $api_type ? 'kit' : 'single',
4262 isset( $_POST['builder'] ) ? sanitize_key( wp_unslash( $_POST['builder'] ) ) : '',
4263 1
4264 );
4265 }
4266
4267 wp_send_json( $response );
4268 wp_die();
4269 }
4270
4271 /**
4272 * This Function is Use For Media Import
4273 *
4274 * @since 1.0.0
4275 *
4276 * @param array $content store media content.
4277 * @param string $editor it is check editor.
4278 */
4279 /**
4280 * Resolve a local upload URL back to its attachment ID.
4281 *
4282 * Handles the "-scaled" copy WordPress makes for large originals and any
4283 * "-1920x1280" size suffix, both of which attachment_url_to_postid() misses because
4284 * they are not the value stored in _wp_attached_file.
4285 *
4286 * @since 2.6.2
4287 *
4288 * @param string $url Local upload URL.
4289 * @return int Attachment ID, or 0.
4290 */
4291 private static function wdkit_attachment_id_from_url( $url ) {
4292 static $cache = array();
4293
4294 if ( isset( $cache[ $url ] ) ) {
4295 return $cache[ $url ];
4296 }
4297
4298 $id = (int) attachment_url_to_postid( $url );
4299
4300 if ( ! $id ) {
4301 // Try the original file behind a -scaled or -WxH derivative.
4302 $stripped = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $url );
4303 $stripped = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', (string) $stripped );
4304
4305 if ( $stripped && $stripped !== $url ) {
4306 $id = (int) attachment_url_to_postid( $stripped );
4307 }
4308 }
4309
4310 // Only remember hits. Page imports run concurrently, so an attachment created by a
4311 // sibling request may not exist yet when this is first asked — caching that miss
4312 // would keep every later control in this request pointing at nothing.
4313 if ( $id ) {
4314 $cache[ $url ] = $id;
4315 }
4316
4317 return $id;
4318 }
4319
4320 /**
4321 * Is this media reference still pointing off-site?
4322 *
4323 * Template content arrives holding the URLs of wherever the media lived before. Those
4324 * carry that site's attachment IDs, which have no meaning here - and can collide with
4325 * unrelated local posts.
4326 *
4327 * @since 2.6.2
4328 *
4329 * @param string $url URL from a media control.
4330 * @return bool True when the URL points at another site's uploads.
4331 */
4332 /**
4333 * Localise every foreign SVG media control in a set of Elementor elements, in-request.
4334 *
4335 * The deferred path cannot carry SVGs (see wdkit_collect_foreign_media_urls()), so they
4336 * take the same route they always did: wdkit_localise_media_url(), which resolves through
4337 * Elementor's own importer and its `_elementor_source_image_hash` cache, so an SVG a
4338 * sibling page already imported costs a database lookup rather than a download.
4339 *
4340 * Only the `{ url, id }` media-control shape is handled here, matching
4341 * wdkit_repair_attachment_ids(). An SVG inlined into an HTML/text control has no `id` to
4342 * correct, and the finalize sweep still covers those - which is why the sweep refuses to
4343 * skip any page that still holds a foreign SVG reference.
4344 *
4345 * @since 2.7.1
4346 *
4347 * @param mixed $node Elementor data, walked recursively.
4348 * @return mixed The data with local SVG references.
4349 */
4350 private static function wdkit_localise_foreign_svgs( $node ) {
4351
4352 if ( ! is_array( $node ) ) {
4353 return $node;
4354 }
4355
4356 if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node )
4357 && self::wdkit_is_foreign_media_url( $node['url'] )
4358 && preg_match( '/\.svg$/i', (string) wp_parse_url( $node['url'], PHP_URL_PATH ) )
4359 ) {
4360 $local = self::wdkit_localise_media_url( $node['url'], isset( $node['id'] ) ? (int) $node['id'] : 0 );
4361
4362 if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
4363 $node['id'] = $local['id'];
4364 $node['url'] = $local['url'];
4365 }
4366 }
4367
4368 foreach ( $node as $key => $value ) {
4369 if ( is_array( $value ) ) {
4370 $node[ $key ] = self::wdkit_localise_foreign_svgs( $value );
4371 }
4372 }
4373
4374 return $node;
4375 }
4376
4377 /**
4378 * Empty the attachment ID on every media control whose image is being deferred.
4379 *
4380 * The IDs that arrive in a template's data belong to the site the kit was authored on.
4381 * Until the background pass replaces them (see wdkit_replace_deferred_media()), each one
4382 * lands in one of two states on this site, and neither is what Elementor expects:
4383 *
4384 * - It matches nothing locally. Elementor's `<img>` path copes - it checks
4385 * wp_attachment_is_image() and falls back to the url - but the CSS path does not.
4386 * Control_Media::get_style_value() only takes the url branch when the ID is *empty*;
4387 * given a non-empty one it returns wp_get_attachment_image_url( $id ), which is false
4388 * for a dangling ID, so the declaration is dropped and a container background renders
4389 * as nothing at all. Measured on a Taj Bakery import: 9 of 9 background_image controls
4390 * produced 0 background-image rules for the whole deferred window.
4391 * - It happens to match an unrelated local attachment. Then wp_attachment_is_image()
4392 * passes and Elementor renders that other image - the wrong picture, silently. Whether
4393 * this bites is pure luck: it needs the template's ID range to overlap the IDs a fresh
4394 * import creates, which is why Zion hit it and Taj Bakery did not.
4395 *
4396 * An empty ID is the one state every affected renderer is written to handle, so this
4397 * turns both failures into "shows the source image until the local copy lands":
4398 *
4399 * - Control_Media::get_style_value() empty ID -> parent -> the raw url.
4400 * - Group_Control_Image_Size::get_attachment_image_html() empty ID -> url fallback.
4401 * - image-carousel `if ( ! $image_url && isset( $attachment['url'] ) )`.
4402 * - image-gallery `$image['url'] ?? ''`.
4403 *
4404 * So this is deliberately not a blanket "clear every ID": it keys off the exact URL list
4405 * that was just handed to the background pass. Anything already local keeps its ID, and
4406 * so do SVGs, which wdkit_localise_foreign_svgs() has already imported for real.
4407 *
4408 * One renderer is not helped by this: The Plus Addons' tp_get_image() returns '' when
4409 * wp_get_attachment_image_src() fails and has no url fallback, so its widgets show
4410 * nothing during the window either way. That is a limitation of that helper rather than
4411 * something this pass can fix, and the background pass repairs it on completion.
4412 *
4413 * @since 2.6.6
4414 *
4415 * @param mixed $node Elementor data, walked recursively.
4416 * @param array $deferred Source URLs handed to the background pass.
4417 * @return mixed Data with the deferred controls' IDs emptied.
4418 */
4419 private static function wdkit_blank_deferred_media_ids( $node, array $deferred ) {
4420
4421 if ( ! is_array( $node ) || empty( $deferred ) ) {
4422 return $node;
4423 }
4424
4425 $lookup = array_flip( $deferred );
4426
4427 $walk = function ( $item ) use ( &$walk, $lookup ) {
4428
4429 if ( ! is_array( $item ) ) {
4430 return $item;
4431 }
4432
4433 // A media control: `url` and `id` as siblings. Empty the ID only when this
4434 // exact URL is one the background pass is going to replace.
4435 if ( isset( $item['url'] ) && is_string( $item['url'] )
4436 && array_key_exists( 'id', $item )
4437 && isset( $lookup[ $item['url'] ] )
4438 ) {
4439 // '' rather than 0: this is the value Elementor itself writes when it
4440 // rejects an ID (see get_attachment_image_html()), and what its
4441 // empty()-based checks are written against.
4442 $item['id'] = '';
4443 }
4444
4445 foreach ( $item as $key => $value ) {
4446 if ( is_array( $value ) ) {
4447 $item[ $key ] = $walk( $value );
4448 }
4449 }
4450
4451 return $item;
4452 };
4453
4454 return $walk( $node );
4455 }
4456
4457 /**
4458 * Build the demo-URL -> real-permalink substitution table for a finished kit import.
4459 *
4460 * The kit's templates link to each other by the *authoring* site's URLs, so every
4461 * internal link has to be rewritten once each page exists and its real permalink is
4462 * known. Two representations of the same URL have to be covered, because the two places
4463 * these links live store them differently:
4464 *
4465 * - `post_content` holds them raw, inside an attribute: `href="https://demo/about/"`.
4466 * - `_elementor_data` holds them JSON-encoded, so every slash is escaped:
4467 * `"url":"https:\/\/demo\/about\/"`. This is the one the widgets actually render from.
4468 *
4469 * Each key is anchored on the surrounding quote so `/about` cannot match inside
4470 * `/about-us`, and both the bare and trailing-slash forms are mapped because templates
4471 * are inconsistent about it. The trailing slash has to be escaped in the escaped
4472 * variants (`...\/about\/"`, not `...\/about/"`) - getting that wrong is invisible in
4473 * post_content and leaves the whole navigation pointing at the demo site.
4474 *
4475 * @since 2.6.6
4476 *
4477 * @param array $imported Inserted templates: each entry needs `post_url` (the demo URL)
4478 * and `id` (the local post it became).
4479 * @return array strtr() substitution table.
4480 */
4481 private static function wdkit_build_nav_url_map( $imported ) {
4482
4483 $url_map = array();
4484
4485 foreach ( (array) $imported as $entry ) {
4486
4487 $entry = (array) $entry;
4488
4489 if ( empty( $entry['post_url'] ) || empty( $entry['id'] ) ) {
4490 continue;
4491 }
4492
4493 $permalink = get_permalink( (int) $entry['id'] );
4494
4495 if ( ! $permalink ) {
4496 continue;
4497 }
4498
4499 $demo = rtrim( (string) $entry['post_url'], '/' );
4500 $target = rtrim( (string) $permalink, '/' );
4501
4502 if ( '' === $demo || $demo === $target ) {
4503 continue;
4504 }
4505
4506 // On a site left on plain permalinks the target is a query string, not a path:
4507 // `http://site/?page_id=54`. Re-adding the trailing slash the demo URL carried
4508 // yields `?page_id=54/`, and joining a query string onto it with `?` yields a
4509 // second `?` - both malformed. This stayed invisible on a pretty-permalink site
4510 // because there the target really is a path and appending is correct.
4511 $has_query = ( false !== strpos( $target, '?' ) );
4512
4513 // The "…and a trailing slash" form of the target, which for a query-string
4514 // permalink is simply the permalink - there is nothing to put a slash after.
4515 $target_slash = $has_query ? $target : $target . '/';
4516
4517 // What a query string has to be joined on, given what the target already carries.
4518 $query_join = $has_query ? '&' : '?';
4519
4520 // Raw form, as it appears in post_content.
4521 foreach ( array( '"', '\\"' ) as $q ) {
4522 $url_map[ $q . $demo . $q ] = $q . $target . $q;
4523 $url_map[ $q . $demo . '/' . $q ] = $q . $target_slash . $q;
4524 }
4525
4526 // JSON-escaped form, as it appears in _elementor_data - trailing slash escaped too.
4527 $demo_esc = str_replace( '/', '\\/', $demo );
4528 $target_esc = str_replace( '/', '\\/', $target );
4529 $target_slash_esc = $has_query ? $target_esc : $target_esc . '\\/';
4530 $target_slash_raw = $has_query ? $target_esc : $target_esc . '/';
4531
4532 foreach ( array( '"', '\\"' ) as $q ) {
4533 $url_map[ $q . $demo_esc . $q ] = $q . $target_esc . $q;
4534 $url_map[ $q . $demo_esc . '\\/' . $q ] = $q . $target_slash_esc . $q;
4535 // Kept for templates that stored the slash unescaped next to escaped ones.
4536 $url_map[ $q . $demo_esc . '/' . $q ] = $q . $target_slash_raw . $q;
4537 }
4538
4539 // An in-page link (`/taj/#menu-list`) or one carrying a query string never has the
4540 // quote straight after the path, so none of the anchored keys above can reach it and
4541 // the link keeps pointing at the demo site. `#` and `?` cannot occur inside a path
4542 // segment, so they anchor the match just as safely as the closing quote does.
4543 foreach ( array( '#', '?' ) as $sep ) {
4544 $join = ( '?' === $sep ) ? $query_join : $sep;
4545
4546 $url_map[ $demo . $sep ] = $target . $join;
4547 $url_map[ $demo . '/' . $sep ] = $target_slash . $join;
4548 $url_map[ $demo_esc . $sep ] = $target_esc . $join;
4549 $url_map[ $demo_esc . '\\/' . $sep ] = $target_slash_esc . $join;
4550 }
4551 }
4552
4553 return $url_map;
4554 }
4555
4556 /**
4557 * Every source-site media URL inside a set of Elementor elements.
4558 *
4559 * Scans the serialised tree rather than walking control-by-control, for the same reason
4560 * the Gutenberg SVG prefetch does: a URL can sit in a media control's `url`, in a
4561 * responsive variant beneath it, or inline in an HTML/text control that never had an
4562 * `id` sibling - and wdkit_replace_deferred_media() rewrites all three shapes, the last
4563 * by plain substring swap. Matching the serialised form keeps this collector and that
4564 * rewriter looking at the same set. Over-collecting is harmless: the cron skips a URL it
4565 * cannot resolve, and an already-local one is filtered out below.
4566 *
4567 * SVGs are excluded by default, and deliberately: the background pass runs with no user,
4568 * where `svg` is not in get_allowed_mime_types() because the filter granting it is
4569 * capability-gated - so a deferred SVG sideload fails silently and the reference stays
4570 * remote forever. The Gutenberg walk carves SVGs out of deferral for the same reason
4571 * (see the `/\.svg$/i` test in Wdkit_Import_Images::wdkit_Import_media()); they are
4572 * localised inline instead, by wdkit_localise_foreign_svgs() below.
4573 *
4574 * Video IS included, and does not share that problem: mp4/m4v, webm and mov/qt are all in
4575 * get_allowed_mime_types() with no user, so a deferred video sideload succeeds where an
4576 * SVG one cannot. Deferral is also where video belongs - the files are the largest thing
4577 * a kit references, and nothing is waiting on the cron event. Until this listed them, a
4578 * template's video kept the authoring site's URL and attachment id forever, because the
4579 * deferred path has no other media discovery (see wdkit_media_import()'s elementor
4580 * branch) - which is why a video widget rendered as an empty frame.
4581 *
4582 * @since 2.7.1
4583 *
4584 * @param mixed $elements Elementor `elements` data.
4585 * @param string|null $extensions Regex alternation of extensions; defaults to the shared list.
4586 * @return array<string> Distinct foreign media URLs.
4587 */
4588 private static function wdkit_collect_foreign_media_urls( $elements, $extensions = null ) {
4589
4590 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
4591 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
4592 }
4593
4594 // One list for defer / blank-id / remap - see Wdkit_Image_Guard::DEFERRABLE_EXTENSIONS.
4595 $extensions = ( null === $extensions ) ? Wdkit_Image_Guard::DEFERRABLE_EXTENSIONS : $extensions;
4596
4597 $json = wp_json_encode( $elements, JSON_UNESCAPED_SLASHES );
4598
4599 if ( ! is_string( $json ) || '' === $json ) {
4600 return array();
4601 }
4602
4603 // The trailing query group is required, not cosmetic. Without it the engine matches
4604 // greedily and then backtracks to the extension, so
4605 // `…/photo.jpeg?auto=compress&w=1260` was collected as `…/photo.jpeg` - a URL that
4606 // appears nowhere in the content. wdkit_blank_deferred_media_ids() compares the node's
4607 // url exactly and missed, leaving the dangling foreign id it exists to clear (the
4608 // measured symptom: 9 of 9 background_image controls produced no background-image
4609 // rule), and the cron's str_replace then produced `…/local.jpg?auto=compress&w=1260`.
4610 $candidates = array();
4611
4612 if ( preg_match_all( '#https?://[^"\'\s<>()]+\.(?:' . $extensions . ')(?:\?[^"\'\s<>()]*)?#i', $json, $matches ) ) {
4613 $candidates = $matches[0];
4614 }
4615
4616 // The regex above can only find a URL that spells its type. A media control whose url
4617 // has no extension at all - which the template CDN serves, resolving the type by
4618 // Content-Type - matched nothing, so it was never deferred, never recorded and never
4619 // localised: the reference stayed remote permanently, with no error anywhere.
4620 //
4621 // Walking the tree settles it without a single HTTP request: a node shaped
4622 // `{ url, id }` IS a media control, whatever the url looks like. This is the same
4623 // shape wdkit_blank_deferred_media_ids() and wdkit_replace_deferred_media() key on,
4624 // so what gets collected here is exactly what those two can act on. The regex pass
4625 // stays for URLs embedded in HTML and inline-CSS strings, which have no node shape.
4626 $structural = self::wdkit_collect_media_control_urls( $elements );
4627
4628 $found = array();
4629
4630 foreach ( array_unique( $candidates ) as $url ) {
4631 if ( self::wdkit_is_foreign_media_url( $url ) ) {
4632 $found[] = $url;
4633 }
4634 }
4635
4636 // No extension requirement for these - the node shape is the proof.
4637 foreach ( array_unique( $structural ) as $url ) {
4638 if ( self::wdkit_is_foreign_media_url( $url, false ) ) {
4639 $found[] = $url;
4640 }
4641 }
4642
4643 return array_values( array_unique( $found ) );
4644 }
4645
4646 /**
4647 * Collect the url of every `{ url, id }` media control in an element tree.
4648 *
4649 * Structural, not textual: no extension is required, so an extensionless CDN URL is
4650 * found here even though wdkit_collect_foreign_media_urls()'s regex cannot see it.
4651 *
4652 * SVG is still excluded, for the reason given on that method: the background pass runs
4653 * with no user, `svg` is therefore absent from get_allowed_mime_types(), and a deferred
4654 * SVG sideload fails silently. Those are localised in-request instead.
4655 *
4656 * @since 2.7.1
4657 *
4658 * @param mixed $node Elementor data, walked recursively.
4659 * @return array<string> Media-control URLs, in tree order.
4660 */
4661 private static function wdkit_collect_media_control_urls( $node ) {
4662
4663 if ( ! is_array( $node ) ) {
4664 return array();
4665 }
4666
4667 $found = array();
4668
4669 // `url` + `id` as siblings is Elementor's media-control shape. A link control carries
4670 // is_external/nofollow instead and never an id, so excluding those keys keeps a
4671 // button's href out of the media queue even if a theme adds an id to it.
4672 $is_media_control = isset( $node['url'] ) && is_string( $node['url'] )
4673 && array_key_exists( 'id', $node )
4674 && ! array_key_exists( 'is_external', $node )
4675 && ! array_key_exists( 'nofollow', $node );
4676
4677 if ( $is_media_control ) {
4678 $url = $node['url'];
4679 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
4680
4681 if ( 0 === strpos( $url, 'http' ) && ! preg_match( '/\.svg$/i', $path ) ) {
4682 $found[] = $url;
4683 }
4684 }
4685
4686 foreach ( $node as $value ) {
4687 if ( is_array( $value ) ) {
4688 $found = array_merge( $found, self::wdkit_collect_media_control_urls( $value ) );
4689 }
4690 }
4691
4692 return $found;
4693 }
4694
4695 /**
4696 * Whether a media URL points at something this site does not already host.
4697 *
4698 * Anything under the uploads baseurl is ours; everything else - the template CDN, the
4699 * authoring site, a stock provider - is foreign and has to be localised. Data URIs and
4700 * relative paths are not foreign either, and are refused here rather than downstream.
4701 *
4702 * @since 2.7.1
4703 *
4704 * @param string $url Absolute media URL.
4705 * @return bool True when the URL needs localising.
4706 */
4707 private static function wdkit_is_foreign_media_url( $url, $require_extension = true ) {
4708
4709 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
4710 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
4711 }
4712
4713 $uploads = wp_get_upload_dir();
4714
4715 return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '', $require_extension );
4716 }
4717
4718 /**
4719 * Find - or make - the local attachment behind a source-site media URL.
4720 *
4721 * Elementor stamps every image it imports with `_elementor_source_image_hash`
4722 * (sha1 of the URL it came from), and its importer consults that before doing any
4723 * network work. Delegating here means a URL already imported at create time resolves
4724 * from the database, and one that never made it is fetched exactly once.
4725 *
4726 * Only ever called for foreign URLs. Handing it a local URL would re-download the
4727 * file and leave a duplicate, because the stored hash is of the *remote* URL and so
4728 * would never match.
4729 *
4730 * @since 2.6.2
4731 *
4732 * @param string $url Source-site media URL.
4733 * @param int $source_id The source site's attachment ID, used as Elementor's cache key.
4734 * @return array Local `id` and `url`, or an empty array when it cannot be resolved.
4735 */
4736 private static function wdkit_localise_media_url( $url, $source_id = 0 ) {
4737 static $cache = array();
4738
4739 if ( isset( $cache[ $url ] ) ) {
4740 return $cache[ $url ];
4741 }
4742
4743 // SVGs: use the prefetch-aware sideloader. It consults the concurrent
4744 // warm-up cache (see the prefetch_remote_files() call in the Elementor
4745 // $defer_media branch of wdkit_media_import()); Elementor's own importer
4746 // below always makes its own blocking round trip and never looks at it.
4747 // Fall through on any miss/failure so behaviour is unchanged when the
4748 // warm-up did not run or the file could not be fetched.
4749 if ( preg_match( '/\.svg$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) ) {
4750 self::wdkit_require_import_images();
4751
4752 $warm = Wdkit_Import_Images::wdkit_Import_media(
4753 array(
4754 'id' => (int) $source_id,
4755 'url' => $url,
4756 )
4757 );
4758
4759 if ( is_array( $warm ) && ! empty( $warm['id'] ) && ! empty( $warm['url'] )
4760 && ! self::wdkit_is_foreign_media_url( $warm['url'] )
4761 && self::wdkit_is_usable_attachment( (int) $warm['id'] )
4762 ) {
4763 $cache[ $url ] = array(
4764 'id' => (int) $warm['id'],
4765 'url' => $warm['url'],
4766 );
4767
4768 return $cache[ $url ];
4769 }
4770 }
4771
4772 if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
4773 return array();
4774 }
4775
4776 $images = \Elementor\Plugin::$instance->templates_manager->get_import_images_instance();
4777
4778 if ( ! $images ) {
4779 return array();
4780 }
4781
4782 // A download may happen, so keep the oversized-image guard in force.
4783 self::wdkit_guard_oversized_images();
4784
4785 // Elementor only consults its hash table when an id is present - but it also
4786 // memoises the result per source id in Import_Images::$_replace_image_ids, and that
4787 // memo is checked BEFORE the url is looked at. So a shared placeholder is not a
4788 // harmless "any non-zero value": the first url imported under it wins, and every
4789 // later url handed the same placeholder is silently answered with that first
4790 // attachment. Measured on a Zion import: one image served 10 of the page's media
4791 // controls and all 3 of its background rules, while the correct files sat in the
4792 // library unreferenced.
4793 //
4794 // A stable, url-derived stand-in keeps the hash-table path enabled while giving each
4795 // distinct url its own memo slot. crc32 is used for a compact positive integer, not
4796 // for collision resistance - a clash would only cost a redundant lookup, and the
4797 // per-url cache above already prevents repeats within a request.
4798 $probe_id = $source_id ? (int) $source_id : ( crc32( $url ) & 0x7FFFFFFF );
4799
4800 self::wdkit_watch_elementor_attachments();
4801
4802 $imported = $images->import(
4803 array(
4804 'id' => $probe_id,
4805 'url' => $url,
4806 )
4807 );
4808
4809 // Elementor answers this from its own `_elementor_source_image_hash` index before it
4810 // downloads anything, so it hands back whatever attachment claimed that hash - a
4811 // record with no file included. Same reason the deferred lookup is gated: writing
4812 // that id into the page is what leaves a control resolving to nothing. The stale
4813 // hash is dropped on the way out so the next attempt is a miss and downloads.
4814 $local = array();
4815
4816 if ( ! empty( $imported['id'] ) && ! empty( $imported['url'] ) ) {
4817 if ( self::wdkit_is_usable_attachment( $imported['id'] ) ) {
4818 $local = array(
4819 'id' => (int) $imported['id'],
4820 'url' => $imported['url'],
4821 );
4822 } else {
4823 self::wdkit_forget_reuse_keys( $imported['id'] );
4824 }
4825 }
4826
4827 // Drained inline as well as on shutdown: Elementor has finished writing this row's
4828 // metadata by the time import() returns, so it is safe to remove now, and removing it
4829 // now keeps a stub from being visible to the rest of the import.
4830 self::wdkit_sweep_stub_attachments();
4831
4832 // Remember hits only: a sibling request importing concurrently may simply not have
4833 // finished yet, and caching that miss would strand every later control on this page.
4834 if ( $local ) {
4835 $cache[ $url ] = $local;
4836 }
4837
4838 return $local;
4839 }
4840
4841 /**
4842 * Whether a page still references an SVG on the template's own site.
4843 *
4844 * @since 2.7.1
4845 *
4846 * @param int $post_id Page to inspect.
4847 * @return bool True when a foreign SVG reference remains.
4848 */
4849 private static function wdkit_page_has_foreign_svg( $post_id ) {
4850
4851 $raw = get_post_meta( (int) $post_id, '_elementor_data', true );
4852
4853 if ( empty( $raw ) ) {
4854 return false;
4855 }
4856
4857 $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
4858
4859 if ( ! is_array( $data ) ) {
4860 return false;
4861 }
4862
4863 return ! empty( self::wdkit_collect_foreign_media_urls( $data, 'svg' ) );
4864 }
4865
4866 /**
4867 * Post IDs that still have a deferred-media event waiting in wp-cron.
4868 *
4869 * Read off the cron array rather than tracked separately, so it cannot drift from what
4870 * wkit_schedule_deferred_media_sync() actually queued.
4871 *
4872 * @since 2.7.1
4873 *
4874 * @return array<int> Post IDs with a pending sideload event.
4875 */
4876 private static function wdkit_pages_awaiting_deferred_media() {
4877
4878 $crons = _get_cron_array();
4879
4880 if ( ! is_array( $crons ) ) {
4881 return array();
4882 }
4883
4884 $pending = array();
4885
4886 foreach ( $crons as $events ) {
4887 if ( empty( $events['wdkit_async_sideload_page_images'] ) || ! is_array( $events['wdkit_async_sideload_page_images'] ) ) {
4888 continue;
4889 }
4890
4891 foreach ( $events['wdkit_async_sideload_page_images'] as $event ) {
4892 // args[0] is the post id - see wkit_schedule_deferred_media_sync().
4893 if ( isset( $event['args'][0] ) ) {
4894 $pending[] = (int) $event['args'][0];
4895 }
4896 }
4897 }
4898
4899 return array_unique( $pending );
4900 }
4901
4902 /**
4903 * Repair dangling attachment IDs across every page of a finished import.
4904 *
4905 * The create-time repair in wdkit_media_import() can only see attachments that already
4906 * exist. Pages import concurrently and share images — an icon first imported by one
4907 * page is referenced by several others — so a page that runs early legitimately cannot
4908 * resolve an image a sibling request has not created yet.
4909 *
4910 * This runs at the finalize step, once every page and attachment exists, and fixes
4911 * whatever the per-page pass had to leave behind.
4912 *
4913 * @since 2.6.2
4914 *
4915 * @param array $page_ids Imported post IDs.
4916 * @param bool $local_only Skip the download-triggering branch of
4917 * wdkit_repair_attachment_ids() and only fix dangling ids on
4918 * already-local media. The PHP runner passes true here — its
4919 * site_settings step (stage_setup) runs before
4920 * schedule_media_sweep() (stage_finalize) ever queues anything,
4921 * so wdkit_pages_awaiting_deferred_media() below is always empty
4922 * for it and every still-remote image used to be downloaded
4923 * synchronously right here instead of by the deferred pass - see
4924 * ClickUp 14ynqxywncc. The browser's own AJAX call already waits
4925 * for scheduling first, so it is unaffected by this defaulting to
4926 * false.
4927 * @return int Number of pages actually rewritten.
4928 */
4929 private function wdkit_sweep_attachment_ids( $page_ids, $local_only = false ) {
4930
4931 if ( empty( $page_ids ) || ! did_action( 'elementor/loaded' ) ) {
4932 return 0;
4933 }
4934
4935 $fixed = 0;
4936 $ids = array_unique( array_map( 'intval', $page_ids ) );
4937
4938 // Leave alone any page whose media is already queued for the background pass.
4939 //
4940 // The repair walk itself is trivial - 15ms across 18 pages. The cost is that
4941 // wdkit_repair_attachment_ids() resolves foreign URLs through
4942 // wdkit_localise_media_url(), which downloads anything not already local. For an
4943 // Elementor kit that quietly made this sweep the media importer, inside the import
4944 // request, at 156s-225s. wdkit_async_sideload_page_images() does the same work per
4945 // page - rewriting _elementor_data (url and id), clearing _elementor_css, flushing
4946 // the files manager - but off the request, and only once that page's images exist.
4947 //
4948 // Safe only because the queue is genuinely populated for both builders now: the
4949 // Elementor branch of wdkit_media_import() reports what it deferred (see
4950 // wdkit_collect_foreign_media_urls()), and the client awaits the scheduling call
4951 // before this runs. Pages with nothing queued still sweep exactly as before.
4952 $queued = self::wdkit_pages_awaiting_deferred_media();
4953
4954 if ( ! empty( $queued ) ) {
4955 foreach ( $queued as $queued_id ) {
4956 // A queued page is only safe to skip once nothing on it still needs
4957 // in-request work. The deferred list is raster-only, and an SVG inlined into
4958 // an HTML control has no `id` for wdkit_localise_foreign_svgs() to correct -
4959 // so if any foreign SVG reference survives, this page still needs the sweep.
4960 if ( self::wdkit_page_has_foreign_svg( $queued_id ) ) {
4961 continue;
4962 }
4963
4964 $ids = array_values( array_diff( $ids, array( $queued_id ) ) );
4965 }
4966
4967 if ( empty( $ids ) ) {
4968 return 0;
4969 }
4970 }
4971
4972 foreach ( $ids as $post_id ) {
4973
4974 if ( ! $post_id ) {
4975 continue;
4976 }
4977
4978 $raw = get_post_meta( $post_id, '_elementor_data', true );
4979
4980 if ( empty( $raw ) ) {
4981 continue;
4982 }
4983
4984 $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
4985
4986 if ( ! is_array( $data ) ) {
4987 continue;
4988 }
4989
4990 $repaired = self::wdkit_repair_attachment_ids( $data, $local_only );
4991
4992 if ( wp_json_encode( $repaired ) === wp_json_encode( $data ) ) {
4993 continue;
4994 }
4995
4996 // Save through the document API so Elementor regenerates the page CSS — the
4997 // background-image rules are only emitted once the IDs resolve.
4998 $document = \Elementor\Plugin::$instance->documents->get( $post_id );
4999
5000 // Count only a save that actually happened. Document::save() returns false
5001 // without saving when the current user cannot edit the post, and reporting
5002 // those as repaired hides the fact that nothing changed.
5003 if ( $document && $document->save( array( 'elements' => $repaired ) ) ) {
5004 ++$fixed;
5005 }
5006 }
5007
5008 // No cache flush here on purpose. Document::save() above already deletes the css
5009 // file, _elementor_css, and the element cache for each page it saved - see
5010 // wdkit_invalidate_elementor_page_cache() for why the site-wide
5011 // files_manager->clear_cache() that used to follow this loop is the wrong tool.
5012
5013 return $fixed;
5014 }
5015
5016 /**
5017 * Repair media controls whose attachment ID does not resolve.
5018 *
5019 * Elementor media controls store `{ url, id }`. Controls flagged `has_sizes` — the
5020 * container/section **background image** among them — do not render from `url` at all:
5021 * CSS generation resolves the image through the attachment ID, so a dangling ID
5022 * produces no `background-image` rule and the section renders with no image even
5023 * though its URL is perfectly correct.
5024 *
5025 * IDs arrive dangling whenever Elementor's own importer does not rewrite a control —
5026 * it carries the source site's ID, which means nothing locally. Now that the URL is
5027 * already a local upload before import, the ID can simply be looked up from it.
5028 *
5029 * @since 2.6.2
5030 *
5031 * @param mixed $node Elementor data, walked recursively.
5032 * @return mixed Data with resolvable attachment IDs.
5033 */
5034 /**
5035 * Is this URL a video the background pass owns?
5036 *
5037 * @since 2.7.1
5038 *
5039 * @param string $url Media URL.
5040 * @return bool
5041 */
5042 private static function wdkit_is_video_url( $url ) {
5043 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
5044 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
5045 }
5046
5047 return (bool) preg_match(
5048 '/\.(?:' . Wdkit_Image_Guard::VIDEO_EXTENSIONS . ')$/i',
5049 (string) wp_parse_url( (string) $url, PHP_URL_PATH )
5050 );
5051 }
5052
5053 private static function wdkit_repair_attachment_ids( $node, $local_only = false ) {
5054
5055 if ( ! is_array( $node ) ) {
5056 return $node;
5057 }
5058
5059 // A media control value: has a url, and an id slot to correct.
5060 if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
5061
5062 $current = (int) $node['id'];
5063
5064 if ( self::wdkit_is_foreign_media_url( $node['url'] ) ) {
5065 // Still pointing at the source site. Ask Elementor for the local copy: its
5066 // _elementor_source_image_hash lookup returns the attachment the create-time
5067 // import already made, so this normally costs a single query and no download.
5068 //
5069 // Skipped entirely in $local_only mode: that caller is the deferred pass,
5070 // which owns the decision about what gets downloaded and when, and must not
5071 // have a repair walk start fetching things behind it.
5072 //
5073 // Video is skipped for the same reason in every mode. It is localised only by
5074 // the background pass, so a still-foreign video URL here is not a fault to
5075 // repair - it is work that has not run yet. Worse, sending one through
5076 // wdkit_localise_media_url() actively corrupts it: that helper hands
5077 // Elementor `id => 1` whenever the control's id is empty, and Elementor
5078 // caches its import results *by that source id*, so every blanked-id control
5079 // collides on key 1 and gets handed back whichever attachment claimed it
5080 // first. A video, whose id the deferred pass deliberately blanks and whose
5081 // URL stays foreign until the cron runs, is the one control that meets both
5082 // conditions - and it came back pointing at an unrelated PNG.
5083 if ( ! $local_only && ! self::wdkit_is_video_url( $node['url'] ) ) {
5084 $local = self::wdkit_localise_media_url( $node['url'], $current );
5085
5086 if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
5087 $node['id'] = $local['id'];
5088 $node['url'] = $local['url'];
5089 }
5090 }
5091 } elseif ( false !== strpos( $node['url'], '/wp-content/uploads/' ) ) {
5092 // The URL is already local, so the attachment it names is the truth and the
5093 // id beside it may not be. Two ways it goes wrong, and both look the same
5094 // from here: the id is the authoring site's and matches nothing locally, or
5095 // it happens to match some unrelated attachment. Either way the URL wins.
5096 //
5097 // This matters most for a container background: Control_Media::get_style_value()
5098 // only falls back to the url when the id is *empty*, so a non-empty id that
5099 // resolves to nothing makes wp_get_attachment_image_url() return false and the
5100 // whole background-image declaration is dropped - the image simply vanishes.
5101 // An image widget survives it (get_attachment_image_html() clears an id that
5102 // fails wp_attachment_is_image() and renders from the url), which is why this
5103 // showed up as "some backgrounds missing" rather than as broken images.
5104 //
5105 // Only a real change is written: when the URL resolves to the id that is
5106 // already there, the node is left exactly as it was.
5107 $resolved = self::wdkit_attachment_id_from_url( $node['url'] );
5108
5109 // Held to the same bar as every other id this class writes. A local upload url
5110 // can still name a record with nothing behind it - that is the whole subject
5111 // of this repair - and writing its id back would reintroduce exactly the
5112 // dangling-id symptom this branch exists to remove. On a reject the node keeps
5113 // the id it arrived with, which is no worse than before.
5114 if ( $resolved && $resolved !== $current && self::wdkit_is_usable_attachment( $resolved ) ) {
5115 $node['id'] = $resolved;
5116 }
5117 }
5118 }
5119
5120 foreach ( $node as $key => $value ) {
5121 if ( is_array( $value ) ) {
5122 $node[ $key ] = self::wdkit_repair_attachment_ids( $value, $local_only );
5123 }
5124 }
5125
5126 return $node;
5127 }
5128
5129 public function wdkit_media_import( $content = array(), $editor = '', $defer_media = false ) {
5130
5131 if ( empty( $content ) && empty( $editor ) ) {
5132 $args = $this->wdkit_parse_args( $_POST );
5133 $content = ! empty( $args['content'] ) ? json_decode( $args['content'], true ) : array();
5134 } else {
5135 $args = array(
5136 'content' => $content,
5137 'editor' => $editor,
5138 );
5139 $content = ! empty( $args['content'] ) ? $args['content'] : array();
5140
5141 if ( 'elementor' === $args['editor'] ) {
5142 $content = json_decode( $content, true );
5143 }
5144 }
5145
5146 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
5147 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
5148 }
5149
5150
5151 if ( ! empty( $args['editor'] ) && 'gutenberg' === $args['editor'] && ! empty( $content ) ) {
5152
5153 // parse_blocks() leaves a filler entry (blockName null/empty) for every stretch of
5154 // whitespace between real blocks. blocks_import_media_copy_content() below always
5155 // dropped these before recursing - but WDKIT_Nexter_Block_Processor::run() (which
5156 // runs unconditionally right after this, in import_page_section_content()) keys
5157 // each block's generated CSS/id off its position in this array, so leaving them in
5158 // shifts every block after the first and the page renders unstyled/mismatched.
5159 // This is a plain array filter, no network/media work, so it must run regardless
5160 // of $defer_media - only the actual per-block media copy below is skippable.
5161 if ( is_array( $content ) ) {
5162 foreach ( $content as $key => $val ) {
5163 if ( is_array( $val ) && array_key_exists( 'blockName', $val )
5164 && ( empty( $val['blockName'] ) || null === $val['blockName'] || ' ' === $val['blockName'] )
5165 ) {
5166 unset( $content[ $key ] );
5167 }
5168 }
5169 }
5170
5171 // Same contract as the Elementor branch below: the caller already collected this
5172 // content's image_urls from the template response and will hand them to
5173 // wkit_schedule_deferred_media_sync() once the page exists, so raster images can
5174 // stay pointed at the source CDN and be localised later, off this request, via
5175 // wdkit_async_sideload_page_images(). SVGs are excluded from that deferral (see
5176 // Wdkit_Import_Images::wdkit_Import_media()) and still import synchronously here -
5177 // the walk itself is cheap, so passing $defer_media through and letting the leaf
5178 // function decide per-image costs nothing extra when not deferring.
5179 // Warm every SVG this walk is about to import, concurrently, before the walk
5180 // starts. The walk itself is strictly one node at a time, so without this each
5181 // SVG pays its own blocking round trip to the template CDN - measured at ~44s
5182 // for the 65 SVGs in a 13-template kit, virtually all of it connection setup
5183 // and latency rather than work. SVGs specifically because they are the only
5184 // media still imported synchronously once $defer_media is on (see the carve-out
5185 // in Wdkit_Import_Images::wdkit_Import_media()), and because they are small
5186 // enough to hold in memory safely.
5187 //
5188 // Scanning the serialized content rather than pre-walking the tree keeps this
5189 // from having to duplicate - and drift from - the walk's own URL detection.
5190 // Over-collecting is harmless (an unused warm entry is just dropped) and
5191 // under-collecting only costs the original sequential fetch.
5192 $svg_scan = wp_json_encode( $content, JSON_UNESCAPED_SLASHES );
5193
5194 if ( is_string( $svg_scan ) && preg_match_all( '#https?://[^"\'\s<>()\\\\]+\.svg(?:\?[^"\'\s<>()\\\\]*)?#i', $svg_scan, $svg_matches ) ) {
5195 Wdkit_Import_Images::prefetch_remote_files( array_unique( $svg_matches[0] ) );
5196 }
5197
5198 // Snapshot before the walk so the ids cleared below are this content's alone.
5199 // peek_deferred_urls() returns the whole request-wide static list, so on a route
5200 // that imports more than one item, an earlier item's URLs were still in it - and
5201 // any of them that happened to appear on this page had its id zeroed here with
5202 // no event of its own coming to put the id back.
5203 self::wdkit_require_import_images();
5204 $deferred_before = Wdkit_Import_Images::peek_deferred_urls();
5205
5206 $media_import = array( $content );
5207 $media_import = self::blocks_import_media_copy_content( $media_import, $defer_media );
5208 $content = $media_import[0];
5209
5210 // Blocks that rebuild their image from attributes on every render read the
5211 // attachment id ahead of the url, so the foreign id the template shipped hides
5212 // the still-remote url that would have rendered perfectly well. Zero those ids
5213 // for exactly the images the walk above chose to defer, so the page renders from
5214 // the source CDN until wdkit_async_sideload_page_images() localises it and
5215 // wdkit_remap_attachment_ids() writes the real id back.
5216 $deferred_here = array_values( array_diff( Wdkit_Import_Images::peek_deferred_urls(), $deferred_before ) );
5217
5218 $content = self::wdkit_clear_deferred_attachment_ids( $content, $deferred_here );
5219
5220 // The walk above only visits block ATTRIBUTES. A block that ships rendered markup
5221 // - tpgb/tp-testimonials is one - carries its images as `<img src>` inside its own
5222 // innerHTML, which no attribute walk can reach, so those URLs were never recorded
5223 // and the background pass never learned to rewrite them. Measured on a Gutenberg
5224 // AI import: 8 template-CDN PNGs left hotlinked across two pages, every one of
5225 // them inside block markup rather than an attribute.
5226 //
5227 // A regex sweep of the serialised content closes that gap, and is additive by
5228 // construction: it only ever adds URLs to the deferred list, and the cron's
5229 // str_replace over post_content is what localises them. A URL the cron cannot
5230 // resolve is skipped exactly as before.
5231 if ( $defer_media ) {
5232 // Hand the collector the content as-is. Pre-encoding it here was wrong twice
5233 // over: wp_json_encode() without JSON_UNESCAPED_SLASHES turns every `https://`
5234 // into `https:\/\/`, which the collector's regex cannot match, and the
5235 // collector encodes what it is given anyway - so a string argument was being
5236 // escaped a second time. Passing the parsed block array straight through lets
5237 // it encode once, with the right flags, and innerHTML is inside that encoding.
5238 $markup_urls = self::wdkit_collect_foreign_media_urls( $content );
5239
5240 if ( ! empty( $markup_urls ) ) {
5241 $missed = array_values( array_diff( $markup_urls, $deferred_here ) );
5242
5243 if ( ! empty( $missed ) ) {
5244 Wdkit_Import_Images::record_deferred_urls( $missed );
5245 }
5246 }
5247 }
5248
5249 // The warmed bodies have served their purpose for this template; do not carry
5250 // them across the rest of the request.
5251 Wdkit_Import_Images::clear_prefetched();
5252 } elseif ( ! empty( $args['editor'] ) && 'elementor' === $args['editor'] && ! empty( $content ) ) {
5253 $media_import = array( $content );
5254 // Element IDs must stay unique regardless of the media path, so this always runs.
5255 $media_import = self::widgets_elements_id_change( $media_import );
5256
5257 if ( $defer_media ) {
5258 // Skipping the walk above means Elementor never sees this page's media, so
5259 // nothing else in the request would notice what was left remote. Split it the
5260 // same way the Gutenberg walk does, per image:
5261 //
5262 // - SVGs are localised now. The background pass runs with no user and cannot
5263 // upload them (see wdkit_collect_foreign_media_urls()), so deferring one
5264 // loses it silently. These are cheap - small files, and usually already
5265 // imported by a sibling page.
5266 // - Everything else is recorded for the background pass, which is where the
5267 // expensive raster downloads and subsize generation belong.
5268 //
5269 // Warm every SVG this branch is about to import, concurrently, before the
5270 // one-node-at-a-time walk starts - the exact same fix the Gutenberg branch
5271 // above already carries. Without it each SVG paid its own blocking round
5272 // trip to the template CDN: measured at ~42s of sum_media_import for a
5273 // 15-template Elementor kit (matching that branch's own 65-SVG / ~44s
5274 // finding), virtually all of it connection setup and latency.
5275 // wdkit_localise_media_url() consults this cache for .svg URLs.
5276 $svg_scan = wp_json_encode( $media_import[0], JSON_UNESCAPED_SLASHES );
5277
5278 if ( is_string( $svg_scan ) && preg_match_all( '#https?://[^"\'\s<>()\\\\]+\.svg(?:\?[^"\'\s<>()\\\\]*)?#i', $svg_scan, $svg_matches ) ) {
5279 self::wdkit_require_import_images();
5280 Wdkit_Import_Images::prefetch_remote_files( array_unique( $svg_matches[0] ) );
5281 }
5282
5283 $media_import[0] = self::wdkit_localise_foreign_svgs( $media_import[0] );
5284
5285 Wdkit_Import_Images::clear_prefetched();
5286
5287 $deferred = self::wdkit_collect_foreign_media_urls( $media_import[0] );
5288
5289 Wdkit_Import_Images::record_deferred_urls( $deferred );
5290
5291 // Leave the attachment IDs of those deferred controls empty rather than
5292 // carrying the template site's IDs through the window - see
5293 // wdkit_blank_deferred_media_ids().
5294 $media_import[0] = self::wdkit_blank_deferred_media_ids( $media_import[0], $deferred );
5295
5296 return $media_import[0];
5297 }
5298
5299 $media_import = self::widgets_import_media_copy_content( $media_import );
5300 $content = $media_import[0];
5301
5302 // Last: point any control Elementor left holding a foreign attachment ID at the
5303 // local attachment its URL already refers to. Without this, has_sizes controls
5304 // such as container background images resolve to nothing and render empty.
5305 $content = self::wdkit_repair_attachment_ids( $content );
5306 }
5307
5308 return $content;
5309 }
5310
5311 /**
5312 * Widgets elements data
5313 *
5314 * @since 1.0.0
5315 * @param string $media_import it is store media data.
5316 */
5317 protected static function widgets_elements_id_change( $media_import ) {
5318 if ( did_action( 'elementor/loaded' ) ) {
5319 return \Elementor\Plugin::instance()->db->iterate_data(
5320 $media_import,
5321 function ( $element ) {
5322 $element['id'] = \Elementor\Utils::generate_random_string();
5323 return $element;
5324 }
5325 );
5326 } else {
5327 return $media_import;
5328 }
5329 }
5330
5331 /**
5332 * Widgets Media import copy content.
5333 *
5334 * @since 1.0.0
5335 *
5336 * @param string $media_import it is store media data.
5337 */
5338 protected static function widgets_import_media_copy_content( $media_import ) {
5339 if ( did_action( 'elementor/loaded' ) ) {
5340
5341 return \Elementor\Plugin::instance()->db->iterate_data(
5342 $media_import,
5343 function ( $element_data ) {
5344 $elements = \Elementor\Plugin::instance()->elements_manager->create_element_instance( $element_data );
5345
5346 if ( ! $elements ) {
5347 return null;
5348 }
5349
5350 return self::widgets_element_import_start( $elements );
5351 }
5352 );
5353 } else {
5354 return $media_import;
5355 }
5356 }
5357
5358 /**
5359 * Start element copy content for media import.
5360 *
5361 * @since 1.0.0
5362 *
5363 * @param string \Elementor\Controls_Stack $element it is store elementor data.
5364 */
5365 protected static function widgets_element_import_start( \Elementor\Controls_Stack $element ) {
5366 $get_element_instance = $element->get_data();
5367 $tp_mi_on_fun = 'on_import';
5368
5369 if ( method_exists( $element, $tp_mi_on_fun ) ) {
5370 $get_element_instance = $element->{$tp_mi_on_fun}( $get_element_instance );
5371 }
5372
5373 foreach ( $element->get_controls() as $get_control ) {
5374 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
5375 $control_name = $get_control['name'];
5376
5377 if ( ! $control_type ) {
5378 // Skip just this control. Returning here would abandon every control after
5379 // it, so a single unregistered type - routine when a kit uses an addon that
5380 // is not fully active yet - would silently leave the rest of the element's
5381 // media pointing at the source site.
5382 continue;
5383 }
5384
5385 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
5386 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
5387 }
5388 }
5389
5390 return $get_element_instance;
5391 }
5392
5393 /**
5394 * Blocks Recursively data
5395 *
5396 * @param string $data_import gutenber data import.
5397 */
5398 public static function blocks_import_media_copy_content( $data_import, $defer_media = false ) {
5399 if ( ! empty( $data_import ) ) {
5400 foreach ( $data_import[0] as $key => $val ) {
5401 if ( array_key_exists( 'blockName', $val ) && ( empty( $val['blockName'] ) || null === $val['blockName'] || empty( $val['blockName'] ) || ' ' === $val['blockName'] ) ) {
5402 unset( $data_import[0][ $key ] );
5403 }
5404 }
5405 }
5406
5407 return self::blocks_array_recursively_data(
5408 $data_import,
5409 function ( $block_data, $args ) {
5410 $elements = self::blocks_data_instance( $block_data, $args );
5411 return $elements;
5412 },
5413 array( 'defer_media' => $defer_media )
5414 );
5415 }
5416
5417 /**
5418 * Blocks Recursively data
5419 *
5420 * @param array $data store data.
5421 * @param string $callback store data.
5422 * @param string $args store data.
5423 */
5424 public static function blocks_array_recursively_data( $data, $callback, $args = array() ) {
5425 if ( ( isset( $data['name'] ) && ! empty( $data['name'] ) ) || ( isset( $data['blockName'] ) && ! empty( $data['blockName'] ) ) ) {
5426 if ( ! empty( $data['innerBlocks'] ) ) {
5427 $data['innerBlocks'] = self::blocks_array_recursively_data( $data['innerBlocks'], $callback, $args );
5428 }
5429
5430 return call_user_func( $callback, $data, $args );
5431 }
5432
5433 if ( ! empty( $data ) ) {
5434 $data = (array) $data;
5435 foreach ( $data as $block_key => $block_value ) {
5436 $block_data = self::blocks_array_recursively_data( $data[ $block_key ], $callback, $args );
5437
5438 if ( null === $block_data ) {
5439 continue;
5440 }
5441
5442 $data[ $block_key ] = $block_data;
5443 }
5444 }
5445
5446 return $data;
5447 }
5448
5449 /**
5450 * Check Blocks data media Url
5451 *
5452 * @param array $block_data store data.
5453 * @param array $args store data.
5454 * @param array $block_args store data.
5455 */
5456 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
5457
5458 if ( ( isset( $block_data['name'] ) && isset( $block_data['clientId'] ) && isset( $block_data['attributes'] ) ) || ( isset( $block_data['blockName'] ) && isset( $block_data['attrs'] ) && ! empty( $block_data['attrs'] ) ) ) {
5459 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
5460 $blocks_attr = self::wdkit_import_block_media( $blocks_attr, ! empty( $args['defer_media'] ) );
5461 if ( isset( $block_data['attributes'] ) ) {
5462 $block_data['attributes'] = $blocks_attr;
5463 } elseif ( isset( $block_data['attrs'] ) ) {
5464 $block_data['attrs'] = $blocks_attr;
5465 }
5466
5467 $block_data = self::wdkit_relink_block_markup( $block_data );
5468 }
5469
5470 return $block_data;
5471 }
5472
5473 /**
5474 * Run block markup through the media import, the way the create path does.
5475 *
5476 * Used wherever block content is written from the browser: media import, then the Nexter
5477 * block processor so each block's rendered copy matches its attributes, then serialise.
5478 *
5479 * @since 2.6.2
5480 *
5481 * @param string $content Block markup.
5482 * @return string Block markup with local media.
5483 */
5484 private function wdkit_relink_gutenberg_content( $content ) {
5485
5486 if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) ) {
5487 return $content;
5488 }
5489
5490 // wdkit_media_import() loads this itself, but it is referenced before that below.
5491 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
5492 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
5493 }
5494
5495 // Thumbnail generation decodes each image, so keep the oversized-image guard in force.
5496 self::wdkit_guard_oversized_images();
5497
5498
5499 // Block attributes are JSON inside the block delimiters, so they only survive a parse
5500 // when the string carries exactly one level of escaping. Arrive with an extra level and
5501 // parse_blocks() reads no attributes at all - serialising that back out writes every
5502 // block bare, throwing away titles, body text, icons and styling.
5503 $parsable = self::wdkit_parsable_block_content( $content );
5504
5505 if ( null === $parsable ) {
5506
5507 return $content;
5508 }
5509
5510 $blocks = parse_blocks( $parsable );
5511 $blocks = $this->wdkit_media_import( $blocks, 'gutenberg' );
5512
5513 if ( empty( $blocks ) || ! is_array( $blocks ) ) {
5514 return $content;
5515 }
5516
5517 if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
5518 $processor = new WDKIT_Nexter_Block_Processor();
5519 $blocks = $processor->run( $blocks );
5520 }
5521
5522 $serialised = serialize_blocks( $blocks );
5523
5524 // serialize_blocks() re-escapes any literal "--" left in attributes (WP core's own
5525 // protection against breaking the HTML comment delimiters), the same way Path A/B's
5526 // wdkit_bundle_insert_item()/import_page_section_content() do - so it needs the same
5527 // decode step afterward, or those - escapes are left in the saved content.
5528 $serialised = $this->replace_unicode_glitch( $serialised );
5529
5530 // Last line of defence. This function exists to repoint media, so a result carrying
5531 // fewer block attributes than it started with is a broken round trip, not a rewrite.
5532 // Leaving the media wrong is recoverable; saving gutted content is not.
5533 $before = self::wdkit_block_attr_count( $parsable );
5534 $after = self::wdkit_block_attr_count( $serialised );
5535
5536 if ( $after < $before ) {
5537
5538 return $content;
5539 }
5540
5541 // Never hand back nothing: an empty result would blank the page.
5542 return ! empty( $serialised ) ? $serialised : $content;
5543 }
5544
5545 /**
5546 * Attachment rows Elementor's importer created during this request.
5547 *
5548 * @since 2.7.1
5549 *
5550 * @var array<int>
5551 */
5552 private static $elementor_new_attachments = array();
5553
5554 /**
5555 * Watch Elementor's importer so a row it leaves behind can be rolled back.
5556 *
5557 * Elementor inserts the attachment row before it knows whether the upload worked.
5558 * class-import-images.php gates the mime type on `if ( $info )`, where `$info` is
5559 * wp_check_filetype()'s return - always an array, so always true - and it never looks at
5560 * wp_upload_bits()'s own error. So when the upload fails, which on a real site means an
5561 * unwritable or full uploads directory, `$upload['file']` is simply absent: Elementor
5562 * warns "Undefined array key file", inserts a row with post_mime_type '' and no
5563 * _wp_attached_file, and carries on. Reproduced here by pointing upload_dir at a path
5564 * that cannot be created.
5565 *
5566 * Nothing renders from such a row - wdkit_localise_media_url() refuses the id - but it
5567 * stays in the customer's media library, and it is exactly the record the reuse lookup
5568 * used to adopt on a re-import.
5569 *
5570 * Registered lazily and only from the import paths, so an ordinary page load never
5571 * carries either hook. The capture is Elementor's own new_attachment filter rather than
5572 * an id diff, so only a row Elementor itself just created is ever a candidate.
5573 *
5574 * @since 2.7.1
5575 *
5576 * @return void
5577 */
5578 private static function wdkit_watch_elementor_attachments() {
5579
5580 static $watching = false;
5581
5582 if ( $watching ) {
5583 return;
5584 }
5585
5586 $watching = true;
5587
5588 add_filter(
5589 'elementor/template_library/import_images/new_attachment',
5590 static function ( $id ) {
5591 self::$elementor_new_attachments[] = (int) $id;
5592
5593 return $id;
5594 }
5595 );
5596
5597 // Elementor writes the attachment metadata AFTER the filter above fires, so nothing
5598 // may be deleted from inside it: update_post_meta() on a removed post would leave an
5599 // orphan meta row, which is worse than the orphan post. The sweep therefore runs once
5600 // the request is over, and wdkit_localise_media_url() drains it inline as well so a
5601 // bad id is gone before anything downstream can read it.
5602 add_action( 'shutdown', array( __CLASS__, 'wdkit_sweep_stub_attachments' ) );
5603 }
5604
5605 /**
5606 * Delete the attachment rows Elementor's importer created without a file behind them.
5607 *
5608 * Both markers are required, not either: an empty post_mime_type AND no attached file.
5609 * A media-offload plugin legitimately has no local file but keeps both of those, and an
5610 * SVG keeps both too - so neither can be reached by this. A row that has a mime type but
5611 * whose file has since gone missing is deliberately left alone for the same reason: from
5612 * here it is indistinguishable from offloaded media, and deleting it would take a working
5613 * image out of a customer's library.
5614 *
5615 * @since 2.7.1
5616 *
5617 * @return int Rows removed.
5618 */
5619 public static function wdkit_sweep_stub_attachments() {
5620
5621 $removed = 0;
5622
5623 foreach ( array_unique( self::$elementor_new_attachments ) as $id ) {
5624 $id = (int) $id;
5625
5626 if ( ! $id || 'attachment' !== get_post_type( $id ) ) {
5627 continue;
5628 }
5629
5630 $file = get_attached_file( $id );
5631
5632 if ( '' !== (string) get_post_mime_type( $id ) || ! empty( $file ) ) {
5633 continue;
5634 }
5635
5636 if ( self::wdkit_is_usable_attachment( $id ) ) {
5637 continue;
5638 }
5639
5640 wp_delete_attachment( $id, true );
5641 ++$removed;
5642 }
5643
5644 self::$elementor_new_attachments = array();
5645
5646 return $removed;
5647 }
5648
5649 /**
5650 * Is this id a usable image attachment on THIS site?
5651 *
5652 * get_post() is not the question. A template authored elsewhere carries that site's
5653 * attachment ids, and on the destination those integers frequently already belong to
5654 * something else entirely - a page, a nav menu item, a revision. Zion is a live example:
5655 * its team images arrive as ids 93-96, which here are two pages, an nxt_builder template
5656 * and a nav_menu_item. get_post() happily returns those, so a "does a post exist" test
5657 * concludes the id is fine and leaves it in place, and every consumer that resolves media
5658 * through the id gets nothing back.
5659 *
5660 * The collision is generic, not specific to those four numbers: any id range that overlaps
5661 * existing local content behaves the same way. So the test is what the id has to satisfy to
5662 * actually work - it must be an attachment, and it must have something to serve.
5663 *
5664 * SVGs are accepted on the strength of a real file rather than image dimensions: they are
5665 * imported synchronously and legitimately carry no width/height/sizes metadata.
5666 *
5667 * An empty post_mime_type is disqualifying on its own, whatever else the record has.
5668 * wp_attachment_is_image() is false without one, so wp_get_attachment_image_src() returns
5669 * false and Elementor's Control_Media::get_style_value() emits no background-image rule at
5670 * all - the section renders empty even though the id resolves and the url looks right.
5671 * Every path in this plugin that creates an attachment sets a real mime type (see
5672 * Wdkit_Import_Images::import(), which deletes the upload rather than insert an unknown
5673 * type), so a blank one only ever comes from a record an earlier build left behind.
5674 *
5675 * @since 2.7.1
5676 *
5677 * @param mixed $id Candidate attachment id.
5678 * @return bool True when the id resolves to an attachment that can actually be rendered.
5679 */
5680 private static function wdkit_is_usable_attachment( $id ) {
5681
5682 $id = (int) $id;
5683
5684 if ( ! $id || 'attachment' !== get_post_type( $id ) ) {
5685 return false;
5686 }
5687
5688 if ( '' === (string) get_post_mime_type( $id ) ) {
5689 return false;
5690 }
5691
5692 $meta = wp_get_attachment_metadata( $id );
5693
5694 if ( is_array( $meta ) && ( ! empty( $meta['file'] ) || ! empty( $meta['sizes'] ) ) ) {
5695 return true;
5696 }
5697
5698 // No image metadata is normal for an SVG - fall back to the file itself.
5699 $file = get_attached_file( $id );
5700
5701 return ! empty( $file ) && file_exists( $file );
5702 }
5703
5704 /**
5705 * The attachment a url on this site already refers to, or 0.
5706 *
5707 * Exists for the featured-image path: a picked stock image is copied into the media
5708 * library before the blog posts are created (see localised_images()), so the thumb_image
5709 * the client sends is normally a url on this very site by then. Handing that to
5710 * download_url() cannot work - wdesignkit_validate_external_url() rejects loopback and
5711 * reserved addresses by design - so the file has to be recognised as already-imported
5712 * instead of fetched again.
5713 *
5714 * Host-matched first so nothing offsite can reach attachment_url_to_postid(), then held
5715 * to the same wdkit_is_usable_attachment() bar as every other id this class writes: an
5716 * id that resolves to no usable attachment returns 0 and the caller falls through to its
5717 * normal external handling.
5718 *
5719 * @since 2.7.1
5720 *
5721 * @param string $url Candidate url.
5722 * @return int Usable local attachment id, or 0.
5723 */
5724 /**
5725 * Store post_content that this import already produced, without kses rewriting it.
5726 *
5727 * wp_update_post() runs post_content through kses whenever the current user cannot
5728 * `unfiltered_html`. WP-Cron has no user at all, so the deferred media pass always hits
5729 * that branch - and kses is not a no-op on block markup. It deletes <input>, <iframe>,
5730 * <form>, <select> and inline <svg> outright, and strips every attribute off <object>.
5731 *
5732 * The visible casualty is the pricing switcher: its markup carries
5733 * `<input class="switch-toggle" type="checkbox">`, and every rule that paints the control
5734 * is an adjacent-sibling selector on it - `.switch-toggle + .switch-slider` for the track,
5735 * `.switch-toggle:checked + .switch-slider` for the on state. Delete the input and the
5736 * track has no background at all and the Monthly/Yearly panels can never switch, which is
5737 * why the page had to be opened and saved by hand to come back: an administrator does hold
5738 * `unfiltered_html`, so the editor's own save writes the markup back intact.
5739 *
5740 * Suspending the filters around the write is what core's own importer does, for this exact
5741 * reason. Nothing user-supplied passes through here - this only rewrites image urls inside
5742 * content an authenticated administrator imported moments earlier - and the filters are
5743 * restored immediately, so nothing else in the request loses its sanitising.
5744 *
5745 * @since 2.7.1
5746 *
5747 * @param int $post_id Post to write.
5748 * @param string $content Block markup to store.
5749 * @return int|WP_Error Whatever wp_update_post() returned.
5750 */
5751 private static function wdkit_write_post_content( $post_id, $content ) {
5752
5753 $had_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
5754
5755 if ( $had_kses ) {
5756 kses_remove_filters();
5757 }
5758
5759 // Nexter Blocks' save_post handler treats a save of anything that is not a page,
5760 // post or product - a header/footer template, say - as a site-wide change and bumps
5761 // the stamp every page's block bundle is validated against, sending every page back
5762 // to its unbundled first-view render (block CSS arriving late, from the footer). Every
5763 // caller here only rewrites URLs; the blocks, and so the bundles, are unchanged, and
5764 // the page's own stylesheet is rebuilt with a fresh version by
5765 // wdkit_rebuild_block_css(). So that one handler sits this write out. Measured: one
5766 // unchanged re-save of the Taj Bakery header took 10 of 10 built pages to 0.
5767 $detached = self::wdkit_detach_block_bundle_invalidation();
5768
5769 // wp_update_post() unslashes what it is given, exactly like update_post_meta(),
5770 // so the content has to arrive slashed or every backslash in the markup is eaten.
5771 // The synchronous import paths already do this (see the wp_slash() calls around
5772 // wdkit_insert_post_content()); this writer was the one that did not.
5773 $result = wp_update_post(
5774 array(
5775 'ID' => $post_id,
5776 'post_content' => wp_slash( $content ),
5777 )
5778 );
5779
5780 // Restore exactly what was there, so the rest of the request sanitises as it did.
5781 if ( $had_kses ) {
5782 kses_init_filters();
5783 }
5784
5785 foreach ( $detached as $hooked ) {
5786 add_action( 'save_post', $hooked['callback'], $hooked['priority'], $hooked['args'] );
5787 }
5788
5789 return $result;
5790 }
5791
5792 /**
5793 * Take Nexter Blocks' save_post bundle invalidation off the hook, returning what was removed.
5794 *
5795 * Found by method name rather than class, the same way the CSS generator is located, so a
5796 * renamed class does not silently turn this into a no-op - and a site without Nexter
5797 * Blocks simply gets an empty list back.
5798 *
5799 * @since 2.7.2
5800 *
5801 * @return array[] Each { callback, priority, args }, for add_action() to put back.
5802 */
5803 private static function wdkit_detach_block_bundle_invalidation() {
5804 global $wp_filter;
5805
5806 $removed = array();
5807
5808 if ( empty( $wp_filter['save_post'] ) || ! is_object( $wp_filter['save_post'] ) || empty( $wp_filter['save_post']->callbacks ) ) {
5809 return $removed;
5810 }
5811
5812 foreach ( $wp_filter['save_post']->callbacks as $priority => $callbacks ) {
5813 foreach ( $callbacks as $hooked ) {
5814 $callback = isset( $hooked['function'] ) ? $hooked['function'] : null;
5815
5816 if ( is_array( $callback ) && isset( $callback[1] ) && 'plus_post_save_transient' === $callback[1] ) {
5817 $removed[] = array(
5818 'callback' => $callback,
5819 'priority' => (int) $priority,
5820 'args' => isset( $hooked['accepted_args'] ) ? (int) $hooked['accepted_args'] : 1,
5821 );
5822 }
5823 }
5824 }
5825
5826 foreach ( $removed as $hooked ) {
5827 remove_action( 'save_post', $hooked['callback'], $hooked['priority'] );
5828 }
5829
5830 return $removed;
5831 }
5832
5833 private static function wdkit_local_attachment_from_url( $url ) {
5834
5835 if ( ! is_string( $url ) || '' === $url ) {
5836 return 0;
5837 }
5838
5839 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
5840
5841 if ( '' === $host ) {
5842 return 0;
5843 }
5844
5845 // Both, because a site can be served on a different host than it stores.
5846 $local_hosts = array_filter(
5847 array_unique(
5848 array(
5849 strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ),
5850 strtolower( (string) wp_parse_url( site_url(), PHP_URL_HOST ) ),
5851 )
5852 )
5853 );
5854
5855 if ( ! in_array( $host, $local_hosts, true ) ) {
5856 return 0;
5857 }
5858
5859 $id = (int) attachment_url_to_postid( $url );
5860
5861 // A sized rendition (…-300x122.png) is not itself an attachment; the full size is.
5862 if ( ! $id ) {
5863 $full = preg_replace( '/-\d+x\d+(?=\.[A-Za-z0-9]+$)/', '', $url );
5864
5865 if ( is_string( $full ) && $full !== $url ) {
5866 $id = (int) attachment_url_to_postid( $full );
5867 }
5868 }
5869
5870 return self::wdkit_is_usable_attachment( $id ) ? $id : 0;
5871 }
5872
5873 /**
5874 * Block attributes whose image is rebuilt from the attributes on every render, keyed by
5875 * block name and listing the attribute key the image object sits under.
5876 *
5877 * Only these can be helped by wdkit_clear_deferred_attachment_ids(): their renderer does
5878 *
5879 * if ( ! empty( $img['id'] ) ) -> wp_get_attachment_image( $img['id'], ... )
5880 * elseif ( ! empty( $img['url'] ) ) -> <img src="{$img['url']}">
5881 *
5882 * so an id that resolves to nothing wins over a url that works, and nothing is emitted at
5883 * all. Zeroing the id lets the url branch run until the deferred cron supplies the real id.
5884 *
5885 * Deliberately an allow-list rather than a sweep, because none of the cheap ways to tell
5886 * "the renderer rebuilds this from attributes" actually hold here:
5887 *
5888 * - WP_Block_Type_Registry::is_dynamic() reports every TPGB block dynamic (they all get a
5889 * render_callback for css injection), tpgb/tp-image included - and tp-image is served
5890 * from its stored innerHTML, whose save() derives wp-image-{id} from this very
5891 * attribute. Zeroing it there would make the block fail editor validation.
5892 * - "the url also appears in innerHTML" does not mean innerHTML is what gets served:
5893 * tp-team-listing and tp-testimonials both carry the source site's last-saved markup
5894 * even though their php renderer ignores it.
5895 *
5896 * A block missing from this list simply keeps today's behaviour, so an omission costs a
5897 * fix, never a regression.
5898 *
5899 * @since 2.7.1
5900 *
5901 * @return array block name => list of attribute keys holding an image object.
5902 */
5903 private static function wdkit_deferred_image_attrs() {
5904
5905 return array(
5906 'tpgb/tp-team-listing' => array( 'TImage' ),
5907 'tpgb/tp-testimonials' => array( 'avatar' ),
5908 'tpgb/tp-infobox' => array( 'imageName' ),
5909 'tpgb/tp-heading-title' => array( 'imgName' ),
5910 'tpgb/tp-button' => array( 'imageName' ),
5911 'tpgb/tp-navigation-builder' => array( 'openImg', 'closeImg', 'menuImg' ),
5912 'tpgb/tp-pricing-list' => array( 'imageField' ),
5913 'tpgb/tp-social-icons' => array( 'imgField' ),
5914 'tpgb/tp-stylist-list' => array( 'iconImg' ),
5915 'tpgb/tp-breadcrumbs' => array( 'iconsImg', 'sepIconImg' ),
5916 'tpgb/tp-google-map' => array( 'pinIcon' ),
5917 'tpgb/tp-hovercard' => array( 'cntImg' ),
5918 'tpgb/tp-video' => array( 'BannerImg' ),
5919 // The header logo. Missing this one cost every kit whose header uses the block
5920 // with a raster logo its logo entirely: the file is deferred like any other PNG,
5921 // so the block was left holding the authoring site's attachment id, and its
5922 // renderer takes the id branch first - wp_get_attachment_image() on an id that
5923 // is not here returns nothing, and the block substitutes its OWN
5924 // tpgb-placeholder.jpg rather than falling through to the url that works. So the
5925 // header came out with a grey placeholder box where the logo should be
5926 // (reproduced on the PawFusion Gutenberg kit, whose logo is a .png; kits with an
5927 // .svg logo were unaffected because SVGs never defer and keep a real local id).
5928 // All three of its image slots read the id first, hover and sticky included.
5929 'tpgb/tp-site-logo' => array( 'imageStore', 'hvrImageStore', 'stickyImg' ),
5930 'tpgb/tp-progress-bar' => array( 'imageName' ),
5931 // Pro blocks, same renderer shape.
5932 'tpgb/tp-circle-menu' => array( 'imageStore' ),
5933 'tpgb/tp-timeline' => array( 'StartImage', 'EndImage' ),
5934 );
5935 }
5936
5937 /**
5938 * Point the blocks in wdkit_deferred_image_attrs() at their remote url for as long as
5939 * their image is still deferred, by zeroing the foreign attachment id sitting next to it.
5940 *
5941 * Runs on the parsed block array the media walk just returned, so there is no markup
5942 * round trip here at all - no escaping depth to resolve, no re-serialise, no attribute
5943 * count to compare. Only the integer value of one already-present key changes.
5944 *
5945 * Zero specifically, and never '' / null / unset: those all read as "no id" to the
5946 * renderer too, but wdkit_remap_attachment_ids() gates on
5947 * isset( $node['id'] ) && '' !== $node['id'], so only an integer 0 both silences the
5948 * renderer now and still gets picked up and replaced with the real id when the deferred
5949 * cron localises the file. Dropping the key would also shrink the attribute count that
5950 * that pass's own guard compares.
5951 *
5952 * If the cron never runs at all, the id stays 0 and the url stays remote - which renders
5953 * the image rather than nothing, so this is also the safer resting state.
5954 *
5955 * @since 2.7.1
5956 *
5957 * @param array $blocks parsed block array, post media walk.
5958 * @param array $deferred urls this walk chose to localise later.
5959 * @return array The same block array, with unusable deferred ids zeroed.
5960 */
5961 private static function wdkit_clear_deferred_attachment_ids( $blocks, $deferred ) {
5962
5963 if ( empty( $blocks ) || ! is_array( $blocks ) || empty( $deferred ) || ! is_array( $deferred ) ) {
5964 return $blocks;
5965 }
5966
5967 $allow = self::wdkit_deferred_image_attrs();
5968 $deferred = array_fill_keys( array_map( 'strval', $deferred ), true );
5969
5970 $clear_node = function ( &$node ) use ( $deferred ) {
5971 if ( ! is_array( $node ) || ! isset( $node['url'], $node['id'] ) ) {
5972 return;
5973 }
5974
5975 if ( ! is_string( $node['url'] ) || '' === $node['url'] ) {
5976 return;
5977 }
5978
5979 // A dynamic-source image resolves through TPGB Pro's own url helper, and in
5980 // several renderers that branch sits ahead of the plain url fallback - so an
5981 // empty id would route somewhere else entirely. Leave those untouched.
5982 if ( isset( $node['dynamic'] ) ) {
5983 return;
5984 }
5985
5986 // Only images this same walk actually deferred. Anything else either imported
5987 // synchronously already or was never ours to touch - link fields carry a
5988 // url/id pair too, and a menu item's id is a post id, not an attachment.
5989 if ( ! isset( $deferred[ (string) $node['url'] ] ) ) {
5990 return;
5991 }
5992
5993 // Same list the collector defers and wdkit_remap_attachment_ids() restores: an id
5994 // cleared for an extension one of those passes will not revisit would never come
5995 // back. This used to omit avif, which the collector already deferred.
5996 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $node['url'], PHP_URL_PATH ), PATHINFO_EXTENSION ) );
5997
5998 if ( ! in_array( $ext, Wdkit_Image_Guard::deferrable_extensions(), true ) ) {
5999 return;
6000 }
6001
6002 // An id that already resolves here is never second-guessed.
6003 if ( 0 === $node['id'] || self::wdkit_is_usable_attachment( $node['id'] ) ) {
6004 return;
6005 }
6006
6007 $node['id'] = 0;
6008 };
6009
6010 $walk_attrs = function ( &$node, $keys ) use ( &$walk_attrs, $clear_node ) {
6011 foreach ( $node as $key => &$value ) {
6012 if ( ! is_array( $value ) ) {
6013 continue;
6014 }
6015
6016 // The image object itself: clear it and stop. Its own 'sizes' map holds the
6017 // source site's other renditions, which are separate files that this import
6018 // may never localise - a 0 written in there could never be restored.
6019 if ( in_array( (string) $key, $keys, true ) ) {
6020 $clear_node( $value );
6021 continue;
6022 }
6023
6024 if ( 'sizes' === (string) $key ) {
6025 continue;
6026 }
6027
6028 $walk_attrs( $value, $keys );
6029 }
6030
6031 unset( $value );
6032 };
6033
6034 $walk_blocks = function ( &$list ) use ( &$walk_blocks, &$walk_attrs, $allow ) {
6035 foreach ( $list as &$block ) {
6036 if ( ! is_array( $block ) ) {
6037 continue;
6038 }
6039
6040 $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
6041
6042 if ( '' !== $name && isset( $allow[ $name ] ) && ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
6043 $walk_attrs( $block['attrs'], $allow[ $name ] );
6044 }
6045
6046 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
6047 $walk_blocks( $block['innerBlocks'] );
6048 }
6049 }
6050
6051 unset( $block );
6052 };
6053
6054 $walk_blocks( $blocks );
6055
6056 return $blocks;
6057 }
6058
6059 /**
6060 * Repoint attachment ids left behind by the deferred media pass.
6061 *
6062 * wdkit_async_sideload_page_images() rewrites every source url in post_content to its local
6063 * copy, but a Gutenberg media attribute is a pair - `{"url":…,"id":…}` - and only the url
6064 * half was ever touched. The id keeps pointing at the attachment id from the site the
6065 * template was authored on, which does not exist here. Blocks that render from the url are
6066 * unaffected, which is why this went unnoticed; blocks that resolve the attachment id
6067 * instead (tp-team-listing, tp-testimonials, tp-image, tp-container backgrounds) get
6068 * nothing back and render empty. The synchronous path never had this problem because
6069 * wdkit_Import_media() returns both halves and its caller writes both.
6070 *
6071 * Declines by returning null rather than guessing. Every caller keeps the url-only content
6072 * in that case, so the worst outcome is exactly today's behaviour and never worse than it:
6073 *
6074 * - not block markup, or the escaping depth cannot be resolved unambiguously
6075 * - nothing actually needs remapping (no serialise round trip is attempted at all)
6076 * - the round trip came back with fewer block attributes than it started with
6077 * - the round trip came back empty
6078 *
6079 * Only the id half of a media pair is written, only when the current id resolves to no
6080 * attachment at all, and only to an id this same pass created ($url_map) - so an id that
6081 * already resolves is never touched and no unrelated attribute is rewritten.
6082 *
6083 * @since 2.7.1
6084 *
6085 * @param string $content post_content with urls already localised.
6086 * @param array $url_map source url => array( 'id' => int, 'url' => string ) from this pass.
6087 * @return string|null Rewritten content, or null to keep the caller's own content.
6088 */
6089 private function wdkit_remap_attachment_ids( $content, $url_map ) {
6090
6091 if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) || empty( $url_map ) ) {
6092 return null;
6093 }
6094
6095 // Local url => the attachment id this pass just created for it. This is the primary
6096 // mapping: these ids were made moments ago in this same request, so no lookup is needed.
6097 $by_url = array();
6098
6099 foreach ( $url_map as $local ) {
6100 // Only ids that are genuinely renderable attachments here - see
6101 // wdkit_is_usable_attachment(). Writing an id that does not resolve would trade one
6102 // broken reference for another.
6103 if ( ! empty( $local['url'] ) && ! empty( $local['id'] ) && self::wdkit_is_usable_attachment( $local['id'] ) ) {
6104 $by_url[ (string) $local['url'] ] = (int) $local['id'];
6105 }
6106 }
6107
6108 if ( empty( $by_url ) ) {
6109 return null;
6110 }
6111
6112 // Same normalisation the relink path uses: an extra level of escaping makes
6113 // parse_blocks() read no attributes at all, and serialising that back out would write
6114 // every block bare. Null means the depth is ambiguous - decline rather than risk it.
6115 $parsable = self::wdkit_parsable_block_content( $content );
6116
6117 if ( null === $parsable ) {
6118 return null;
6119 }
6120
6121 $blocks = parse_blocks( $parsable );
6122 $changed = 0;
6123
6124 $remap = function ( &$node ) use ( &$remap, $by_url, &$changed ) {
6125 if ( ! is_array( $node ) ) {
6126 return;
6127 }
6128
6129 if ( isset( $node['url'], $node['id'] ) && is_string( $node['url'] ) && '' !== $node['url'] && '' !== $node['id'] ) {
6130 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $node['url'], PHP_URL_PATH ), PATHINFO_EXTENSION ) );
6131
6132 // Same list the collector defers and wdkit_blank_deferred_media_ids() clears -
6133 // the SVG path imports synchronously and already carries a real id. And only
6134 // when the id resolves to nothing: an id that already works is never
6135 // second-guessed, whatever the url says.
6136 if ( in_array( $ext, Wdkit_Image_Guard::deferrable_extensions(), true ) && ! self::wdkit_is_usable_attachment( $node['id'] ) ) {
6137 $new_id = isset( $by_url[ $node['url'] ] ) ? $by_url[ $node['url'] ] : 0;
6138
6139 // A sized rendition (…-800x600.png) of something this pass localised: the map
6140 // is keyed by the full-size url, so try that before any database work.
6141 $full = preg_replace( '/-\d+x\d+(?=\.[A-Za-z0-9]+$)/', '', $node['url'] );
6142
6143 if ( ! $new_id && is_string( $full ) && $full !== $node['url'] && isset( $by_url[ $full ] ) ) {
6144 $new_id = $by_url[ $full ];
6145 }
6146
6147 // Defensive last resort for a shape the map does not cover.
6148 if ( ! $new_id ) {
6149 $probe = (int) attachment_url_to_postid( $node['url'] );
6150
6151 if ( ! $probe && is_string( $full ) && $full !== $node['url'] ) {
6152 $probe = (int) attachment_url_to_postid( $full );
6153 }
6154
6155 // Held to the same bar as the map above.
6156 if ( $probe && self::wdkit_is_usable_attachment( $probe ) ) {
6157 $new_id = $probe;
6158 }
6159 }
6160
6161 if ( $new_id && (int) $node['id'] !== $new_id ) {
6162 $node['id'] = $new_id;
6163 ++$changed;
6164 }
6165 }
6166 }
6167
6168 foreach ( $node as $key => &$child ) {
6169 if ( is_array( $child ) ) {
6170 $remap( $child );
6171 }
6172 }
6173
6174 unset( $child );
6175 };
6176
6177 $walk_blocks = function ( &$list ) use ( &$walk_blocks, &$remap ) {
6178 foreach ( $list as &$block ) {
6179 if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
6180 $remap( $block['attrs'] );
6181 }
6182
6183 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
6184 $walk_blocks( $block['innerBlocks'] );
6185 }
6186 }
6187
6188 unset( $block );
6189 };
6190
6191 $walk_blocks( $blocks );
6192
6193 // Nothing to fix: do not serialise at all. A round trip that changes nothing is still a
6194 // round trip, and not taking it is strictly safer than taking it.
6195 if ( ! $changed ) {
6196 return null;
6197 }
6198
6199 $serialised = serialize_blocks( $blocks );
6200
6201 // serialize_blocks() re-escapes any literal "--" left in attributes, so undo that here
6202 // exactly as the relink path does.
6203 $serialised = $this->replace_unicode_glitch( $serialised );
6204
6205 if ( empty( $serialised ) ) {
6206 return null;
6207 }
6208
6209 // Last line of defence, same test the relink path applies: fewer attributes out than in
6210 // is a broken round trip, not a rewrite. A wrong id is recoverable; gutted content is not.
6211 if ( self::wdkit_block_attr_count( $serialised ) < self::wdkit_block_attr_count( $parsable ) ) {
6212 return null;
6213 }
6214
6215 return $serialised;
6216 }
6217
6218 /**
6219 * Repoint the `wp-image-{id}` class that the import stamped into rendered <img> tags.
6220 *
6221 * WDKIT_Nexter_Block_Processor::process_image() writes both halves of the tag from the block
6222 * attributes as they stand when it runs - `src` and `class="… wp-image-{id}"`. With deferred
6223 * media that is before the image exists locally, so the class carries the id from the site
6224 * the template was authored on. The deferred pass then repairs the `src` (it is the same url
6225 * string the replace targets) and the attribute id, but the integer inside the class matches
6226 * no url and no attribute, so it is left pointing at an attachment that does not exist here.
6227 *
6228 * Nothing renders wrong as a result - display comes from `src` - but the class is what links
6229 * the block back to a Media Library item, so in the editor the image looks detached from any
6230 * attachment.
6231 *
6232 * Deliberately a targeted string rewrite and NOT a parse_blocks()/serialize_blocks() round
6233 * trip. The change is a run of digits inside one attribute of one tag whose `src` is already
6234 * known, so nothing here needs to understand block structure - and skipping the round trip
6235 * means the escaping-depth and attribute-loss failure modes that guard the sibling id remap
6236 * cannot arise at all.
6237 *
6238 * Declines by returning null - not block-ish content, no mapping, a regex that failed, or
6239 * nothing to change - and the caller then keeps its own content untouched.
6240 *
6241 * @since 2.7.1
6242 *
6243 * @param string $content post_content with urls and attribute ids already repaired.
6244 * @param array $url_map source url => array( 'id' => int, 'url' => string ) from this pass.
6245 * @return string|null Rewritten content, or null to keep the caller's own content.
6246 */
6247 private function wdkit_remap_wp_image_classes( $content, $url_map ) {
6248
6249 if ( ! is_string( $content ) || '' === $content || empty( $url_map ) ) {
6250 return null;
6251 }
6252
6253 // Nothing stamped a class in this content - no work, and no reason to run a regex over it.
6254 if ( false === stripos( $content, 'wp-image-' ) || false === stripos( $content, '<img' ) ) {
6255 return null;
6256 }
6257
6258 // Local url => the attachment id this pass created for it. Same map the id remap uses, so
6259 // the class and the attribute cannot end up disagreeing with each other.
6260 $by_url = array();
6261
6262 foreach ( $url_map as $local ) {
6263 // Only ids that are genuinely renderable attachments here - see
6264 // wdkit_is_usable_attachment(). Writing an id that does not resolve would trade one
6265 // broken reference for another.
6266 if ( ! empty( $local['url'] ) && ! empty( $local['id'] ) && self::wdkit_is_usable_attachment( $local['id'] ) ) {
6267 $by_url[ (string) $local['url'] ] = (int) $local['id'];
6268 }
6269 }
6270
6271 if ( empty( $by_url ) ) {
6272 return null;
6273 }
6274
6275 // Structural fingerprint taken before the rewrite. The only thing this function may
6276 // change is digits inside a class attribute, so every one of these has to come back
6277 // identical - anything else means the regex reshaped the markup and the result is refused.
6278 $img_before = preg_match_all( '/<img\b/i', $content );
6279 $src_before = preg_match_all( '/\ssrc=/i', $content );
6280 $changed = 0;
6281
6282 // One pass over the <img> tags, rather than one pass per url: a kit page carries dozens of
6283 // localised urls and re-scanning the whole document for each of them is needless work.
6284 $result = preg_replace_callback(
6285 '/<img\b[^>]*>/i',
6286 function ( $matches ) use ( $by_url, &$changed ) {
6287 $tag = $matches[0];
6288
6289 if ( false === stripos( $tag, 'wp-image-' ) ) {
6290 return $tag;
6291 }
6292
6293 // Only ever act on a tag whose src is exactly a url this pass localised. A tag
6294 // pointing anywhere else is left alone, whatever its class says.
6295 if ( ! preg_match( '/\ssrc=(["\'])(.*?)\1/i', $tag, $src ) ) {
6296 return $tag;
6297 }
6298
6299 if ( ! isset( $by_url[ $src[2] ] ) ) {
6300 return $tag;
6301 }
6302
6303 $new_id = $by_url[ $src[2] ];
6304
6305 $rewritten = preg_replace_callback(
6306 '/wp-image-(\d+)/',
6307 function ( $class_match ) use ( $new_id, &$changed ) {
6308 if ( (int) $class_match[1] === $new_id ) {
6309 return $class_match[0];
6310 }
6311
6312 ++$changed;
6313
6314 return 'wp-image-' . $new_id;
6315 },
6316 $tag
6317 );
6318
6319 return null === $rewritten ? $tag : $rewritten;
6320 },
6321 $content
6322 );
6323
6324 // preg_replace_callback() returns null on failure (backtrack limit on a large page, for
6325 // instance). Never hand a partial result back.
6326 if ( null === $result || '' === $result ) {
6327 return null;
6328 }
6329
6330 if ( ! $changed ) {
6331 return null;
6332 }
6333
6334 // Same tag count, same src count: the rewrite touched classes and nothing structural.
6335 if ( preg_match_all( '/<img\b/i', $result ) !== $img_before
6336 || preg_match_all( '/\ssrc=/i', $result ) !== $src_before ) {
6337 return null;
6338 }
6339
6340 return $result;
6341 }
6342
6343 /**
6344 * Rebuild the block stylesheet for a page whose content we just rewrote.
6345 *
6346 * The addon keeps each block's styling in a generated per-page stylesheet, and every rule
6347 * is keyed to the block id it was written for. That file is produced when the page is
6348 * saved through the editor - not by wp_update_post() from an AJAX handler - so rewriting
6349 * content here leaves the page pointing at a stylesheet built for the previous markup.
6350 * Blocks whose ids are not in that file get no rules at all and render unstyled.
6351 *
6352 * @since 2.6.2
6353 *
6354 * @param int $post_id Page whose content changed.
6355 * @return bool True when a rebuild was triggered.
6356 */
6357 private static function wdkit_rebuild_block_css( $post_id ) {
6358
6359 if ( ! $post_id ) {
6360 return false;
6361 }
6362
6363 // Same build, plus the `_block_css` version the front end caches the stylesheets by
6364 // (and without the stray term-meta row the generator writes outside the editor) -
6365 // see Wdkit_Import_Css::rebuild(). Without the version, the rebuilt file kept its old
6366 // URL and browsers went on serving the copy built for the previous content.
6367 if ( class_exists( 'Wdkit_Import_Css' ) ) {
6368 return Wdkit_Import_Css::rebuild( $post_id );
6369 }
6370
6371 foreach ( get_declared_classes() as $class ) {
6372 if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
6373 continue;
6374 }
6375
6376 try {
6377 if ( method_exists( $class, 'instance' ) ) {
6378 $instance = $class::instance();
6379 } elseif ( method_exists( $class, 'get_instance' ) ) {
6380 $instance = $class::get_instance();
6381 } else {
6382 $instance = new $class();
6383 }
6384
6385 $instance->make_block_css_by_post_id( $post_id );
6386
6387
6388 return true;
6389 } catch ( \Throwable $e ) {
6390 // Styling is best-effort: a failure here must not fail the import.
6391
6392 return false;
6393 }
6394 }
6395
6396 return false;
6397 }
6398
6399 /**
6400 * How many block attributes does this markup actually yield when parsed?
6401 *
6402 * Used as a before/after measure: block attributes are the part of block markup a round
6403 * trip can silently drop, so counting them is how we tell a rewrite from a mangling.
6404 *
6405 * @since 2.6.2
6406 *
6407 * @param string $content Block markup.
6408 * @return int Total attributes across every block.
6409 */
6410 private static function wdkit_block_attr_count( $content ) {
6411 $total = 0;
6412
6413 $walk = function ( $blocks ) use ( &$walk, &$total ) {
6414 foreach ( $blocks as $block ) {
6415 if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
6416 $total += count( $block['attrs'] );
6417 }
6418
6419 if ( ! empty( $block['innerBlocks'] ) ) {
6420 $walk( $block['innerBlocks'] );
6421 }
6422 }
6423 };
6424
6425 $walk( parse_blocks( (string) $content ) );
6426
6427 return $total;
6428 }
6429
6430 /**
6431 * Return this content in a form whose block attributes actually parse.
6432 *
6433 * Content written straight to post_content never had to parse, so an extra level of
6434 * escaping on the way in did no harm. Parsing it - which repointing media requires - makes
6435 * that escaping fatal: `{\"Title\":\"…\"}` is not JSON, so every attribute is discarded.
6436 *
6437 * Rather than assume a slash depth, this measures: if stripping one level yields more
6438 * attributes, the content was over-escaped and the stripped form is the real one.
6439 *
6440 * @since 2.6.2
6441 *
6442 * @param string $content Block markup as received.
6443 * @return string|null Markup safe to parse, or null when no form of it parses.
6444 */
6445 private static function wdkit_parsable_block_content( $content ) {
6446
6447 $as_is = self::wdkit_block_attr_count( $content );
6448
6449 // Nothing claims to carry attributes, so there is nothing to lose either.
6450 if ( false === strpos( $content, '{' ) ) {
6451 return $content;
6452 }
6453
6454 // Already parses into real attributes: this is a single, correctly escaped level.
6455 // Stripping it again would remove backslashes the JSON itself needs (\n, \", \/, the
6456 // -- guard around a literal "--") and corrupt otherwise-valid content -
6457 // checking the stripped form is only safe once the as-is form has proven broken.
6458 if ( $as_is > 0 ) {
6459 return $content;
6460 }
6461
6462 $stripped = wp_unslash( $content );
6463 $stripped_attrs = self::wdkit_block_attr_count( $stripped );
6464
6465 if ( $stripped_attrs > $as_is ) {
6466 return $stripped;
6467 }
6468
6469 // Neither form parses into attributes even though the markup contains JSON: better to
6470 // leave the content exactly as it arrived than to rewrite it into something bare.
6471 return null;
6472 }
6473
6474 /**
6475 * Point a block's saved markup at the media that was just localised.
6476 *
6477 * A block stores a rendered copy of itself in `innerHTML` / `innerContent`, and for many
6478 * blocks that copy is what the front end actually outputs. Importing the attributes alone
6479 * therefore fixes the editor while leaving the page still loading from the site the
6480 * template came from - and those hosts answer 403, so the image renders broken.
6481 *
6482 * @since 2.6.2
6483 *
6484 * @param array $block_data One parsed block.
6485 * @return array The block with its markup repointed.
6486 */
6487 private static function wdkit_relink_block_markup( $block_data ) {
6488
6489 self::wdkit_require_import_images();
6490 $map = Wdkit_Import_Images::get_url_map();
6491
6492 if ( empty( $map ) ) {
6493 return $block_data;
6494 }
6495
6496 $from = array_keys( $map );
6497 $to = array_values( $map );
6498
6499 if ( ! empty( $block_data['innerHTML'] ) && is_string( $block_data['innerHTML'] ) ) {
6500 $block_data['innerHTML'] = str_replace( $from, $to, $block_data['innerHTML'] );
6501 }
6502
6503 if ( ! empty( $block_data['innerContent'] ) && is_array( $block_data['innerContent'] ) ) {
6504 foreach ( $block_data['innerContent'] as $index => $chunk ) {
6505 if ( is_string( $chunk ) ) {
6506 $block_data['innerContent'][ $index ] = str_replace( $from, $to, $chunk );
6507 }
6508 }
6509 }
6510
6511 return $block_data;
6512 }
6513
6514 /**
6515 * Import every media reference held in a block's attributes.
6516 *
6517 * Block attributes nest arbitrarily - a repeater of cards each with an image, responsive
6518 * variants, nested inner settings - so this recurses rather than reaching a fixed number
6519 * of levels down. The previous version was unrolled exactly four levels deep and also
6520 * skipped any subtree carrying an `md` key, which meant anything below that simply kept
6521 * the source site's URL and attachment ID and rendered as an empty placeholder.
6522 *
6523 * A node counts as media when it has a non-empty string `url` and either an id slot or a
6524 * URL that names an image file. That pairing is what distinguishes a media control from
6525 * a link, which also carries a `url`.
6526 *
6527 * @since 2.6.2
6528 *
6529 * @param mixed $node Block attributes, walked recursively.
6530 * @return mixed Attributes with local media.
6531 */
6532 private static function wdkit_import_block_media( $node, $defer_media = false ) {
6533
6534 if ( ! is_array( $node ) ) {
6535 return $node;
6536 }
6537
6538 $url = isset( $node['url'] ) && is_string( $node['url'] ) ? $node['url'] : '';
6539
6540 if ( '' !== $url
6541 && ( array_key_exists( 'id', $node ) || array_key_exists( 'Id', $node )
6542 || preg_match( '/\.(?:jpe?g|png|gif|svg|webp|avif|bmp)$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) )
6543 ) {
6544 $imported = Wdkit_Import_Images::wdkit_Import_media( $node, $defer_media );
6545
6546 // Only accept a real result. The importer returns the node untouched when it
6547 // cannot localise the file, and anything falsy here would wipe out the URL and
6548 // leave the block with no image at all.
6549 if ( ! empty( $imported['url'] ) ) {
6550 $node = array_merge( $node, $imported );
6551 }
6552 }
6553
6554 // Keep walking even after importing this node. A media value carries its own `sizes`
6555 // map of per-size URLs, and returning here left every one of those pointing at the
6556 // site the template came from - which is what the widgets actually render from.
6557 foreach ( $node as $key => $value ) {
6558 if ( is_array( $value ) ) {
6559 $node[ $key ] = self::wdkit_import_block_media( $value, $defer_media );
6560 }
6561 }
6562
6563 return $node;
6564 }
6565
6566 /**
6567 * Kit Template Import Pages/Sections
6568 *
6569 * @since 1.0.0
6570 * */
6571 protected function wdkit_import_kit_template() {
6572
6573 if ( ! current_user_can( 'manage_options' ) ) {
6574 return false;
6575 }
6576
6577 $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
6578
6579 $page_section = ! empty( $_POST['page_section'] ) ? sanitize_text_field( wp_unslash( $_POST['page_section'] ) ) : '';
6580
6581 if ( isset( $page_section ) ) {
6582 $args['page_section'] = ! empty( $page_section ) ? sanitize_text_field( wp_unslash( $page_section ) ) : '';
6583 }
6584
6585 $template_ids = ! empty( $_POST['template_ids'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_ids'] ) ), true ) : array();
6586 $email = ! empty( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : '';
6587 $editor = isset( $_POST['editor'] ) ? sanitize_text_field( wp_unslash( $_POST['editor'] ) ) : '';
6588 $website_kit = isset( $_POST['website_kit'] ) ? sanitize_text_field( wp_unslash( $_POST['website_kit'] ) ) : '';
6589 $api_type = isset( $_POST['api_type'] ) ? sanitize_text_field( wp_unslash( $_POST['api_type'] ) ) : 'import_template';
6590 $ai_compitible = isset( $_POST['ai_compitible'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_compitible'] ) ) : false;
6591
6592 if ( empty( $template_ids ) ) {
6593 $output = array(
6594 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
6595 'description' => esc_html__( 'Invalid import: Check your details and try again.', 'wdesignkit' ),
6596 'success' => false,
6597 );
6598
6599 wp_send_json( $output );
6600 wp_die();
6601 }
6602
6603 /**Not Usefull*/
6604 if ( isset( $_POST['select'] ) ) {
6605 $args['post_type'] = ! empty( $_POST['select'] ) ? sanitize_text_field( wp_unslash( $_POST['select'] ) ) : '';
6606 }
6607
6608 $args['custom_meta'] = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
6609 $args['editor'] = $editor;
6610
6611 $token = $this->wdkit_login_user_token( $email );
6612
6613 $temp_args = array(
6614 'token' => $token,
6615 'template_id' => $template_ids['id'],
6616 'editor' => $editor,
6617 'website_kit' => $website_kit,
6618 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
6619 );
6620
6621 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6622 $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
6623 }
6624
6625 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
6626 $output = array();
6627
6628 if ( is_wp_error( $response ) ) {
6629
6630 wp_send_json( $response );
6631 wp_die();
6632 }
6633
6634 if ( isset( $response['content'] ) && 'error' === $response['content'] ) {
6635 wp_send_json( $response );
6636 wp_die();
6637 }
6638
6639 $result = array(
6640 'response' => $response,
6641 'args' => $args,
6642 'id' => $template_ids['id'],
6643 'temp_data' => $template_ids,
6644 );
6645
6646 $output['message'] = $response['message'];
6647 $output['description'] = $response['description'];
6648 $output['data'] = $result;
6649 $output['success'] = $response['success'];
6650
6651 // Counts the IMPORT ACTION, not what it brought in. A kit import always counts as 1 kit,
6652 // no matter how many blocks/pages that kit contains — confirmed live: a single gutenberg
6653 // kit import recorded total=680, kinds.kit=680, because $template_ids for that call was a
6654 // 680-element array of the kit's own blocks and count( $template_ids ) counted every one of
6655 // them. A page-kit's *size* is not tracking's concern; "was a kit imported" is.
6656 //
6657 // The 'single' branch keeps a defensive fallback for the one shape this endpoint's own
6658 // $template_ids reliably takes when it is not a kit — a single {id, name, slug, thumb...}
6659 // object — where count() would likewise count JSON keys instead of "1 template imported".
6660 if ( ! empty( $output['success'] ) ) {
6661 $is_kit = ( '' !== $website_kit );
6662 $import_count = $is_kit ? 1 : ( isset( $template_ids['id'] ) ? 1 : ( is_array( $template_ids ) ? count( $template_ids ) : 1 ) );
6663 do_action(
6664 'wdkit_template_imported',
6665 $is_kit ? 'kit' : 'single',
6666 sanitize_key( $builder ),
6667 $import_count
6668 );
6669 }
6670
6671 wp_send_json( $output );
6672 wp_die();
6673 }
6674
6675 /**
6676 * Batched template-JSON fetch for a full AI kit import.
6677 *
6678 * Mirrors wdkit_import_kit_template() but accepts every template of a kit at once and
6679 * forwards their ids to the Laravel `import_template_batch` route in a single request,
6680 * instead of one `import_template` round trip per template. The per-template
6681 * `response`/`args`/`temp_data` shape returned for each item is identical to what
6682 * wdkit_import_kit_template() returns for a single template, so the existing
6683 * import_kit_temps()/import_page_section_content() insertion path on the JS/PHP side
6684 * is unaffected — only the template-fetch round trips are collapsed.
6685 *
6686 * Any failure (network, missing route on an older/self-hosted backend, malformed
6687 * response) returns success=false so the caller falls back to the existing
6688 * one-call-per-template flow.
6689 *
6690 * @since 2.7.0
6691 */
6692 protected function wkit_import_kit_bundle() {
6693
6694 if ( ! current_user_can( 'manage_options' ) ) {
6695 return false;
6696 }
6697
6698 $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
6699 $templates = ! empty( $_POST['template_ids'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_ids'] ) ), true ) : array();
6700 $email = ! empty( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : '';
6701 $editor = isset( $_POST['editor'] ) ? sanitize_text_field( wp_unslash( $_POST['editor'] ) ) : '';
6702 $website_kit = isset( $_POST['website_kit'] ) ? sanitize_text_field( wp_unslash( $_POST['website_kit'] ) ) : '';
6703 $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
6704
6705 if ( empty( $templates ) || ! is_array( $templates ) ) {
6706 wp_send_json(
6707 array(
6708 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
6709 'description' => esc_html__( 'Invalid import: Check your details and try again.', 'wdesignkit' ),
6710 'success' => false,
6711 )
6712 );
6713 wp_die();
6714 }
6715
6716 $ids = array();
6717 foreach ( $templates as $temp ) {
6718 if ( ! empty( $temp['id'] ) ) {
6719 $ids[] = (int) $temp['id'];
6720 }
6721 }
6722
6723 if ( empty( $ids ) ) {
6724 wp_send_json(
6725 array(
6726 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
6727 'success' => false,
6728 )
6729 );
6730 wp_die();
6731 }
6732
6733 $token = $this->wdkit_login_user_token( $email );
6734
6735 $batch_args = array(
6736 'token' => $token,
6737 'template_ids' => $ids,
6738 'website_kit' => $website_kit,
6739 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
6740 );
6741
6742 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6743 $batch_args = wdkit_kit_import_with_site_identity( $batch_args );
6744 }
6745
6746 $response = WDesignKit_Data_Query::get_data( 'import_template_batch', $batch_args );
6747
6748 if ( is_wp_error( $response ) || empty( $response['success'] ) ) {
6749 wp_send_json(
6750 array(
6751 'message' => is_wp_error( $response ) ? $response->get_error_message() : ( $response['message'] ?? esc_html__( 'Batch import failed', 'wdesignkit' ) ),
6752 'success' => false,
6753 )
6754 );
6755 wp_die();
6756 }
6757
6758 $by_id = array();
6759 foreach ( (array) $response['templates'] as $tpl_result ) {
6760 if ( isset( $tpl_result['id'] ) ) {
6761 $by_id[ (int) $tpl_result['id'] ] = $tpl_result;
6762 }
6763 }
6764
6765 $results = array();
6766 foreach ( $templates as $temp ) {
6767 $tid = ! empty( $temp['id'] ) ? (int) $temp['id'] : 0;
6768 $tpl_response = isset( $by_id[ $tid ] ) ? $by_id[ $tid ] : array(
6769 'success' => false,
6770 'message' => esc_html__( 'Template not found in batch response', 'wdesignkit' ),
6771 );
6772
6773 // import_temp_json() resolves `editor` per template from that template's own
6774 // post_builder (Get_temp_builer()), not one value for the whole kit — a kit can
6775 // mix builders. The client sends that already-resolved value as `_editor` on
6776 // each item; fall back to the request-level $editor if it's missing.
6777 $args = array(
6778 'editor' => ! empty( $temp['_editor'] ) ? sanitize_text_field( $temp['_editor'] ) : $editor,
6779 'custom_meta' => $custom_meta,
6780 );
6781
6782 $results[] = array(
6783 'id' => $tid,
6784 'success' => ! empty( $tpl_response['success'] ),
6785 'message' => $tpl_response['message'] ?? '',
6786 'data' => array(
6787 'response' => $tpl_response,
6788 'args' => $args,
6789 'id' => $tid,
6790 'temp_data' => $temp,
6791 ),
6792 );
6793 }
6794
6795 do_action( 'wdkit_template_imported', 'kit', sanitize_key( $builder ), count( $ids ) );
6796
6797 wp_send_json(
6798 array(
6799 'results' => $results,
6800 'success' => true,
6801 )
6802 );
6803 wp_die();
6804 }
6805
6806 /**
6807 * Hand the assembled site bundle to the client without importing it.
6808 *
6809 * The AI import path needs this: personalizing a template runs ~1,600 lines of browser
6810 * logic (replace_elementor_txt and the media/global chain) against site_obj.site_info,
6811 * which only exists in the browser. So an AI kit fetches the bundle, transforms every
6812 * template with the same proven code the per-template path uses, and posts the result
6813 * back to wkit_import_site_bundle(). A non-AI kit never calls this — it lets
6814 * wkit_import_site_bundle() fetch and build in one request, with no payload leaving
6815 * the server.
6816 *
6817 * @since 2.7.0
6818 */
6819 protected function wkit_fetch_site_bundle() {
6820
6821 $t_fetch = microtime( true );
6822 $bundle = $this->wdkit_fetch_site_bundle();
6823
6824 if ( is_wp_error( $bundle ) ) {
6825 wp_send_json(
6826 array(
6827 'message' => $bundle->get_error_message(),
6828 'description' => $bundle->get_error_message(),
6829 'success' => false,
6830 )
6831 );
6832 wp_die();
6833 }
6834
6835 // Widget manifest only. The non-AI path needs to know what to enable before it
6836 // imports, but has no use for the content itself — sending a multi-megabyte kit down
6837 // just to count widget names would cost more than the problem it solves. The bundle
6838 // stays parked in the cache wdkit_fetch_site_bundle() just filled, so the import call
6839 // that follows reuses it instead of assembling the kit a second time.
6840 if ( ! empty( $_POST['widgets_only'] ) ) {
6841 $manifest = self::wdkit_bundle_widget_manifest( is_array( $bundle ) ? $bundle : array() );
6842
6843 wp_send_json(
6844 array(
6845 'success' => true,
6846 'widgets' => $manifest['widgets'],
6847 'extensions' => $manifest['extensions'],
6848 'timing_ms' => array(
6849 'cloud_assemble' => isset( $bundle['cloud_assemble_ms'] ) ? (int) $bundle['cloud_assemble_ms'] : 0,
6850 'wp_total' => (int) round( ( microtime( true ) - $t_fetch ) * 1000 ),
6851 ),
6852 )
6853 );
6854 wp_die();
6855 }
6856
6857 if ( is_array( $bundle ) ) {
6858 unset( $bundle['cache_key'] );
6859
6860 $bundle['timing_ms'] = array(
6861 'cloud_assemble' => isset( $bundle['cloud_assemble_ms'] ) ? (int) $bundle['cloud_assemble_ms'] : 0,
6862 'wp_total' => (int) round( ( microtime( true ) - $t_fetch ) * 1000 ),
6863 'payload_kb' => (int) round( strlen( (string) wp_json_encode( $bundle ) ) / 1024 ),
6864 );
6865 }
6866
6867 wp_send_json( $bundle );
6868 wp_die();
6869 }
6870
6871 /**
6872 * Ingest a pre-assembled kit site bundle in one request.
6873 *
6874 * The cloud half is GenerateKitSiteBundle(): it resolves every template in the kit,
6875 * splits them into pages/sections, and pre-merges the kit's The Plus globals into one
6876 * object. This side builds all of it inside a single PHP request, in place of the
6877 * historical one-AJAX-call-per-template walk.
6878 *
6879 * Per-template insertion is NOT reimplemented here — wdkit_bundle_insert_item() below is
6880 * a port of import_page_section_content(), the canonical shipped import path: Elementor
6881 * templates go through \Elementor\Plugin::$instance->documents->create() plus
6882 * $document->save() rather than hand-written meta, Gutenberg templates through the Nexter
6883 * block processor, and images through wdkit_media_import() under the same $defer_media
6884 * contract. All this method removes is the HTTP round trip that used to sit around each
6885 * one; every legacy route and handler is left untouched.
6886 *
6887 * @since 2.7.0
6888 */
6889 protected function wkit_import_site_bundle() {
6890 $response = $this->wdkit_import_site_bundle_data();
6891
6892 wp_send_json( $response );
6893 wp_die();
6894 }
6895
6896 /**
6897 * The kit importer itself, reachable without a request.
6898 *
6899 * Extracted from the AJAX action above so the PHP import runner imports through THIS
6900 * engine rather than carrying a second one.
6901 *
6902 * ── Why this extraction is the whole point of the merge ─────────────────────
6903 *
6904 * The runner grew its own content pipeline: one cloud round trip per template, its own
6905 * media handling, its own page store writing `_elementor_data` by hand. Measured on a
6906 * 15-template kit that came to 71-139s against ~8s here, and it also skipped things
6907 * this path does - Elementor's own documents->create()/save(), unique element ids,
6908 * server-side block CSS, the theme-builder remap, the nav menu build. Two pipelines
6909 * meant two sets of behaviour to keep in step, and only one of them was fast.
6910 *
6911 * So the runner now hands its (optionally AI-merged) bundle straight to this method.
6912 * The browser flow reaches the identical code through the action above, so "the fast
6913 * flow" and "the runner" are the same importer, and speed cannot drift between them.
6914 *
6915 * @since 2.7.2
6916 *
6917 * @param array|null $request Request fields to read instead of `$_POST`. The runner
6918 * passes its own array - normally carrying `bundle`
6919 * directly, already fetched and AI-merged, which is the
6920 * same entry tests and WP-CLI have always used.
6921 * @return array The response the AJAX action emits.
6922 */
6923 public function wdkit_import_site_bundle_data( $request = null ) {
6924
6925 // Elementor sideloads every image a page references from inside this request, and a
6926 // single oversized source image decodes to more than the whole memory limit. Same
6927 // guard import_page_section_content() takes, for the same reason.
6928 $this->wdkit_guard_oversized_images();
6929
6930 // Kit-level millisecond costs for this request, returned in the response.
6931 $kit_timing = array();
6932 $t_request = microtime( true );
6933
6934 // A whole kit in one request, where import_page_section_content() handled one
6935 // template in 120s. Harmless no-op where set_time_limit() is disabled.
6936 if ( function_exists( 'set_time_limit' ) ) {
6937 @set_time_limit( 300 );
6938 }
6939
6940 // Whether the fields came off the wire. Only then is the content slashed, and only
6941 // then may it be unslashed: running wp_unslash() over a bundle a PHP caller built
6942 // would strip the one level of escaping block markup depends on - the
6943 // `var(--tpgb-C7)` global references - and every global colour on every
6944 // page would silently stop resolving.
6945 $from_request = ! is_array( $request );
6946
6947 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the AJAX router verified the nonce before dispatching; the runner supplies its own array.
6948 $request = $from_request ? $_POST : $request;
6949
6950 $raw_bundle = isset( $request['bundle'] ) ? $request['bundle'] : '';
6951
6952 if ( $from_request ) {
6953 $raw_bundle = wp_unslash( $raw_bundle );
6954 }
6955
6956 $bundle = is_array( $raw_bundle ) ? $raw_bundle : json_decode( $raw_bundle, true );
6957
6958 // Normal path: the client sends only the kit's template ids and the bundle is fetched
6959 // here. Sending it down to the browser and straight back up would double the transfer
6960 // and put a multi-megabyte kit up against post_max_size on the return trip, so the
6961 // assembled JSON never leaves the server. Passing `bundle` directly still works and is
6962 // what the import can be driven with directly (tests, WP-CLI, other callers).
6963 if ( ( empty( $bundle ) || ! is_array( $bundle ) ) && ! empty( $request['template_ids'] ) ) {
6964 // Normally a cache hit: the widgets_only call immediately before this one already
6965 // assembled the kit, so this reuses it rather than paying the cloud round trip
6966 // again. A cold call (no preceding fetch, or the cache expired) still assembles.
6967 $bundle = $this->wdkit_fetch_site_bundle( $request, isset( $request['token'] ) ? (string) $request['token'] : '' );
6968
6969 // Error check FIRST. This used to sit below the cache-key cleanup, which indexes
6970 // $bundle as an array - and on the failure path $bundle is a WP_Error object, so
6971 // the cleanup fataled with "Cannot use object of type WP_Error as array" and took
6972 // the whole chunk request down. That matters far beyond one lost error message:
6973 // a chunk that dies never returns its `imported` entries, so the client never
6974 // seeds post_ids with the demo-id -> local-id mapping, and update_section_id()
6975 // then silently skips remapping every dynamic widget (tp-switcher, tp-tabs-tours,
6976 // tp-accordion...). The final chunk dying also means wdkit_bundle_build_menu()
6977 // never runs, so the kit imports with no navigation menu.
6978 if ( is_wp_error( $bundle ) ) {
6979 return array(
6980 'message' => $bundle->get_error_message(),
6981 'description' => $bundle->get_error_message(),
6982 'success' => false,
6983 );
6984 }
6985
6986 // Consumed. Holding a whole kit in the options table past the import it was
6987 // assembled for is dead weight, and a retry after a failure should get a fresh
6988 // copy rather than whatever this attempt was handed.
6989 if ( ! empty( $bundle['cache_key'] ) ) {
6990 delete_transient( $bundle['cache_key'] );
6991 }
6992 }
6993
6994 if ( empty( $bundle ) || ! is_array( $bundle ) ) {
6995 return array(
6996 'message' => esc_html__( 'Invalid bundle', 'wdesignkit' ),
6997 'description' => esc_html__( 'Invalid bundle payload: nothing to import.', 'wdesignkit' ),
6998 'success' => false,
6999 );
7000 }
7001
7002 $builder = ! empty( $bundle['builder'] ) ? sanitize_key( $bundle['builder'] ) : 'elementor';
7003 $pages = ( ! empty( $bundle['pages'] ) && is_array( $bundle['pages'] ) ) ? $bundle['pages'] : array();
7004 $sections = ( ! empty( $bundle['sections'] ) && is_array( $bundle['sections'] ) ) ? $bundle['sections'] : array();
7005 $nav_menu = ( ! empty( $bundle['nav_menu'] ) && is_array( $bundle['nav_menu'] ) ) ? $bundle['nav_menu'] : array();
7006 $defer_media = ! empty( $request['defer_media'] );
7007 $custom_meta = ! empty( $request['custom_meta'] );
7008 $wireframe = ! empty( $request['wirefram_import'] );
7009
7010 // Chunked ingestion, used by the AI path: the browser transforms every template and
7011 // posts them back a few at a time so a large kit never meets post_max_size in one
7012 // POST. Each chunk carries the same `session`; the last one sets `finalize`. With no
7013 // session at all (the non-AI single-call path) this behaves exactly as before —
7014 // one request that imports and finalizes.
7015 $session = isset( $request['session'] ) ? sanitize_key( $request['session'] ) : '';
7016 $is_final = empty( $session ) || ! empty( $request['finalize'] );
7017 $session_key = $session ? 'wdkit_sb_' . $session : '';
7018 $carried = $session_key ? get_transient( $session_key ) : false;
7019 $carried = is_array( $carried ) ? $carried : array();
7020
7021 if ( empty( $pages ) && empty( $sections ) ) {
7022 return array(
7023 'message' => esc_html__( 'Invalid bundle', 'wdesignkit' ),
7024 'description' => esc_html__( 'The bundle contained no templates to import.', 'wdesignkit' ),
7025 'success' => false,
7026 );
7027 }
7028
7029 // ── 1. Kit globals, once for the whole kit ──────────────────────────────────────
7030 // The cloud already merged every template's globals into this one object (same
7031 // dedupe-by-_id rule wdkit_merge_tp_globals() applies), so the presets land before
7032 // the first page renders instead of accumulating one template at a time.
7033 $site_settings = ( ! empty( $bundle['site_settings'] ) && is_array( $bundle['site_settings'] ) ) ? $bundle['site_settings'] : array();
7034
7035 // Only on the first chunk: the merge dedupes by `_id` so repeating it is harmless,
7036 // but it rewrites kit meta and regenerates the kit stylesheet every time.
7037 if ( empty( $carried ) && ! empty( $site_settings['tp_globals'] ) && is_array( $site_settings['tp_globals'] ) ) {
7038 $t_globals = microtime( true );
7039 $this->wdkit_merge_tp_globals(
7040 $site_settings['tp_globals'],
7041 ( ! empty( $site_settings['tp_global_refs'] ) && is_array( $site_settings['tp_global_refs'] ) )
7042 ? $site_settings['tp_global_refs']
7043 : array()
7044 );
7045 // This one also regenerates the kit stylesheet, so it is worth its own number.
7046 $kit_timing['globals_merge'] = (int) round( ( microtime( true ) - $t_globals ) * 1000 );
7047 }
7048
7049 // ── 2. Sections first, then pages ───────────────────────────────────────────────
7050 // Headers, footers and nav are what the pages reference, and a theme-builder
7051 // condition attached to a section should exist before the first page renders.
7052 $imported = array();
7053 $errors = array();
7054 $widgets_to_enable = array();
7055 $deferred_pages = array();
7056 $template_map = array();
7057
7058 $groups = array(
7059 'section' => $sections,
7060 'page' => $pages,
7061 );
7062
7063 foreach ( $groups as $group => $items ) {
7064 foreach ( $items as $item ) {
7065 if ( ! is_array( $item ) ) {
7066 continue;
7067 }
7068
7069 if ( $wireframe ) {
7070 $item = self::wdkit_wireframe_bundle_item( $item );
7071 }
7072
7073 $result = $this->wdkit_bundle_insert_item( $item, $builder, $defer_media, $custom_meta );
7074
7075 // One template failing (empty content, builder missing, insert error) is
7076 // recorded and skipped — it must not cost the rest of the site.
7077 if ( empty( $result['success'] ) ) {
7078 $errors[] = array(
7079 'id' => isset( $item['id'] ) ? sanitize_text_field( (string) $item['id'] ) : '',
7080 'title' => isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '',
7081 'message' => ! empty( $result['message'] ) ? $result['message'] : esc_html__( 'Template could not be imported.', 'wdesignkit' ),
7082 );
7083 continue;
7084 }
7085
7086 // A template that stored fewer widgets than it arrived with imported "successfully"
7087 // — the page exists and is linked — but its content is incomplete. Report it as
7088 // an error as well as an import so the row fails loudly instead of the kit
7089 // looking clean while a page renders empty. Deleting the page instead would
7090 // trade a visibly broken page for a silently missing one.
7091 if ( ! empty( $result['unregistered'] ) ) {
7092 $errors[] = array(
7093 'id' => isset( $item['id'] ) ? sanitize_text_field( (string) $item['id'] ) : '',
7094 'title' => isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '',
7095 'message' => sprintf(
7096 /* translators: %s: comma-separated list of widget type slugs. */
7097 esc_html__( 'Imported without these widgets, which are not registered on this site: %s', 'wdesignkit' ),
7098 implode( ', ', array_map( 'sanitize_text_field', $result['unregistered'] ) )
7099 ),
7100 );
7101 }
7102
7103 // The loader's progress rows are keyed by cloud template id, not post id.
7104 $result['group'] = $group;
7105 $result['template_id'] = isset( $item['id'] ) ? $item['id'] : '';
7106
7107 // Sum each phase across every template in this chunk.
7108 if ( ! empty( $result['timing_ms'] ) && is_array( $result['timing_ms'] ) ) {
7109 foreach ( $result['timing_ms'] as $phase => $ms ) {
7110 $key = 'sum_' . $phase;
7111 $kit_timing[ $key ] = ( isset( $kit_timing[ $key ] ) ? $kit_timing[ $key ] : 0 ) + (int) $ms;
7112 }
7113 }
7114 $imported[] = $result;
7115
7116 if ( ! empty( $item['id'] ) ) {
7117 $template_map[ (string) $item['id'] ] = $result['id'];
7118 }
7119
7120 if ( ! empty( $result['widget_list'] ) ) {
7121 $widgets_to_enable = array_unique( array_merge( $widgets_to_enable, $result['widget_list'] ) );
7122 }
7123
7124 // Same payload wkit_schedule_deferred_media_sync() receives from the JS side.
7125 // Built here because this request already knows every post id it created, so
7126 // the kit needs no extra round trip to register its media work.
7127 if ( $defer_media && ! empty( $result['image_urls'] ) ) {
7128 $deferred_pages[] = array(
7129 'post_id' => $result['id'],
7130 'builder' => $result['editor'],
7131 'image_urls' => $result['image_urls'],
7132 );
7133 }
7134 }
7135 }
7136
7137 // Fold in everything earlier chunks of this session already built, so the finalize
7138 // steps below see the whole kit and not just this chunk.
7139 if ( ! empty( $carried ) ) {
7140 $imported = array_merge( isset( $carried['imported'] ) ? $carried['imported'] : array(), $imported );
7141 $errors = array_merge( isset( $carried['errors'] ) ? $carried['errors'] : array(), $errors );
7142 $widgets_to_enable = array_unique( array_merge( isset( $carried['widgets'] ) ? $carried['widgets'] : array(), $widgets_to_enable ) );
7143 $deferred_pages = array_merge( isset( $carried['deferred'] ) ? $carried['deferred'] : array(), $deferred_pages );
7144 // `+` and NOT array_merge(): template ids are numeric keys, and array_merge()
7145 // renumbers those into 0,1,2... which silently destroys the id => post_id mapping
7146 // the nav menu resolves against. Left operand wins, so this chunk's entries take
7147 // precedence over an earlier chunk's.
7148 $carried_map = ( isset( $carried['template_map'] ) && is_array( $carried['template_map'] ) ) ? $carried['template_map'] : array();
7149 $template_map = $template_map + $carried_map;
7150 }
7151
7152 // Not the last chunk: bank what this one made and let the client send the next.
7153 if ( ! $is_final ) {
7154 set_transient(
7155 $session_key,
7156 array(
7157 'imported' => $imported,
7158 'errors' => $errors,
7159 'widgets' => array_values( $widgets_to_enable ),
7160 'deferred' => $deferred_pages,
7161 'template_map' => $template_map,
7162 ),
7163 15 * MINUTE_IN_SECONDS
7164 );
7165
7166 return array(
7167 'message' => esc_html__( 'Bundle chunk imported.', 'wdesignkit' ),
7168 'imported' => $imported,
7169 'errors' => $errors,
7170 'partial' => true,
7171 'success' => true,
7172 );
7173 }
7174
7175 if ( $session_key ) {
7176 delete_transient( $session_key );
7177 }
7178
7179 if ( empty( $imported ) ) {
7180 return array(
7181 'message' => esc_html__( 'Import failed', 'wdesignkit' ),
7182 'description' => esc_html__( 'No template in the bundle could be imported.', 'wdesignkit' ),
7183 'errors' => $errors,
7184 'success' => false,
7185 );
7186 }
7187
7188 // ── 3. Front page and shop page ─────────────────────────────────────────────────
7189 // An explicit flag from the bundle wins; the title heuristic is the same fallback
7190 // wdkit_handle_create_full_site() and update_site_setting() already rely on.
7191 $front_page_id = 0;
7192 $shop_page_id = 0;
7193
7194 foreach ( $imported as $entry ) {
7195 if ( 'page' !== $entry['group'] ) {
7196 continue;
7197 }
7198
7199 $title_lower = strtolower( (string) $entry['title'] );
7200
7201 if ( ! $front_page_id
7202 && ( ! empty( $entry['is_front_page'] )
7203 || false !== strpos( $title_lower, 'home' )
7204 || false !== strpos( $title_lower, 'landing' ) )
7205 ) {
7206 $front_page_id = $entry['id'];
7207 }
7208
7209 if ( ! $shop_page_id
7210 && ( ! empty( $entry['is_shop_page'] )
7211 || false !== strpos( $title_lower, 'shop' )
7212 || false !== strpos( $title_lower, 'store' ) )
7213 ) {
7214 $shop_page_id = $entry['id'];
7215 }
7216 }
7217
7218 if ( $front_page_id ) {
7219 update_option( 'show_on_front', 'page' );
7220 update_option( 'page_on_front', $front_page_id );
7221 }
7222
7223 if ( $shop_page_id && function_exists( 'wc_get_page_id' ) ) {
7224 update_option( 'woocommerce_shop_page_id', $shop_page_id );
7225 }
7226
7227 // ── 4. Primary menu ─────────────────────────────────────────────────────────────
7228 $t_menu = microtime( true );
7229 $menu_id = $this->wdkit_bundle_build_menu( $nav_menu, $imported, $template_map );
7230 $kit_timing['menu_build'] = (int) round( ( microtime( true ) - $t_menu ) * 1000 );
7231
7232 // ── 4.5. Remap demo navigation links to actual site URLs ────────────────────────
7233 $url_map = self::wdkit_build_nav_url_map( $imported );
7234
7235 if ( ! empty( $url_map ) ) {
7236 foreach ( $imported as $entry ) {
7237 $pid = (int) $entry['id'];
7238 $post = get_post( $pid );
7239 if ( ! $post ) {
7240 continue;
7241 }
7242
7243 $orig_content = $post->post_content;
7244 $new_content = strtr( $orig_content, $url_map );
7245 if ( $new_content !== $orig_content ) {
7246 // Same reason as the deferred pass: on multisite, and on any host that
7247 // defines DISALLOW_UNFILTERED_HTML, an administrator does not hold
7248 // `unfiltered_html` either, so this admin-driven write would also lose
7249 // <input>/<iframe>/<form> from content the import just produced.
7250 self::wdkit_write_post_content( $pid, $new_content );
7251
7252 // wdkit_bundle_insert_item() already built this page's CSS file once, right
7253 // after its own wp_insert_post() - against the pre-remap content, since this
7254 // step runs afterward, once every page in the kit exists and every other
7255 // page's real permalink is known. The block hashes that CSS was keyed to no
7256 // longer line up with what post_content now says (Gutenberg's own class
7257 // names embed a hash of the block's attributes), so without this the page
7258 // renders with whatever styling the two versions still happen to share and
7259 // silently drops the rest - the same class of bug the deferred-media cron
7260 // callback was fixed for earlier.
7261 self::wdkit_rebuild_block_css( $pid );
7262 }
7263
7264 $elem_data = get_post_meta( $pid, '_elementor_data', true );
7265 if ( ! empty( $elem_data ) && is_string( $elem_data ) ) {
7266 $new_elem = strtr( $elem_data, $url_map );
7267 if ( $new_elem !== $elem_data ) {
7268 // get_post_meta() hands back the unslashed JSON, and
7269 // update_post_meta() unslashes again on the way in - so writing it
7270 // straight back corrupts it. Same reason as the deferred cron's
7271 // write above.
7272 update_post_meta( $pid, '_elementor_data', wp_slash( $new_elem ) );
7273 }
7274 }
7275 }
7276 }
7277
7278 // ── 5. Every widget the kit uses, enabled in one call ───────────────────────────
7279 if ( ! empty( $widgets_to_enable ) && has_filter( 'tpae_enable_selected_widgets' ) ) {
7280 apply_filters(
7281 'tpae_enable_selected_widgets',
7282 array(
7283 'widgets' => array_values( $widgets_to_enable ),
7284 'extensions' => array(),
7285 )
7286 );
7287 }
7288
7289 // ── 6. Deferred image sideloading, off this request ─────────────────────────────
7290 $scheduled = 0;
7291 $stagger = 0;
7292
7293 foreach ( $deferred_pages as $deferred ) {
7294 // Stagger so every page's sideload loop does not fire in one wp-cron tick —
7295 // see the identical stagger in wkit_schedule_deferred_media_sync().
7296 wp_schedule_single_event(
7297 time() + 2 + $stagger,
7298 'wdkit_async_sideload_page_images',
7299 array( $deferred['post_id'], $deferred['builder'], $deferred['image_urls'] )
7300 );
7301
7302 $stagger += 2;
7303 ++$scheduled;
7304 }
7305
7306 // ── 7. One cache clear for the kit, not one per page ────────────────────────────
7307 $t_clear = microtime( true );
7308 if ( did_action( 'elementor/loaded' ) ) {
7309 \Elementor\Plugin::$instance->files_manager->clear_cache();
7310 }
7311
7312 if ( class_exists( 'Tpgb_Library' ) && method_exists( 'Tpgb_Library', 'remove_backend_dir_files' ) ) {
7313 Tpgb_Library()->remove_backend_dir_files();
7314 }
7315 $kit_timing['cache_clear'] = (int) round( ( microtime( true ) - $t_clear ) * 1000 );
7316
7317 do_action( 'wdkit_template_imported', 'kit', sanitize_key( $builder ), count( $imported ) );
7318
7319 if ( isset( $bundle['cloud_assemble_ms'] ) ) {
7320 $kit_timing['cloud_assemble'] = (int) $bundle['cloud_assemble_ms'];
7321 }
7322 $kit_timing['templates'] = count( $imported );
7323 $kit_timing['request_total'] = (int) round( ( microtime( true ) - $t_request ) * 1000 );
7324 $kit_timing['peak_memory_mb'] = round( memory_get_peak_usage( true ) / 1048576, 1 );
7325
7326 // Also to the log when debugging, so a staging run leaves a trace even if nobody had
7327 // the network tab open.
7328 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7329 error_log( 'WDKIT site bundle timing: ' . wp_json_encode( $kit_timing ) );
7330 }
7331
7332 return array(
7333 'message' => esc_html__( 'Successfully Imported.', 'wdesignkit' ),
7334 'description' => esc_html__( 'Yay! Your site has been successfully imported.', 'wdesignkit' ),
7335 'timing_ms' => $kit_timing,
7336 'imported' => $imported,
7337 'errors' => $errors,
7338 'front_page_id' => $front_page_id,
7339 'shop_page_id' => $shop_page_id,
7340 'menu_id' => $menu_id,
7341 'media_scheduled' => $scheduled,
7342 'site_link' => get_site_url(),
7343 'success' => true,
7344 );
7345 }
7346
7347 /**
7348 * Fetch the assembled site bundle for this kit from the cloud.
7349 *
7350 * Mirrors wkit_import_kit_bundle()'s argument handling: the token is resolved from the
7351 * stored cloud session for $email, never taken from the request. Returns the decoded
7352 * bundle, or a WP_Error the caller reports so the client can fall back to the legacy
7353 * per-template path (an older or self-hosted backend has no v2 route at all).
7354 *
7355 * @since 2.7.0
7356 *
7357 * @param array|null $request Request fields to read instead of `$_POST`. The AJAX
7358 * actions pass nothing and this behaves exactly as before;
7359 * the PHP import runner passes its own array, because it
7360 * runs on cron where there is no request to read. Same
7361 * shape either way, except `template_ids` may already be an
7362 * array rather than a JSON string.
7363 * @param string $token Cloud token, for callers that already resolved one. Only
7364 * ever supplied in PHP: a token is never read out of
7365 * `$request`, so a browser request cannot present its own.
7366 * @return array|WP_Error
7367 */
7368 private function wdkit_fetch_site_bundle( $request = null, $token = '' ) {
7369
7370 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the AJAX router verified the nonce before dispatching; the runner supplies its own array.
7371 $request = is_array( $request ) ? $request : $_POST;
7372
7373 $raw_templates = array();
7374
7375 if ( ! empty( $request['template_ids'] ) ) {
7376 $raw_templates = is_array( $request['template_ids'] )
7377 ? $request['template_ids']
7378 : json_decode( wp_unslash( $request['template_ids'] ), true );
7379 }
7380
7381 if ( empty( $raw_templates ) || ! is_array( $raw_templates ) ) {
7382 return new WP_Error( 'wdkit_bundle_no_templates', esc_html__( 'No templates supplied for the site bundle.', 'wdesignkit' ) );
7383 }
7384
7385 // Sanitize AFTER decoding — running sanitize_text_field() over the raw JSON strips
7386 // `<` and `>` and corrupts any template whose title contains them.
7387 $templates = array();
7388 foreach ( $raw_templates as $raw ) {
7389 if ( is_array( $raw ) ) {
7390 $id = ! empty( $raw['id'] ) ? (int) $raw['id'] : 0;
7391
7392 if ( ! $id ) {
7393 continue;
7394 }
7395
7396 $templates[] = array(
7397 'id' => $id,
7398 'title' => isset( $raw['title'] ) ? sanitize_text_field( $raw['title'] ) : '',
7399 'wp_post_type' => isset( $raw['wp_post_type'] ) ? sanitize_key( $raw['wp_post_type'] ) : 'page',
7400 '_editor' => isset( $raw['_editor'] ) ? sanitize_text_field( $raw['_editor'] ) : '',
7401 );
7402 } elseif ( (int) $raw ) {
7403 $templates[] = array( 'id' => (int) $raw );
7404 }
7405 }
7406
7407 if ( empty( $templates ) ) {
7408 return new WP_Error( 'wdkit_bundle_no_templates', esc_html__( 'No usable templates supplied for the site bundle.', 'wdesignkit' ) );
7409 }
7410
7411 $email = ! empty( $request['email'] ) ? strtolower( sanitize_email( wp_unslash( $request['email'] ) ) ) : '';
7412
7413 $args = array(
7414 'token' => ( is_string( $token ) && '' !== $token ) ? $token : $this->wdkit_login_user_token( $email ),
7415 'template_ids' => $templates,
7416 'website_kit' => isset( $request['website_kit'] ) ? sanitize_text_field( wp_unslash( $request['website_kit'] ) ) : '',
7417 'editor' => isset( $request['editor'] ) ? sanitize_text_field( wp_unslash( $request['editor'] ) ) : '',
7418 'builder' => isset( $request['builder'] ) ? sanitize_text_field( wp_unslash( $request['builder'] ) ) : '',
7419 // get_option() returns false when unset, which `?? ''` does not catch.
7420 'unique_id' => get_option( 'wdkit_unique_id', '' ),
7421 );
7422
7423 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
7424 $args = wdkit_kit_import_with_site_identity( $args );
7425 }
7426
7427 // Assembling a whole kit reads every template's file server-side, so this needs more
7428 // than get_data()'s 60s default.
7429 $t_cloud = microtime( true );
7430
7431 // The non-AI path asks for this bundle twice in a row - once to learn which widgets
7432 // the kit needs (so they can be enabled in their own request, because a widget
7433 // enabled mid-request is not registered until the next one), then again to import
7434 // it. Assembling it twice would pay the whole cloud round trip twice, so the first
7435 // call parks it here for the second. Keyed on the request that produced it, minus
7436 // the credentials, so a different kit or a different template set never reads this.
7437 $cache_key = 'wdkit_bundle_' . md5(
7438 (string) wp_json_encode(
7439 array_diff_key( $args, array( 'token' => '', 'poll_token' => '', 'site_url' => '' ) )
7440 )
7441 );
7442 $cached = get_transient( $cache_key );
7443
7444 if ( is_array( $cached ) && ! empty( $cached['success'] ) ) {
7445 $cached['cloud_assemble_ms'] = 0;
7446 $cached['from_cache'] = true;
7447 $cached['cache_key'] = $cache_key;
7448
7449 return $cached;
7450 }
7451
7452 $response = WDesignKit_Data_Query::get_data( 'v2/generate_kit_site_bundle', $args, array(), 180 );
7453 $cloud_ms = (int) round( ( microtime( true ) - $t_cloud ) * 1000 );
7454
7455 // The cloud assembly is its own round trip - credit checks, disk reads and tracking
7456 // for every template in the kit - so it gets its own number rather than hiding inside
7457 // whatever the caller measures.
7458 if ( is_array( $response ) ) {
7459 $response['cloud_assemble_ms'] = $cloud_ms;
7460 }
7461
7462 // Thirty minutes, not ten.
7463 //
7464 // Ten was sized for the browser's two-call sequence, where the gap is seconds. The
7465 // runner reads this copy on every stage slice instead, so the window it has to
7466 // survive is the WHOLE import — and an import is not always the ~1 minute it takes on
7467 // a healthy host. A slow host, a retry, or a person who stops to answer the door
7468 // pushes it past ten minutes, and then the cache silently expires mid-run: the next
7469 // slice re-assembles the entire kit in the cloud, which is the one round trip the
7470 // bundle exists to avoid, and it does it again on the slice after that.
7471 //
7472 // Still short enough that a kit edited upstream is not served stale for long, and
7473 // wkit_import_site_bundle() deletes it as soon as it has consumed it, so the ceiling
7474 // is only ever reached by a run that never finished.
7475 // Only the widgets_only call parks a copy. The AI path fetches the whole bundle and
7476 // posts the personalized content back, so it never re-reads this — caching there
7477 // would write a megabyte per import that nothing ever looks at.
7478 // The runner asks for the same parking, for the same reason: a stage request that
7479 // runs out of time hands the rest of the kit to the next one, and every one of
7480 // those must read this copy rather than re-assembling the kit in the cloud.
7481 if ( is_array( $response ) && ! empty( $response['success'] ) && ! empty( $request['widgets_only'] ) ) {
7482 set_transient( $cache_key, $response, 30 * MINUTE_IN_SECONDS );
7483
7484 $response['cache_key'] = $cache_key;
7485 }
7486
7487 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7488 error_log( 'WDKIT bundle cloud assemble: ' . $cloud_ms . 'ms' );
7489 }
7490
7491 if ( is_wp_error( $response ) ) {
7492 return $response;
7493 }
7494
7495 if ( empty( $response ) || ! is_array( $response ) || empty( $response['success'] ) ) {
7496 // Diagnostic: the client silently falls back to the slower per-template path when
7497 // this fails, so without this the only symptom is "the import behaved differently
7498 // this time". Log what the cloud actually said.
7499 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7500 error_log(
7501 'WDKIT bundle assemble FAILED: type=' . gettype( $response )
7502 . ' keys=' . ( is_array( $response ) ? implode( ',', array_keys( $response ) ) : '-' )
7503 . ' body=' . substr( (string) wp_json_encode( $response ), 0, 700 )
7504 );
7505 }
7506
7507 return new WP_Error(
7508 'wdkit_bundle_failed',
7509 ! empty( $response['message'] )
7510 ? $response['message']
7511 : esc_html__( 'Could not assemble the site bundle.', 'wdesignkit' )
7512 );
7513 }
7514
7515 return $response;
7516 }
7517
7518 /**
7519 * Insert one bundle template — page or section.
7520 *
7521 * A port of import_page_section_content()'s two editor branches with the AJAX plumbing
7522 * stripped out: same Elementor document creation, same media import, same block
7523 * processing, same custom-meta restore. It returns a result array instead of calling
7524 * wp_send_json(), so the caller can record one bad template and carry on with the kit.
7525 *
7526 * @since 2.7.0
7527 *
7528 * @param array $item One entry from the bundle's pages/sections list.
7529 * @param string $builder Kit-level builder, used when the item names no editor.
7530 * @param bool $defer_media Sideload this template's images later, off this request.
7531 * @param bool $custom_meta Restore the template's nxt-* post meta.
7532 * @return array Result array; `success` false carries a `message`.
7533 */
7534 /**
7535 * The widget and extension names an assembled bundle needs enabled.
7536 *
7537 * The server-side twin of the browser's collect_widget_list(): same extension key map, and
7538 * the same union across every template in the kit. It exists because the non-AI path never
7539 * routes template content through the browser, so nothing there can walk it.
7540 *
7541 * Extensions are matched on the settings key being present rather than truthy. Over-
7542 * collecting is harmless — the addon ignores any name outside its own catalogue, and an
7543 * extension enabled but unused costs nothing — whereas under-collecting silently loses the
7544 * styling it drives.
7545 *
7546 * @since 2.7.0
7547 *
7548 * @param array $bundle Assembled bundle.
7549 * @return array{widgets: array, extensions: array}
7550 */
7551 private static function wdkit_bundle_widget_manifest( array $bundle ) {
7552 $extension_keys = array(
7553 'sc_link_switch' => 'plus_section_column_link',
7554 'scwbf_options' => 'plus_glass_morphism',
7555 'seh_switch' => 'plus_equal_height',
7556 );
7557
7558 $widgets = array();
7559 $extensions = array();
7560
7561 foreach ( array( 'sections', 'pages' ) as $group ) {
7562 $items = ( ! empty( $bundle[ $group ] ) && is_array( $bundle[ $group ] ) ) ? $bundle[ $group ] : array();
7563
7564 foreach ( $items as $item ) {
7565 if ( ! is_array( $item ) || ! isset( $item['content'] ) ) {
7566 continue;
7567 }
7568
7569 $json = is_string( $item['content'] ) ? $item['content'] : (string) wp_json_encode( $item['content'] );
7570
7571 $widgets = array_merge( $widgets, self::wdkit_widget_types_in( $json ) );
7572
7573 foreach ( $extension_keys as $key => $extension ) {
7574 if ( false !== strpos( $json, '"' . $key . '"' ) ) {
7575 $extensions[] = $extension;
7576 }
7577 }
7578 }
7579 }
7580
7581 return array(
7582 'widgets' => array_values( array_unique( $widgets ) ),
7583 'extensions' => array_values( array_unique( $extensions ) ),
7584 );
7585 }
7586
7587 /**
7588 * Which of $types the addon has not actually recorded as enabled.
7589 *
7590 * This deliberately reads the persisted enable list rather than the live widget registry,
7591 * because within the enable request itself the registry is always stale: TPAE registers on
7592 * `elementor/widgets/register` (priority 100) from whatever `check_elements` held at that
7593 * moment, so a widget enabled later in the same request cannot appear until the NEXT one.
7594 * Measured against the real endpoint: first call reports the just-enabled widgets missing,
7595 * an identical second call reports none. Verifying against the registry here would
7596 * therefore fail every healthy import and trigger a pointless retry every time; the
7597 * persisted list is what the next request will build the registry from, so it is the
7598 * honest answer to "did the enable take".
7599 *
7600 * The registry check still matters — it just belongs in the request that saves, which is a
7601 * later request. See wdkit_unregistered_widget_types().
7602 *
7603 * @since 2.7.0
7604 *
7605 * @param array $types Widget type slugs as the client sends them (`tp-post-title`).
7606 * @return array The subset not recorded as enabled, in the same slug form.
7607 */
7608 private static function wdkit_not_enabled_widget_types( array $types ) {
7609 $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7610
7611 if ( empty( $types ) ) {
7612 return array();
7613 }
7614
7615 $options = get_option( 'theplus_options' );
7616 $enabled = ( is_array( $options ) && ! empty( $options['check_elements'] ) && is_array( $options['check_elements'] ) )
7617 ? $options['check_elements']
7618 : null;
7619
7620 // Unverifiable — the addon's settings row does not exist yet, which is itself a state
7621 // where the enable cannot have worked (tpae_enable_selected_widgets() writes nothing
7622 // when that option is empty, and still reports success). Report everything as not
7623 // enabled so the caller retries rather than trusting a success it cannot confirm.
7624 if ( null === $enabled ) {
7625 return $types;
7626 }
7627
7628 // The caller sends every widget a template uses, core Elementor ones included, and the
7629 // addon quietly ignores anything outside its own catalogue — so `heading`, `image` and
7630 // `divider` are never going to appear in its enabled list no matter how many times we
7631 // ask. Scoping to the catalogue is what keeps this from reporting a permanent failure
7632 // on every healthy import, which would make the retry fire every time.
7633 $managed = null;
7634
7635 // has_filter() first: it is only true once the addon has constructed its hooks
7636 // singleton, so get_instance() below is guaranteed to hand back the existing object
7637 // rather than build one — asking a question must not register another plugin's hooks.
7638 if ( has_filter( 'tpae_enable_selected_widgets' )
7639 && class_exists( 'Tpae_Hooks' )
7640 && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
7641 $hooks = Tpae_Hooks::get_instance();
7642
7643 if ( isset( $hooks->all_widgets ) && is_array( $hooks->all_widgets ) ) {
7644 $managed = $hooks->all_widgets;
7645 }
7646 }
7647
7648 // No catalogue to scope against. The settings row exists, so the enable had somewhere
7649 // to write; treat that as verified rather than inventing a failure we cannot support.
7650 // The save-time guard still catches anything that really did not register.
7651 if ( null === $managed ) {
7652 return array();
7653 }
7654
7655 $missing = array();
7656
7657 foreach ( $types as $type ) {
7658 // Same normalisation the addon applies before storing (`tp-post-title` -> `tp_post_title`).
7659 $slug = str_replace( '-', '_', $type );
7660
7661 if ( in_array( $slug, $managed, true ) && ! in_array( $slug, $enabled, true ) ) {
7662 $missing[] = $type;
7663 }
7664 }
7665
7666 return $missing;
7667 }
7668
7669 /**
7670 * Every widgetType named anywhere in a payload, whatever shape it arrives in.
7671 *
7672 * Scans the serialized form rather than walking the tree so it cannot drift from the
7673 * nesting rules Elementor actually uses (containers, inner sections, nested repeaters).
7674 *
7675 * @since 2.7.0
7676 *
7677 * @param mixed $payload Array, object or JSON string.
7678 * @return array Unique widget type slugs.
7679 */
7680 /**
7681 * Drop the redundant "Page" from an imported page's title.
7682 *
7683 * Catalog titles are written for browsing a list of kits ("About Us Page | Taj Bakery"),
7684 * where the word does useful work. On the site itself every entry in Pages is a page, so it
7685 * is noise on every row - and it rides along into the fallback slug too.
7686 *
7687 * Only stripped when it is a standalone word sitting immediately before the kit separator
7688 * or at the end of the title, so a kit or page whose name legitimately contains the word
7689 * ("Page Builder | …", "Landing Pages Bundle") keeps it. Pages only: a theme-builder
7690 * template called "Blog Single Page" is describing what it is, not repeating itself, and
7691 * those live in a different list entirely.
7692 *
7693 * @since 2.7.1
7694 *
7695 * @param string $title Catalog title.
7696 * @param string $post_type Destination post type.
7697 * @return string Title to save.
7698 */
7699 private static function wdkit_clean_page_title( $title, $post_type ) {
7700
7701 if ( 'page' !== $post_type || '' === (string) $title ) {
7702 return $title;
7703 }
7704
7705 $clean = preg_replace( '/\s*\bPages?\b(?=\s*(?:\||$))/i', '', (string) $title );
7706 $clean = trim( preg_replace( '/\s+/', ' ', (string) $clean ) );
7707
7708 // A title that was only ever the word itself ("Page", "Page | Kit") would come back
7709 // empty or orphaned from its separator; leave those exactly as the catalog wrote them.
7710 if ( '' === $clean || 0 === strpos( $clean, '|' ) ) {
7711 return $title;
7712 }
7713
7714 /* Drop the kit's name: "About Us | Zion Technology" -> "About Us".
7715 *
7716 * Catalog titles are "<page> | <kit>", and the kit half is the demo brand. Keeping it
7717 * named every imported page after someone else's business — it showed in the browser
7718 * tab and in the theme's page heading ("About Us | Zion Technology" on a freight
7719 * company's site). Both other ways a page can be created already cut here: the
7720 * browser's import_new_temp() does `title.split("|")[0]`, and
7721 * Wdkit_Page_Importer::store() does `explode( '|', … )[0]`. This path — the one-request
7722 * bundle insert, which is the one the runner actually uses — was the only one that
7723 * did not. */
7724 $parts = explode( '|', $clean );
7725 $name = trim( $parts[0] );
7726
7727 return '' !== $name ? $name : $clean;
7728 }
7729
7730 /**
7731 * Point a header's logo at the home page when the template shipped it unlinked.
7732 *
7733 * Kits are inconsistent here: some ship the logo image with `link_to: custom` and the
7734 * authoring site's home URL (which the nav remap then rewrites to this site's), and some
7735 * ship it with no link control at all. Elementor's image widget defaults to `link_to: none`,
7736 * so the second kind renders a bare <img> and the logo is not clickable - which is not a
7737 * choice any of these templates meant to make.
7738 *
7739 * Deliberately narrow, because "which image is the logo" is a guess everywhere else:
7740 *
7741 * - headers only, identified by the theme-builder section meta rather than the title, which
7742 * is translated and kit-specific;
7743 * - the core `image` widget only;
7744 * - and only when the widget has no link at all. A template that set one - to home or
7745 * anywhere else - is left exactly as its author wrote it.
7746 *
7747 * @since 2.7.1
7748 *
7749 * @param mixed $elements Elementor `elements` data.
7750 * @param mixed $custom_meta The template's custom_meta (meta_key => array(value)).
7751 * @return mixed Elements, with the logo linked where it was not.
7752 */
7753 private static function wdkit_link_header_logo( $elements, $custom_meta ) {
7754
7755 $meta = is_object( $custom_meta ) ? get_object_vars( $custom_meta ) : $custom_meta;
7756
7757 if ( ! is_array( $meta ) || empty( $meta['nxt-hooks-layout-sections'] ) ) {
7758 return $elements;
7759 }
7760
7761 $section = $meta['nxt-hooks-layout-sections'];
7762 $section = is_array( $section ) ? reset( $section ) : $section;
7763
7764 if ( 'header' !== $section ) {
7765 return $elements;
7766 }
7767
7768 $home = home_url( '/' );
7769
7770 $walk = function ( $node ) use ( &$walk, $home ) {
7771 if ( ! is_array( $node ) ) {
7772 return $node;
7773 }
7774
7775 if ( isset( $node['elType'], $node['widgetType'] )
7776 && 'widget' === $node['elType'] && 'image' === $node['widgetType'] ) {
7777
7778 $settings = isset( $node['settings'] ) && is_array( $node['settings'] ) ? $node['settings'] : array();
7779 $link_to = isset( $settings['link_to'] ) ? (string) $settings['link_to'] : '';
7780 $link_url = isset( $settings['link']['url'] ) ? (string) $settings['link']['url'] : '';
7781
7782 if ( '' === $link_url && ( '' === $link_to || 'none' === $link_to ) ) {
7783 $settings['link_to'] = 'custom';
7784 $settings['link'] = array(
7785 'url' => $home,
7786 'is_external' => '',
7787 'nofollow' => '',
7788 'custom_attributes' => '',
7789 );
7790
7791 $node['settings'] = $settings;
7792 }
7793 }
7794
7795 if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
7796 foreach ( $node['elements'] as $i => $child ) {
7797 $node['elements'][ $i ] = $walk( $child );
7798 }
7799 }
7800
7801 return $node;
7802 };
7803
7804 if ( ! is_array( $elements ) ) {
7805 return $elements;
7806 }
7807
7808 foreach ( $elements as $i => $node ) {
7809 $elements[ $i ] = $walk( $node );
7810 }
7811
7812 return $elements;
7813 }
7814
7815 private static function wdkit_widget_types_in( $payload ) {
7816 $json = is_string( $payload ) ? $payload : (string) wp_json_encode( $payload );
7817
7818 if ( ! is_string( $json ) || ! preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $json, $matches ) ) {
7819 return array();
7820 }
7821
7822 return array_values( array_unique( $matches[1] ) );
7823 }
7824
7825 /**
7826 * Which of $types Elementor cannot resolve right now.
7827 *
7828 * This is the check that makes an unregistered widget visible. Document::save() walks
7829 * every node through Elements_Manager::create_element_instance(), which returns null for a
7830 * widgetType the widgets manager does not know, and get_elements_raw_data() then
7831 * `continue`s past it — the container survives, the widget is dropped, and the save still
7832 * reports success. TPAE documents the same behaviour from the other side (see
7833 * theplus-include-widgets.php: "without a registered type Elementor deletes their saved
7834 * nodes on the next save"). So an unregistered type is silent data loss, not an error, and
7835 * nothing downstream will ever tell us it happened.
7836 *
7837 * @since 2.7.0
7838 *
7839 * @param array $types Widget type slugs.
7840 * @return array The subset that has no registered type.
7841 */
7842 private static function wdkit_unregistered_widget_types( array $types ) {
7843 $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7844
7845 if ( empty( $types ) ) {
7846 return array();
7847 }
7848
7849 // Nothing can be resolved without the widgets manager, so everything is missing —
7850 // which is the honest answer, and the one that stops a stripped save going out quietly.
7851 if ( ! did_action( 'elementor/loaded' ) || ! isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
7852 return $types;
7853 }
7854
7855 $known = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
7856 $known = is_array( $known ) ? array_keys( $known ) : array();
7857
7858 return array_values( array_diff( $types, $known ) );
7859 }
7860
7861 /**
7862 * Make sure the media importer class is loaded before it is used.
7863 *
7864 * The class was only required deep inside the two media walks, so any call reached before
7865 * one of those ran hit a fatal - "Class \"Wdkit_Import_Images\" not found". The per-item
7866 * deferred-list clear at the top of wdkit_bundle_insert_item() is exactly such a call: it
7867 * runs before any walk, by design, so that per-item isolation does not depend on which
7868 * branch happens to execute.
7869 *
7870 * @since 2.7.1
7871 *
7872 * @return void
7873 */
7874 private static function wdkit_require_import_images() {
7875 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
7876 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
7877 }
7878 }
7879
7880 /**
7881 * The four placeholder shapes a wireframe import swaps images for.
7882 *
7883 * Same files the browser uses, so a wireframe site looks identical whichever route
7884 * built it.
7885 *
7886 * @since 2.7.1
7887 *
7888 * @return array<string,string> ratio-name => absolute url.
7889 */
7890 private static function wdkit_wireframe_placeholders() {
7891
7892 $base = WDKIT_SERVER_API_URL . 'images/v2/plugin/template/';
7893
7894 return array(
7895 'hero' => $base . 'hero-placeholder.png',
7896 'long' => $base . 'long-placeholder.png',
7897 'square' => $base . 'square-placeholder.png',
7898 'vertical' => $base . 'vertical-placeholder.png',
7899 );
7900 }
7901
7902 /**
7903 * Swap a bundle item's images for wireframe placeholders, server-side.
7904 *
7905 * "Import as Wireframe" was only ever honoured on routes that hand the template content
7906 * to the browser - the AI path, and the legacy per-template path. A dummy import takes
7907 * the one-request route instead (see import_site_bundle() in import_loader.js), where the
7908 * server fetches and builds the whole kit and the content never reaches the browser at
7909 * all, so the client-side swap could not run and the toggle silently did nothing.
7910 *
7911 * It looked intermittent because the fast route only wins when it can: on a site whose
7912 * plugins were just installed it bails and the legacy path takes over, which DOES swap.
7913 * So wireframe worked on a fresh site and stopped working on a warm one - reproduced by
7914 * importing a kit with AI first, then re-importing it as dummy with wireframe on.
7915 *
7916 * Shape is chosen from the WordPress size suffix when the URL carries one
7917 * (`-700x700` -> square); everything else gets the hero 4:3, matching what the browser
7918 * falls back to when it cannot measure a source image. SVGs are deliberately untouched:
7919 * the browser's swap skips them too, so icons and logos survive on both routes.
7920 *
7921 * @since 2.7.1
7922 *
7923 * @param array $item Bundle item, whose `content` holds the template payload.
7924 * @return array The item with its raster image URLs pointed at placeholders.
7925 */
7926 private static function wdkit_wireframe_bundle_item( $item ) {
7927
7928 if ( empty( $item['content'] ) ) {
7929 return $item;
7930 }
7931
7932 $is_string = is_string( $item['content'] );
7933 $raw = $is_string ? $item['content'] : wp_json_encode( $item['content'] );
7934
7935 if ( ! is_string( $raw ) || '' === $raw ) {
7936 return $item;
7937 }
7938
7939 $shapes = self::wdkit_wireframe_placeholders();
7940
7941 // Both spellings, because a url sits raw in markup and slash-escaped inside the
7942 // builder's JSON - the same pairing wdkit_replace_url_all_encodings() handles.
7943 $flat = str_replace( '\\/', '/', $raw );
7944
7945 if ( ! preg_match_all( '#https?://[^"\'\s<>()\\\\]+\.(?:jpe?g|png|gif|webp|avif)#i', $flat, $matches ) ) {
7946 return $item;
7947 }
7948
7949 $map = array();
7950
7951 foreach ( array_unique( $matches[0] ) as $url ) {
7952 // Already a placeholder (a re-import of an already-wireframed kit) - leave it.
7953 if ( preg_match( '/(hero|long|square|vertical)-placeholder/', $url ) ) {
7954 continue;
7955 }
7956
7957 $shape = 'hero';
7958
7959 if ( preg_match( '/-(\d+)x(\d+)\.[A-Za-z0-9]+$/', $url, $dim ) ) {
7960 $w = (int) $dim[1];
7961 $h = (int) $dim[2];
7962
7963 if ( $w > 0 && $h > 0 ) {
7964 $ratio = $w / $h;
7965
7966 if ( $ratio >= 1.6 ) {
7967 $shape = 'long';
7968 } elseif ( $ratio <= 0.75 ) {
7969 $shape = 'vertical';
7970 } elseif ( $ratio >= 0.9 && $ratio <= 1.1 ) {
7971 $shape = 'square';
7972 }
7973 }
7974 }
7975
7976 $map[ $url ] = $shapes[ $shape ];
7977 }
7978
7979 if ( empty( $map ) ) {
7980 return $item;
7981 }
7982
7983 foreach ( $map as $from => $to ) {
7984 $raw = str_replace(
7985 array( $from, str_replace( '/', '\\/', $from ) ),
7986 array( $to, str_replace( '/', '\\/', $to ) ),
7987 $raw
7988 );
7989 }
7990
7991 $item['content'] = $is_string ? $raw : json_decode( $raw, true );
7992
7993 return $item;
7994 }
7995
7996 private function wdkit_bundle_insert_item( $item, $builder, $defer_media, $custom_meta ) {
7997
7998 // Start every item with an empty deferred list, unconditionally.
7999 //
8000 // Wdkit_Import_Images::$deferred_urls is static, and only the two `if ( $defer_media )`
8001 // unions below drain it. Anything recorded on a path that does not reach one of them
8002 // stays in the array and is handed to the NEXT item in the batch, which then schedules
8003 // a cron event that rewrites a different page's media. Video makes this reachable in
8004 // normal use: it defers regardless of $defer_media (see wdkit_Import_media()), so a
8005 // non-deferred item carrying one leaves a URL behind. Clearing here means per-item
8006 // isolation does not depend on which branch happens to run.
8007 self::wdkit_require_import_images();
8008 Wdkit_Import_Images::get_and_clear_deferred_urls();
8009
8010 $raw_content = isset( $item['content'] ) ? $item['content'] : '';
8011
8012 // The cloud ships each template's file byte-for-byte as the per-template route does,
8013 // so this decodes to the same object import_page_section_content() works with.
8014 $post_content = is_string( $raw_content )
8015 ? json_decode( $raw_content )
8016 : json_decode( wp_json_encode( $raw_content ) );
8017
8018 if ( ! is_object( $post_content ) ) {
8019 return array(
8020 'success' => false,
8021 'message' => esc_html__( 'Template content could not be read.', 'wdesignkit' ),
8022 );
8023 }
8024
8025 $editor = ! empty( $item['editor'] ) ? sanitize_text_field( $item['editor'] ) : $builder;
8026 $file_type = ! empty( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
8027
8028 // import_page_section_content() requires file_type to name the editor and falls
8029 // through to "Something went wrong" when it does not. Silently dropping a page is
8030 // worse in a whole-site import, so fall back to the editor the bundle resolved for
8031 // this template rather than bailing.
8032 if ( '' === $file_type ) {
8033 $file_type = ( 'gutenberg' === $editor ) ? 'wp_block' : 'elementor';
8034 }
8035
8036 $editor = ( 'wp_block' === $file_type ) ? 'gutenberg' : 'elementor';
8037
8038 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8039 if ( '' === $post_title ) {
8040 $post_title = isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '';
8041 }
8042
8043 // Before the slug is derived below, so the fallback slug loses the word too.
8044 $post_title = self::wdkit_clean_page_title(
8045 $post_title,
8046 ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page'
8047 );
8048
8049 $post_slug = isset( $post_content->slug ) ? sanitize_title( $post_content->slug ) : '';
8050
8051 // Falling straight back to the title gives every page a slug like
8052 // "about-us-page-zion-technology" - the catalog's own display title, kit name and
8053 // all, since that title is written for browsing a list of kits, not for a URL. The
8054 // page's own path on the demo site (already fetched for the nav-link remap above) is
8055 // a real slug someone chose for this exact page, so prefer that - except for the
8056 // pages whose whole demo path IS the kit's folder (the homepage, and section-only
8057 // entries like Header/Footer with no page of their own), where the segment left after
8058 // stripping the kit folder is empty and there is nothing better to use than the title.
8059 if ( '' === $post_slug && ! empty( $item['post_url'] ) ) {
8060 $path_segments = array_values( array_filter( explode( '/', (string) wp_parse_url( (string) $item['post_url'], PHP_URL_PATH ) ) ) );
8061
8062 if ( count( $path_segments ) > 1 ) {
8063 $post_slug = sanitize_title( end( $path_segments ) );
8064 }
8065 }
8066
8067 if ( '' === $post_slug ) {
8068 $post_slug = sanitize_title( $post_title );
8069 }
8070
8071 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
8072
8073 if ( empty( $content ) ) {
8074 return array(
8075 'success' => false,
8076 'message' => esc_html__( 'Content is Empty.', 'wdesignkit' ),
8077 );
8078 }
8079
8080 // Whitelist the destination post type against the plugin's own list instead of
8081 // trusting whatever the payload names.
8082 $enqueue_instance = new Wdkit_Enqueue();
8083 $allowed_types = $enqueue_instance->wdkit_get_post_type_list();
8084 $post_type = ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page';
8085
8086 if ( ! array_key_exists( $post_type, $allowed_types ) ) {
8087 $post_type = 'page';
8088 }
8089
8090 $widget_list = ! empty( $post_content->widget_list )
8091 ? json_decode( wp_json_encode( $post_content->widget_list ), true )
8092 : array();
8093
8094 $image_urls = ( ! empty( $item['image_urls'] ) && is_array( $item['image_urls'] ) )
8095 ? array_values( array_filter( array_map( 'esc_url_raw', $item['image_urls'] ) ) )
8096 : array();
8097
8098 // Same gate as the pages route - this half of the bundle payload is client-supplied too.
8099 $image_urls = self::wdkit_filter_fetchable_urls( $image_urls );
8100
8101 // Per-template millisecond costs, returned to the client so a staging run can be read
8102 // straight off the network tab instead of from a log file.
8103 $timing = array();
8104 $t_item = microtime( true );
8105 $inserted_id = 0;
8106
8107 // Only the Elementor branch can lose widgets this way; Gutenberg blocks are stored
8108 // as serialized markup and are not resolved through a type registry on save.
8109 $unregistered = array();
8110
8111 if ( 'gutenberg' === $editor ) {
8112
8113 $blocks = parse_blocks( stripslashes( $content ) );
8114
8115 $t_media = microtime( true );
8116 $blocks = $this->wdkit_media_import( $blocks, 'gutenberg', $defer_media );
8117 $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8118
8119 // The cloud's image_urls (from the per-template site_images DB field) is not a
8120 // reliable manifest - most templates never have it populated, so the background
8121 // sideload pass would otherwise never get scheduled and these images would stay
8122 // pointed at the source CDN forever. wdkit_Import_media() already recorded every
8123 // URL it deferred while walking $blocks above; union that in here instead of
8124 // depending on the cloud field alone.
8125 if ( $defer_media ) {
8126 $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8127 $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8128 }
8129
8130 if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
8131 $t_proc = microtime( true );
8132 $processor = new WDKIT_Nexter_Block_Processor();
8133 $blocks = $processor->run( $blocks );
8134 $timing['block_processor'] = (int) round( ( microtime( true ) - $t_proc ) * 1000 );
8135 }
8136
8137 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
8138
8139 $t_insert = microtime( true );
8140 $inserted_id = wp_insert_post(
8141 array(
8142 'post_status' => 'publish',
8143 'post_type' => $post_type,
8144 'post_title' => $post_title,
8145 'post_name' => $post_slug,
8146 'post_content' => $content,
8147 )
8148 );
8149
8150 $timing['post_insert'] = (int) round( ( microtime( true ) - $t_insert ) * 1000 );
8151
8152 if ( is_wp_error( $inserted_id ) ) {
8153 return array(
8154 'success' => false,
8155 'message' => $inserted_id->get_error_message(),
8156 );
8157 }
8158
8159 $t_css = microtime( true );
8160 self::wdkit_rebuild_block_css( $inserted_id );
8161 $timing['block_css'] = (int) round( ( microtime( true ) - $t_css ) * 1000 );
8162
8163 } else {
8164
8165 if ( ! did_action( 'elementor/loaded' ) ) {
8166 return array(
8167 'success' => false,
8168 'message' => esc_html__( 'Relevant Page Builder not installed or activated', 'wdesignkit' ),
8169 );
8170 }
8171
8172 $content = $this->wdkit_content_remover( $content );
8173
8174 $post_attributes = array(
8175 'post_title' => $post_title,
8176 'post_type' => $post_type,
8177 'post_status' => 'publish',
8178 'post_name' => $post_slug,
8179 );
8180
8181 // documents->create() is what makes this a real Elementor document — it owns
8182 // _elementor_data, _elementor_edit_mode, _elementor_version and the template
8183 // type. Writing that meta by hand is exactly what leaves a page opening as raw
8184 // JSON in the Text Editor, so the canonical path never does.
8185 $t_create = microtime( true );
8186 if ( 'elementor_library' === $post_type ) {
8187 $el_type = ! empty( $post_content->el_type ) ? sanitize_text_field( $post_content->el_type ) : 'page';
8188 $new_document = \Elementor\Plugin::$instance->documents->create( $el_type, $post_attributes );
8189 } else {
8190 $new_document = \Elementor\Plugin::$instance->documents->create( $post_type, $post_attributes );
8191 }
8192 $timing['document_create'] = (int) round( ( microtime( true ) - $t_create ) * 1000 );
8193
8194 if ( is_wp_error( $new_document ) ) {
8195 return array(
8196 'success' => false,
8197 'message' => $new_document->get_error_message(),
8198 );
8199 }
8200
8201 $settings = ! empty( $post_content->settings )
8202 ? json_decode( wp_json_encode( $post_content->settings ), true )
8203 : array();
8204
8205 $elements = wp_json_encode( $content );
8206
8207 $t_media = microtime( true );
8208 $elements = $this->wdkit_media_import( $elements, 'elementor', $defer_media );
8209 $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8210
8211 // Same union the Gutenberg branch above does, and for the same reason: the cloud's
8212 // image_urls is a manifest of the template's *images*, so anything the walk itself
8213 // discovered - a video especially, which nothing else lists - is only ever going to
8214 // reach the background pass if it is added here. Without this, wdkit_media_import()
8215 // recorded those URLs and the schedule then ignored them, leaving the reference
8216 // pointing at the authoring site for good.
8217 if ( $defer_media ) {
8218 $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8219 $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8220 }
8221
8222 // A header whose logo the template left unlinked gets one to the home page, before
8223 // the save rather than as a second write.
8224 $elements = self::wdkit_link_header_logo( $elements, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
8225
8226 // Read the registry as late as possible — right before the save that consumes it.
8227 // Anything named here is about to be dropped on the floor by Document::save()
8228 // (see wdkit_unregistered_widget_types()), and this is the only moment the
8229 // information still exists: afterwards the row simply looks like a page that was
8230 // authored without those widgets.
8231 $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8232
8233 if ( ! empty( $unregistered ) ) {
8234 self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8235 $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8236 }
8237
8238 $t_save = microtime( true );
8239 $new_document->save(
8240 array(
8241 'elements' => $elements,
8242 'settings' => ! empty( $settings ) ? $settings : array(),
8243 )
8244 );
8245 $timing['document_save'] = (int) round( ( microtime( true ) - $t_save ) * 1000 );
8246
8247 $inserted_id = $new_document->get_main_id();
8248 }
8249
8250 if ( empty( $inserted_id ) ) {
8251 return array(
8252 'success' => false,
8253 'message' => esc_html__( 'Something went wrong', 'wdesignkit' ),
8254 );
8255 }
8256
8257 // nxt-* meta: theme-builder display conditions and similar. allowed_classes => false
8258 // because the value comes from the imported kit body, where a serialized object could
8259 // fire a POP gadget chain in any loaded plugin or theme (CWE-502) — see the identical
8260 // restore in import_page_section_content().
8261 if ( $custom_meta && ! empty( $post_content->custom_meta ) ) {
8262 $meta_list = json_decode( wp_json_encode( $post_content->custom_meta ), true );
8263
8264 if ( is_array( $meta_list ) ) {
8265 foreach ( $meta_list as $meta_key => $meta_val ) {
8266 if ( ! isset( $meta_val[0] ) ) {
8267 continue;
8268 }
8269
8270 $value = $meta_val[0];
8271
8272 if ( is_string( $value ) && is_serialized( $value ) ) {
8273 $value = unserialize( $value, array( 'allowed_classes' => false ) );
8274 }
8275
8276 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
8277 add_post_meta( $inserted_id, $meta_key, $value );
8278 }
8279 }
8280 }
8281 }
8282
8283 clean_post_cache( $inserted_id );
8284
8285 // Diagnostic: how many widgets arrived for this template versus how many actually
8286 // landed in the row. A template that comes in with content and stores none is a
8287 // silent data loss - the page still imports, reports success, and renders empty.
8288 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
8289 $in_widgets = preg_match_all( '/"widgetType":/', (string) wp_json_encode( $post_content ) );
8290 $stored_raw = get_post_meta( $inserted_id, '_elementor_data', true );
8291 $stored_widgets = preg_match_all( '/"widgetType":/', is_string( $stored_raw ) ? $stored_raw : (string) wp_json_encode( $stored_raw ) );
8292
8293 error_log(
8294 sprintf(
8295 'WDKIT insert: cloud=%s post=%d editor=%s title=%s widgets_in=%d widgets_stored=%d%s',
8296 isset( $item['id'] ) ? $item['id'] : '?',
8297 (int) $inserted_id,
8298 $editor,
8299 $post_title,
8300 (int) $in_widgets,
8301 (int) $stored_widgets,
8302 ( $in_widgets > 0 && 0 === (int) $stored_widgets ) ? ' <-- LOST' : ''
8303 )
8304 );
8305 }
8306
8307 $timing['custom_meta'] = isset( $timing['custom_meta'] ) ? $timing['custom_meta'] : 0;
8308 $timing['item_total'] = (int) round( ( microtime( true ) - $t_item ) * 1000 );
8309
8310 // Deliberately not behind WP_DEBUG. The page imported, reported success and renders —
8311 // it is simply missing widgets nobody asked it to drop, so a log line the site owner
8312 // never enabled is the difference between a caught regression and a silent one.
8313 if ( ! empty( $unregistered ) ) {
8314 error_log(
8315 sprintf(
8316 'WDKIT: %s (post %d) was saved without %d widget type(s) Elementor could not resolve: %s',
8317 $post_title,
8318 (int) $inserted_id,
8319 count( $unregistered ),
8320 implode( ', ', $unregistered )
8321 )
8322 );
8323 }
8324
8325 return array(
8326 'success' => true,
8327 'timing_ms' => $timing,
8328 // Empty on every healthy import. Non-empty means this template was stored with
8329 // content missing — wkit_import_site_bundle() turns it into a reported error.
8330 'unregistered' => ! empty( $unregistered ) ? $unregistered : array(),
8331 'id' => (int) $inserted_id,
8332 // The template's id on the source site. wdkit_nxt_thembuilder_update() needs it to
8333 // remap nxt_builder display conditions onto the ids this import just created.
8334 'old_page_id' => isset( $post_content->page_id ) ? $post_content->page_id : '',
8335 'title' => get_the_title( $inserted_id ),
8336 'edit_link' => get_edit_post_link( $inserted_id, 'internal' ),
8337 'view' => get_permalink( $inserted_id ),
8338 'editor' => $editor,
8339 'post_type' => $post_type,
8340 'widget_list' => is_array( $widget_list ) ? $widget_list : array(),
8341 'image_urls' => $image_urls,
8342 'post_url' => isset( $item['post_url'] ) ? sanitize_text_field( (string) $item['post_url'] ) : '',
8343 'is_front_page' => ! empty( $item['is_front_page'] ),
8344 'is_shop_page' => ! empty( $item['is_shop_page'] ),
8345 );
8346 }
8347
8348 /**
8349 * Create — or extend — the primary menu from the bundle's pre-ordered nav list.
8350 *
8351 * Two things here that a naive rebuild gets wrong on a site that is not empty: pages
8352 * already in the menu are skipped, so re-importing a kit does not stack duplicate items;
8353 * and only theme menu locations the site has left EMPTY get filled, so an existing
8354 * menu assignment is never replaced by the imported one.
8355 *
8356 * @since 2.7.0
8357 *
8358 * @param array $nav_menu Bundle nav entries: template_id, title, position.
8359 * @param array $imported Results from wdkit_bundle_insert_item().
8360 * @param array $template_map Cloud template id => inserted post id.
8361 * @return int Menu term id, or 0 when no menu was touched.
8362 */
8363 private function wdkit_bundle_build_menu( $nav_menu, $imported, $template_map ) {
8364
8365 // Older bundles (and any kit whose pages are all untitled) carry no nav list — fall
8366 // back to the pages this request created, in insertion order.
8367 if ( empty( $nav_menu ) ) {
8368 foreach ( $imported as $entry ) {
8369 if ( 'page' === $entry['group'] && 'page' === $entry['post_type'] ) {
8370 $nav_menu[] = array(
8371 'post_id' => $entry['id'],
8372 'title' => $entry['title'],
8373 );
8374 }
8375 }
8376 }
8377
8378 if ( empty( $nav_menu ) ) {
8379 return 0;
8380 }
8381
8382 // Deliberately untranslated: this string is the menu's identity across re-imports,
8383 // so it must not change with the admin locale.
8384 $menu_name = 'Primary Menu';
8385 $menu_obj = wp_get_nav_menu_object( $menu_name );
8386 $menu_id = $menu_obj ? (int) $menu_obj->term_id : wp_create_nav_menu( $menu_name );
8387
8388 if ( is_wp_error( $menu_id ) || empty( $menu_id ) ) {
8389 return 0;
8390 }
8391
8392 $existing = wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'any' ) );
8393 $existing = is_array( $existing ) ? $existing : array();
8394
8395 $already = array();
8396 foreach ( $existing as $menu_item ) {
8397 if ( ! empty( $menu_item->object_id ) ) {
8398 $already[ (int) $menu_item->object_id ] = true;
8399 }
8400 }
8401
8402 $position = count( $existing );
8403
8404 foreach ( $nav_menu as $nav_item ) {
8405 $post_id = 0;
8406
8407 if ( ! empty( $nav_item['post_id'] ) ) {
8408 $post_id = (int) $nav_item['post_id'];
8409 } elseif ( ! empty( $nav_item['template_id'] ) && isset( $template_map[ (string) $nav_item['template_id'] ] ) ) {
8410 $post_id = (int) $template_map[ (string) $nav_item['template_id'] ];
8411 }
8412
8413 if ( ! $post_id || isset( $already[ $post_id ] ) ) {
8414 continue;
8415 }
8416
8417 ++$position;
8418
8419 wp_update_nav_menu_item(
8420 $menu_id,
8421 0,
8422 array(
8423 'menu-item-title' => ! empty( $nav_item['title'] )
8424 ? sanitize_text_field( (string) $nav_item['title'] )
8425 : get_the_title( $post_id ),
8426 'menu-item-object-id' => $post_id,
8427 'menu-item-object' => get_post_type( $post_id ),
8428 'menu-item-type' => 'post_type',
8429 'menu-item-status' => 'publish',
8430 'menu-item-position' => $position,
8431 )
8432 );
8433
8434 $already[ $post_id ] = true;
8435 }
8436
8437 // Only ever fill a location the active theme actually registers and the site has
8438 // left empty. The old hardcoded primary/main-menu/header-menu write clobbered live
8439 // menu assignments on any site that already had them.
8440 $registered = function_exists( 'get_registered_nav_menus' ) ? get_registered_nav_menus() : array();
8441 $locations = get_theme_mod( 'nav_menu_locations', array() );
8442 $locations = is_array( $locations ) ? $locations : array();
8443 $changed = false;
8444
8445 foreach ( array_keys( $registered ) as $location ) {
8446 if ( empty( $locations[ $location ] ) ) {
8447 $locations[ $location ] = $menu_id;
8448 $changed = true;
8449 }
8450 }
8451
8452 if ( $changed ) {
8453 set_theme_mod( 'nav_menu_locations', $locations );
8454 }
8455
8456 return (int) $menu_id;
8457 }
8458
8459 public function wdkit_enable_template_widgets() {
8460 $widget_list = ! empty( $_POST['widget_list'] ) ? json_decode( wp_unslash( $_POST['widget_list'] ), true ) : array();
8461 $extensions_list = ! empty( $_POST['extensions_list'] ) ? json_decode( wp_unslash( $_POST['extensions_list'] ), true ) : array();
8462
8463 $res = $this->wdkit_enable_widgets_data( $widget_list, $extensions_list );
8464
8465 wp_send_json( $res );
8466 wp_die();
8467 }
8468
8469 /**
8470 * Enable the widgets and extensions a kit's content actually uses.
8471 *
8472 * Extracted from wdkit_enable_template_widgets() so the PHP import runner can reach it
8473 * without `$_POST`. The lists are derived from imported kit content by the caller — they
8474 * are never accepted from a remote payload.
8475 *
8476 * The AJAX action above is now a thin adapter over this and its response is unchanged.
8477 *
8478 * @since 2.6.5
8479 *
8480 * @param array $widget_list Widget names used by the kit.
8481 * @param array $extensions_list Extension names used by the kit.
8482 * @return array Response array, exactly as the AJAX action used to emit.
8483 */
8484 public function wdkit_enable_widgets_data( $widget_list = array(), $extensions_list = array() ) {
8485 $widget_list = is_array( $widget_list ) ? $widget_list : array();
8486 $extensions_list = is_array( $extensions_list ) ? $extensions_list : array();
8487
8488 if ( empty( $widget_list ) && empty( $extensions_list ) ) {
8489 return array(
8490 'massage' => __( 'Widget array not found', 'wdesignkit' ),
8491 'description' => __( 'Widget array not found', 'wdesignkit' ),
8492 'success' => false,
8493 );
8494 }
8495
8496 // The widget list is what the caller needs verified; the extensions ride along but
8497 // have no registry to check against.
8498 $requested = is_array( $widget_list ) ? $widget_list : array();
8499
8500 if ( ! has_filter( 'tpae_enable_selected_widgets' ) ) {
8501 if ( class_exists( 'Tpae_Hooks' ) && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
8502 Tpae_Hooks::get_instance();
8503 } elseif ( defined( 'L_THEPLUS_PATH' ) && file_exists( L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8504 require_once L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8505 } elseif ( defined( 'THEPLUS_PATH' ) && file_exists( THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8506 require_once THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8507 }
8508 }
8509
8510 // Ensure theplus_options exists so tpae_enable_selected_widgets does not bail silently
8511 // on a fresh install where the option row has not been created yet.
8512 $theplus_options = get_option( 'theplus_options', false );
8513 if ( empty( $theplus_options ) || ! is_array( $theplus_options ) ) {
8514 $theplus_options = array(
8515 'check_elements' => array(),
8516 'extras_elements' => array(),
8517 );
8518 update_option( 'theplus_options', $theplus_options );
8519 }
8520
8521 if ( has_filter( 'tpae_enable_selected_widgets' ) ) {
8522 $w_list = array(
8523 'widgets' => $widget_list,
8524 'extensions' => $extensions_list,
8525 );
8526
8527 $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
8528 } else {
8529 // Fallback: If filter is not loaded in this process (e.g. right after activation or CLI),
8530 // directly merge into theplus_options so subsequent requests and Elementor boot find them.
8531 $to_add = array();
8532 foreach ( $widget_list as $w ) {
8533 $to_add[] = str_replace( '-', '_', $w );
8534 }
8535 $ext_to_add = array();
8536 foreach ( $extensions_list as $e ) {
8537 $ext_to_add[] = str_replace( '-', '_', $e );
8538 }
8539
8540 $theplus_options['check_elements'] = array_values( array_unique( array_merge( isset( $theplus_options['check_elements'] ) && is_array( $theplus_options['check_elements'] ) ? $theplus_options['check_elements'] : array(), $to_add ) ) );
8541 $theplus_options['extras_elements'] = array_values( array_unique( array_merge( isset( $theplus_options['extras_elements'] ) && is_array( $theplus_options['extras_elements'] ) ? $theplus_options['extras_elements'] : array(), $ext_to_add ) ) );
8542 update_option( 'theplus_options', $theplus_options );
8543
8544 $result = array( 'success' => true );
8545 }
8546
8547 // Register newly enabled widgets into Elementor\'s in-memory manager so Document::save()
8548 // in the current PHP execution does not silently drop them.
8549 if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
8550 try {
8551 $widgets_manager = \Elementor\Plugin::$instance->widgets_manager;
8552 if ( class_exists( '\TheplusAddons\L_Theplus_Widgets_Include' ) && method_exists( '\TheplusAddons\L_Theplus_Widgets_Include', 'get_instance' ) ) {
8553 \TheplusAddons\L_Theplus_Widgets_Include::get_instance()->add_widgets( $widgets_manager );
8554 }
8555 if ( class_exists( '\TheplusAddons\Theplus_Widgets_Include' ) && method_exists( '\TheplusAddons\Theplus_Widgets_Include', 'get_instance' ) ) {
8556 \TheplusAddons\Theplus_Widgets_Include::get_instance()->add_widgets( $widgets_manager );
8557 }
8558 } catch ( \Throwable $e ) {
8559 // Best-effort in-memory registration.
8560 }
8561 }
8562
8563 // What the caller actually needs to know. A 200 with success:true only says the filter
8564 // ran — tpae_enable_selected_widgets() reports success even when it wrote nothing
8565 // (it bails silently if the addon's own settings row is still empty). So answer from
8566 // the state it was supposed to write.
8567 $not_enabled = self::wdkit_not_enabled_widget_types( $requested );
8568
8569 if ( ! empty( $result['success'] ) ) {
8570 $res = array(
8571 'massage' => __( 'Enabled widgets successfully', 'wdesignkit' ),
8572 'description' => __( 'Used widgets have been enabled successfully', 'wdesignkit' ),
8573 'success' => true,
8574 );
8575 } else {
8576
8577 $message = isset( $result['message'] ) ? $result['message'] : __( 'Failed to enable widgets', 'wdesignkit' );
8578 $description = isset( $result['description'] ) ? $result['description'] : __( 'Failed to enable widgets', 'wdesignkit' );
8579
8580 $res = array(
8581 'massage' => $message,
8582 'description' => $description,
8583 'success' => false,
8584 );
8585 }
8586
8587 // Authoritative result of the enable, and the only field the caller should retry on.
8588 // Both callers need it: the browser importer retries on it, and the runner's
8589 // enable_kit_widgets() decides from it whether the content stage may start.
8590 $res['not_enabled'] = $not_enabled;
8591
8592 return $res;
8593 }
8594
8595 /**
8596 * Import single template and section from plugin only
8597 * */
8598 protected function wdkit_import_multi_template() {
8599 $args = $this->wdkit_parse_args( $_POST );
8600 $api_type = isset( $_POST['api_type'] ) ? sanitize_text_field( wp_unslash( $_POST['api_type'] ) ) : 'import_template';
8601
8602 if ( ! current_user_can( 'manage_options' ) ) {
8603 return false;
8604 }
8605
8606 if ( empty( $_POST['template_ids'] ) ) {
8607 $output = array(
8608 'data' => array(),
8609 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
8610 'description' => esc_html__( 'Invalid import: Check your details and try again.', 'wdesignkit' ),
8611 'success' => false,
8612 );
8613
8614 wp_send_json( $output );
8615 wp_die();
8616 }
8617
8618 if ( isset( $_POST['template_ids'] ) ) {
8619 $args['template_ids'] = ! empty( $_POST['template_ids'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_ids'] ) ), true ) : array();
8620 }
8621
8622 if ( isset( $_POST['page_section'] ) ) {
8623 $args['page_section'] = ! empty( $_POST['page_section'] ) ? sanitize_text_field( wp_unslash( $_POST['page_section'] ) ) : '';
8624 }
8625
8626 if ( isset( $_POST['select'] ) ) {
8627 $args['post_type'] = ! empty( $_POST['select'] ) ? sanitize_text_field( wp_unslash( $_POST['select'] ) ) : '';
8628 }
8629
8630 $args['custom_meta'] = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
8631
8632 if ( ! empty( $args['template_ids'] ) && ! empty( $args['page_section'] ) ) {
8633 $output = array();
8634 if ( ! empty( $args['template_ids']['id'] ) ) {
8635 $token = $this->wdkit_login_user_token( $args['email'] );
8636
8637 $temp_args = array(
8638 'token' => $token,
8639 'template_id' => $args['template_ids']['id'],
8640 'editor' => $args['editor'],
8641 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
8642 );
8643
8644 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
8645 $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
8646 }
8647
8648 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
8649
8650 if ( is_wp_error( $response ) ) {
8651 wp_send_json( array(
8652 'success' => false,
8653 'message' => $response->get_error_message(),
8654 ) );
8655 wp_die();
8656 }
8657
8658 if ( isset( $response['content'] ) && 'error' === $response['content'] ) {
8659 wp_send_json( $response );
8660 wp_die();
8661 }
8662
8663 $result = array(
8664 'response' => $response,
8665 'args' => $args,
8666 'id' => $args['template_ids'],
8667 'value' => $args['template_ids'],
8668 );
8669
8670 $output['message'] = $response['message'];
8671 $output['description'] = $response['description'];
8672 $output['data'] = $result;
8673 $output['success'] = true;
8674
8675 }
8676
8677 wp_send_json( $output );
8678 wp_die();
8679 }
8680 }
8681
8682 /**
8683 * It is Use for remove selected category from content.
8684 *
8685 * @since 2.0.5
8686 */
8687 public function wdkit_content_remover( &$data ) {
8688
8689 if ( is_array( $data ) ) {
8690
8691 foreach ( $data as $key => &$value ) {
8692 if ( $key === 'post_category' ) {
8693 $data[ $key ] = array();
8694 } else if ($key === 'include_products'){
8695 $data[ $key ] = "";
8696 } else {
8697 $this->wdkit_content_remover( $value );
8698 }
8699 }
8700 } elseif ( is_object( $data ) ) {
8701
8702 foreach ( $data as $key => &$value ) {
8703 if ( $key === 'post_category' ) {
8704 $data->$key = array();
8705 } else if ($key === 'include_products'){
8706 $data->$key = "";
8707 } else {
8708 $this->wdkit_content_remover( $value );
8709 }
8710 }
8711 }
8712
8713 return $data;
8714 }
8715
8716 protected function wkit_update_elementor_template(){
8717
8718 if ( isset( $_POST['data'] ) ) {
8719 $content = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ), true ) : '';
8720 }
8721
8722 if ( isset( $_POST['template_id'] ) ) {
8723 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
8724 }
8725
8726 $document = \Elementor\Plugin::$instance->documents->get($template_id);
8727
8728 // This saves content posted straight from the browser, which carries local image
8729 // URLs but still the source template's attachment IDs. Without repairing them the
8730 // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
8731 // container background images especially — resolve to nothing and render empty.
8732 $content = self::wdkit_repair_attachment_ids( $content );
8733
8734 $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $content ) );
8735 if ( ! empty( $unregistered ) ) {
8736 self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8737 }
8738
8739 $document->save([
8740 'elements' => $content
8741 ]);
8742 }
8743
8744 /**
8745 * Update the content of an already-created page.
8746 *
8747 * Used by the async ("Site Ready first") import path: pages are created up front with
8748 * their un-rewritten template content, then this writes the AI-rewritten content into
8749 * each page in the background. Elementor saves via the document API (same as
8750 * wkit_update_elementor_template); Gutenberg writes post_content directly.
8751 *
8752 * @since 2.6.2
8753 */
8754 protected function wdkit_update_page_content() {
8755 $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
8756 $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
8757
8758 if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
8759 return array(
8760 'success' => false,
8761 'message' => esc_html__( 'Invalid page or insufficient permission', 'wdesignkit' ),
8762 );
8763 }
8764
8765 if ( 'gutenberg' === $builder ) {
8766 // Do NOT run kses here: Gutenberg block delimiters are HTML comments
8767 // (<!-- wp:... -->) which kses strips. Mirror the create path, which stores
8768 // the block markup slashed and unfiltered (endpoint is manage_options-gated
8769 // and the content is plugin-generated).
8770 $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
8771
8772 // This content comes straight from the browser and still carries the template
8773 // site's media URLs and attachment IDs, so it has to go through the same pipeline
8774 // the create path uses. Without this the save simply undid the import: the files
8775 // were fetched, then overwritten by a copy still pointing at the source site.
8776 //
8777 // Re-running is cheap. Every URL already handled resolves from the source-hash
8778 // lookup, and media that is already local resolves straight from its URL, so no
8779 // image is fetched or stored twice.
8780 $content = $this->wdkit_relink_gutenberg_content( $content );
8781
8782 // See wdkit_write_post_content(): this endpoint replaces the whole post_content
8783 // with markup the import itself produced, so kses here strips legitimate block
8784 // markup rather than untrusted input. Kept inline instead of routed through that
8785 // helper because this call needs wp_slash() and the WP_Error return.
8786 $had_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
8787
8788 if ( $had_kses ) {
8789 kses_remove_filters();
8790 }
8791
8792 $result = wp_update_post(
8793 array(
8794 'ID' => $post_id,
8795 'post_content' => wp_slash( $content ),
8796 ),
8797 true
8798 );
8799
8800 if ( $had_kses ) {
8801 kses_init_filters();
8802 }
8803
8804 if ( is_wp_error( $result ) ) {
8805 return array(
8806 'success' => false,
8807 'message' => $result->get_error_message(),
8808 );
8809 }
8810
8811 self::wdkit_rebuild_block_css( $post_id );
8812 } else {
8813 $elements = isset( $_POST['content'] ) ? json_decode( wp_unslash( $_POST['content'] ), true ) : array();
8814
8815 if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
8816 return array(
8817 'success' => false,
8818 'message' => esc_html__( 'Elementor not available', 'wdesignkit' ),
8819 );
8820 }
8821
8822 $document = \Elementor\Plugin::$instance->documents->get( $post_id );
8823 if ( ! $document ) {
8824 return array(
8825 'success' => false,
8826 'message' => esc_html__( 'Elementor document not found', 'wdesignkit' ),
8827 );
8828 }
8829
8830 // Same as wkit_update_elementor_template(): browser-posted content keeps the
8831 // source template's attachment IDs, so repair them or this save undoes the
8832 // create-time fix and background images stop rendering.
8833 $elements = self::wdkit_repair_attachment_ids( $elements );
8834
8835 $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8836 if ( ! empty( $unregistered ) ) {
8837 self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8838 }
8839
8840 $document->save( array( 'elements' => $elements ) );
8841 }
8842
8843 return array(
8844 'success' => true,
8845 'message' => esc_html__( 'Page content updated', 'wdesignkit' ),
8846 );
8847 }
8848
8849 /**
8850 * Import single template and section from plugin only
8851 *
8852 * @param array $args store data.
8853 * @param array $template_id store data.
8854 * @param array $data store data.
8855 * @param array $temp_data store data.
8856 * */
8857 protected function import_page_section_content() {
8858
8859 // Elementor sideloads every image referenced by the page from inside this request.
8860 // A single oversized source image decodes to more than the whole memory limit, so
8861 // guard before any of that starts.
8862 $this->wdkit_guard_oversized_images();
8863
8864 // Sideloading images for image-heavy pages (wdkit_media_import → Imagick
8865 // thumbnail generation per image) can exceed the default 30s execution
8866 // limit and fatal the request mid-import. Give this single page import
8867 // more headroom; harmless no-op where set_time_limit() is disabled.
8868 if ( function_exists( 'set_time_limit' ) ) {
8869 @set_time_limit( 120 );
8870 }
8871
8872 if ( isset( $_POST['args'] ) ) {
8873 $args = ! empty( $_POST['args'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['args'] ) ), true ) : array();
8874 }
8875
8876 if ( isset( $_POST['temp_data'] ) ) {
8877 $temp_data = ! empty( $_POST['temp_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['temp_data'] ) ), true ) : array();
8878 }
8879
8880 if ( isset( $_POST['category_list'] ) ) {
8881 $category_list = ! empty( $_POST['category_list'] ) ? json_decode( wp_unslash( $_POST['category_list'] ), true ) : '';
8882 }
8883
8884 if ( isset( $_POST['tag_list'] ) ) {
8885 $tag_list = ! empty( $_POST['tag_list'] ) ? json_decode( wp_unslash( $_POST['tag_list'] ), true ) : '';
8886 }
8887
8888 if ( isset( $_POST['thumb_image'] ) ) {
8889 $thumb_image = ! empty( $_POST['thumb_image'] ) ? esc_url_raw( $_POST['thumb_image'] ) : '';
8890 // Capped once here covers both download_url( $thumb_image ) calls below (Elementor
8891 // and Gutenberg branches share this variable).
8892 $thumb_image = Wdkit_Image_Guard::cap_pexels_source( $thumb_image );
8893 }
8894
8895 if ( isset( $_POST['template_id'] ) ) {
8896 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
8897 }
8898
8899 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
8900
8901 // AI kit imports send this so the page saves with its source CDN image URLs still in
8902 // place - the JS side then registers it for wkit_schedule_deferred_media_sync() once
8903 // every page in the kit has been created, and the actual media_sideload_image() work
8904 // happens later, off this request. See wdkit_media_import()'s $defer_media branch.
8905 $defer_media = ! empty( $_POST['defer_media'] );
8906
8907 if ( isset( $_POST['data'] ) ) {
8908 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
8909 }
8910
8911 $enqueue_instance = new Wdkit_Enqueue();
8912 $get_post_type = $enqueue_instance->wdkit_get_post_type_list();
8913
8914 $post_type = ! empty( $temp_data['type'] ) ? sanitize_text_field( wp_unslash( $temp_data['type'] ) ) : 'page';
8915
8916 if ( 'section' === $post_type ) {
8917 $post_type = $temp_data['wp_post_type'];
8918 } else {
8919 $post_type = $temp_data['wp_post_type'];
8920 }
8921
8922 if ( ! array_key_exists( $post_type, $get_post_type ) ) {
8923 $post_type = 'page';
8924 }
8925
8926 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
8927 $post_content = $data;
8928 // Restore The Plus Addons' globals before the page is built, so the widgets'
8929 // tp_global_preset references resolve as soon as it renders. Done here rather
8930 // than in the save-template UI's confirmation dialog so that every import path
8931 // - the library, the abilities, the theme builder - gets it.
8932 if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
8933 $this->wdkit_merge_tp_globals(
8934 json_decode( wp_json_encode( $post_content->tp_globals ), true ),
8935 isset( $post_content->tp_global_refs )
8936 ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
8937 : array()
8938 );
8939 }
8940
8941 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8942
8943 // Same title cleanup the bundle path applies, so a kit imported through either
8944 // route lists identically in Pages.
8945 $post_title = self::wdkit_clean_page_title( $post_title, $post_type );
8946
8947 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
8948 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
8949 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
8950 $temp_con = '';
8951
8952 if ( 'gutenberg' === $args['editor'] || ( 'wdkit' === $args['editor'] && ! empty( $file_type ) && 'wp_block' === $file_type ) ) {
8953 if ( empty( $content ) ) {
8954 wp_send_json(
8955 array(
8956 'template_id' => $template_id,
8957 'message' => __( 'Content is Empty.', 'wdesignkit' ),
8958 )
8959 );
8960 wp_die();
8961 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
8962
8963 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
8964
8965 $blocks = parse_blocks( stripslashes( $content ) );
8966 $blocks = $this->wdkit_media_import( $blocks, $editor, $defer_media );
8967
8968 // wdkit_media_import() above just skipped every raster image in this section's
8969 // own content (the "Blog Detail" dummy template's own decorative images, not a
8970 // user-picked stock photo) — this is this method's only chance to learn which
8971 // URLs those were, so the deferred-media response field below is built from it
8972 // rather than left to the bundle-only image_urls the client already tracks.
8973 $deferred_image_urls = $defer_media
8974 ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
8975 : array();
8976
8977 $processor = new WDKIT_Nexter_Block_Processor();
8978 $blocks = $processor->run( $blocks );
8979
8980 $content = serialize_blocks( $blocks );
8981
8982 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
8983
8984 $inserted_post = wp_insert_post(
8985 array(
8986 'post_status' => 'publish',
8987 'post_type' => $post_type,
8988 'post_title' => $post_title,
8989 'post_name' => $post_slug,
8990 'post_content' => $content,
8991 )
8992 );
8993
8994 if ( ! is_wp_error( $inserted_post ) && $inserted_post ) {
8995 self::wdkit_rebuild_block_css( $inserted_post );
8996 }
8997
8998 if ( is_wp_error( $inserted_post ) ) {
8999 wp_send_json(
9000 array(
9001 'import_failed' => $inserted_post->get_error_message(),
9002 'code' => $inserted_post->get_error_code(),
9003 )
9004 );
9005 wp_die();
9006 }
9007
9008 // A picked stock image is already in the media library by the time the posts
9009 // are created, so thumb_image is usually a url on this site rather than a
9010 // remote one. That can never go down the download path - the external-url
9011 // guard rejects loopback addresses by design, which silently dropped the
9012 // featured image - so recognise the existing attachment and point at it.
9013 // Anything genuinely remote still takes the untouched branch below.
9014 $local_thumb_id = ! empty( $thumb_image ) ? self::wdkit_local_attachment_from_url( $thumb_image ) : 0;
9015
9016 if ( $local_thumb_id ) {
9017 set_post_thumbnail( $inserted_post, $local_thumb_id );
9018 } elseif ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
9019 // $featured_image_url = esc_url_raw( $thumb_image );
9020 $tmp = download_url( $thumb_image );
9021 if ( is_wp_error( $tmp ) ) {
9022 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
9023 } else {
9024 $file_array = array(
9025 'name' => wp_basename( $thumb_image ),
9026 'tmp_name' => $tmp,
9027 );
9028
9029 $image_id = media_handle_sideload( $file_array, $inserted_post );
9030
9031 if ( is_wp_error( $image_id ) ) {
9032 error_log( 'Image sideload failed: ' . esc_html( $image_id->get_error_message() ) );
9033 } else {
9034 set_post_thumbnail( $inserted_post, $image_id );
9035 }
9036
9037 @unlink( $tmp );
9038 }
9039 }
9040
9041 if ( ! empty( $category_list ) && is_array( $category_list ) ) {
9042 $category_ids = array_map( 'intval', $category_list );
9043 wp_set_post_terms( $inserted_post, $category_ids, 'category' );
9044 }
9045
9046 if ( ! empty( $tag_list ) && is_array( $tag_list ) ) {
9047 $tag_ids = array_map( 'intval', $tag_list );
9048 wp_set_post_terms( $inserted_post, $tag_ids, 'post_tag' );
9049 }
9050
9051 if ( ! empty( $args['custom_meta'] ) && 'true' == $args['custom_meta'] ) {
9052 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
9053 if ( ! empty( $custom_meta ) ) {
9054 foreach ( $custom_meta as $meta_key => $meta_val ) {
9055 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
9056 $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
9057 }
9058
9059 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
9060 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
9061 }
9062 }
9063 }
9064 }
9065
9066 $temp_detail = array(
9067 'title' => get_the_title( $inserted_post ),
9068 'edit_link' => get_edit_post_link( $inserted_post, 'internal' ),
9069 'view' => get_permalink( $inserted_post ),
9070 'id' => $inserted_post,
9071 );
9072
9073 if ( ! empty( $template_id->id ) ) {
9074 $temp_id = $template_id->id;
9075 } elseif ( ! empty( $template_id ) ) {
9076 $temp_id = $template_id;
9077 } else {
9078 $temp_id = '';
9079 }
9080
9081 wp_update_post([
9082 'ID' => $inserted_post,
9083 ]);
9084
9085 if (class_exists('Tpgb_Library') && method_exists('Tpgb_Library', 'remove_backend_dir_files')) {
9086 Tpgb_Library()->remove_backend_dir_files();
9087 }
9088
9089 clean_post_cache( $inserted_post );
9090
9091 // This whole method imports exactly one section per call — unlike
9092 // wdkit_import_template()/wdkit_import_kit_template(), it never fired this hook
9093 // at all, so single-section imports (Header/Footer/CTA/etc., a primary import
9094 // path per the Template Type sidebar) were invisible to tracking entirely.
9095 do_action( 'wdkit_template_imported', 'single', sanitize_key( $editor ), 1 );
9096
9097 wp_send_json(
9098 array(
9099 $temp_id => $temp_detail,
9100 'description' => 'Yay! Your Section has been Successfully Imported.',
9101 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
9102 'inserted_id' => $inserted_post,
9103 // Raster images this call itself deferred - the caller unions this into
9104 // whatever image_urls its own template-fetch response already carries
9105 // before scheduling the background sideload pass.
9106 'image_urls' => $deferred_image_urls,
9107 'success' => true,
9108 )
9109 );
9110 wp_die();
9111 }
9112 } elseif ( 'elementor' === $args['editor'] || ( 'wdkit' === $args['editor'] && ! empty( $file_type ) && 'elementor' === $file_type ) ) {
9113 if ( did_action( 'elementor/loaded' ) ) {
9114 if ( empty( $content ) ) {
9115 wp_send_json(
9116 array(
9117 'template_id' => $template_id,
9118 'message' => __( 'Content is Empty.', 'wdesignkit' ),
9119 )
9120 );
9121 wp_die();
9122 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'elementor' === $file_type ) {
9123
9124 $content = $this->wdkit_content_remover( $content );
9125
9126 $post_attributes = array(
9127 'post_title' => $post_title,
9128 'post_type' => $post_type,
9129 'post_status' => 'publish',
9130 'post_name' => $post_slug,
9131 );
9132
9133 if ( 'elementor_library' === $post_type ) {
9134 $el_type = ( isset( $post_content->el_type ) && ! empty( $post_content->el_type ) ) ? sanitize_text_field( $post_content->el_type ) : 'page';
9135 $new_document = \Elementor\Plugin::$instance->documents->create(
9136 $el_type,
9137 $post_attributes
9138 );
9139 } else {
9140 $new_document = \Elementor\Plugin::$instance->documents->create(
9141 $post_attributes['post_type'],
9142 $post_attributes
9143 );
9144 }
9145
9146 if ( is_wp_error( $new_document ) ) {
9147 wp_send_json(
9148 array(
9149 'import_failed' => $new_document->get_error_message(),
9150 'code' => $new_document->get_error_code(),
9151 )
9152 );
9153 wp_die();
9154 }
9155
9156 $inserted_id = $new_document->get_main_id();
9157
9158 if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
9159 // $featured_image_url = esc_url_raw( $thumb_image );
9160 $tmp = download_url( $thumb_image );
9161 if ( is_wp_error( $tmp ) ) {
9162 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
9163 } else {
9164 $file_array = array(
9165 'name' => wp_basename( $thumb_image ),
9166 'tmp_name' => $tmp,
9167 );
9168
9169 $image_id = media_handle_sideload( $file_array, $inserted_id );
9170
9171 if ( is_wp_error( $image_id ) ) {
9172 error_log( 'Image sideload failed: ' . esc_html( $image_id->get_error_message() ) );
9173 } else {
9174 set_post_thumbnail( $inserted_id, $image_id );
9175 }
9176
9177 @unlink( $tmp );
9178 }
9179 }
9180
9181 if ( ! empty( $category_list ) && is_array( $category_list ) ) {
9182 $category_ids = array_map( 'intval', $category_list );
9183 wp_set_post_terms( $inserted_id, $category_ids, 'category' );
9184 }
9185
9186 if ( ! empty( $tag_list ) && is_array( $tag_list ) ) {
9187 $tag_ids = array_map( 'intval', $tag_list );
9188 wp_set_post_terms( $inserted_id, $tag_ids, 'post_tag' );
9189 }
9190
9191 $settings = ( isset( $post_content->settings ) && ! empty( $post_content->settings ) ) ? json_decode( wp_json_encode( $post_content->settings ), true ) : array();
9192
9193 $content = wp_json_encode( $content );
9194 $content = $this->wdkit_media_import( $content, $file_type, $defer_media );
9195
9196 // Same header-logo guarantee the bundle path applies, so a kit imported
9197 // through either route ends up with the same clickable logo.
9198 $content = self::wdkit_link_header_logo( $content, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
9199
9200 $widgets_in = self::wdkit_widget_types_in( $content );
9201 $unregistered = self::wdkit_unregistered_widget_types( $widgets_in );
9202 if ( ! empty( $unregistered ) ) {
9203 $this->wdkit_enable_widgets_data( $unregistered );
9204 }
9205
9206 $new_document->save(
9207 array(
9208 'elements' => $content,
9209 'settings' => ! empty( $settings ) ? $settings : array(),
9210 )
9211 );
9212
9213 $inserted_id = $new_document->get_main_id();
9214
9215 if( $temp_type == 'navigation' ){
9216 $temp_con = $content;
9217 }
9218
9219 if ( ! empty( $args['custom_meta'] ) && 'true' == $args['custom_meta'] ) {
9220 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
9221 if ( ! empty( $custom_meta ) ) {
9222 foreach ( $custom_meta as $meta_key => $meta_val ) {
9223 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
9224 $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
9225 }
9226 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
9227 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
9228 }
9229 }
9230 }
9231 }
9232
9233 $temp_detail = array(
9234 'title' => get_the_title( $inserted_id ),
9235 'edit_link' => get_edit_post_link( $inserted_id, 'internal' ),
9236 'view' => get_permalink( $inserted_id ),
9237 'id' => $inserted_id,
9238 );
9239
9240 if ( ! empty( $template_id->id ) ) {
9241 $temp_id = $template_id->id;
9242 } elseif ( ! empty( $template_id ) ) {
9243 $temp_id = $template_id;
9244 } else {
9245 $temp_id = '';
9246 }
9247
9248 \Elementor\Plugin::$instance->files_manager->clear_cache();
9249
9250 // See the matching note in the Gutenberg branch above — this method never
9251 // fired the tracking hook for either editor.
9252 do_action( 'wdkit_template_imported', 'single', 'elementor', 1 );
9253
9254 wp_send_json(
9255 array(
9256 $temp_id => $temp_detail,
9257 'content' => $temp_con,
9258 'description' => 'Yay! Your Section has been Successfully Imported.',
9259 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
9260 'inserted_id' => $inserted_id,
9261 // Same contract as the Gutenberg branch: the media this call
9262 // chose to defer, for the caller to schedule.
9263 'image_urls' => $defer_media
9264 ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
9265 : array(),
9266 'success' => true,
9267 )
9268 );
9269 wp_die();
9270 }
9271 } else {
9272 wp_send_json(
9273 array(
9274 'template_id' => $template_id,
9275 'message' => esc_html__( 'Relevant Page Builder not installed or activated', 'wdesignkit' ),
9276 )
9277 );
9278 wp_die();
9279 }
9280 }
9281 }
9282
9283 wp_send_json(
9284 array(
9285 'success' => false,
9286 'message' => esc_html__( 'Something went wrong', 'wdesignkit' ),
9287 )
9288 );
9289 wp_die();
9290 }
9291
9292 /**
9293 * Replace unicode glitch
9294 *
9295 * @since 2.0.0
9296 */
9297 /**
9298 * Widened from private to public in 2.6.5 so the PHP page importer can apply the exact
9299 * same unicode normalisation the AJAX page import applies. Pure string transform, no
9300 * state, no capability implications.
9301 */
9302 public function replace_unicode_glitch( $content ) {
9303
9304 // Fix escaped unicode like \u003c → <
9305 $content = preg_replace_callback(
9306 '/\\\\u([0-9a-fA-F]{4})/',
9307 function ( $match ) {
9308 return html_entity_decode(
9309 mb_convert_encoding(
9310 pack('H*', $match[1]),
9311 'UTF-8',
9312 'UCS-2BE'
9313 ),
9314 ENT_QUOTES,
9315 'UTF-8'
9316 );
9317 },
9318 $content
9319 );
9320
9321 return $content;
9322 }
9323
9324
9325 /**
9326 * change plugins setting for import kit
9327 *
9328 * @since 2.0.0
9329 */
9330 protected function update_plugin_setting() {
9331 $temp_id = isset( $_POST['plugin_type'] ) ? sanitize_text_field( $_POST['plugin_type'] ) : '';
9332
9333 $response = $this->wdkit_apply_plugin_settings_data( $temp_id );
9334
9335 wp_send_json( $response );
9336 wp_die();
9337 }
9338
9339 /**
9340 * Apply the plugin-side settings an imported kit needs.
9341 *
9342 * Extracted from update_plugin_setting() so the PHP import runner can reach it without
9343 * `$_POST`. The option list is a closed set written by this method alone — there is no
9344 * path here for a caller to name an option, which is the point.
9345 *
9346 * The AJAX action above is now a thin adapter over this and its response is unchanged.
9347 *
9348 * @since 2.6.5
9349 *
9350 * @param string $temp_id Plugin identifier; only 'elementor' is recognised.
9351 * @return array Response array, exactly as the AJAX action used to emit.
9352 */
9353 public function wdkit_apply_plugin_settings_data( $temp_id ) {
9354 $temp_id = is_string( $temp_id ) ? $temp_id : '';
9355
9356 if ( $temp_id == 'elementor' ) {
9357 $unfiltered_files = get_option( 'elementor_unfiltered_files_upload', false );
9358 $load_fa4 = get_option( 'elementor_load_fa4_shim', false );
9359 $Inline_font_icons = get_option( 'elementor_experiment-e_font_icon_svg', false );
9360 $container = get_option( 'elementor_experiment-container', false );
9361
9362 if ( isset( $unfiltered_files ) ) {
9363 update_option( 'elementor_unfiltered_files_upload', 1 );
9364 } else {
9365 add_option( 'elementor_unfiltered_files_upload', 1 );
9366 }
9367
9368 if ( isset( $load_fa4 ) ) {
9369 update_option( 'elementor_load_fa4_shim', 'yes' );
9370 } else {
9371 add_option( 'elementor_load_fa4_shim', 'yes' );
9372 }
9373
9374 if ( isset( $container ) ) {
9375 update_option( 'elementor_experiment-container', 'active' );
9376 } else {
9377 add_option( 'elementor_experiment-container', 'active' );
9378 }
9379
9380 if ( isset( $Inline_font_icons ) ) {
9381 update_option( 'elementor_experiment-e_font_icon_svg', 'inactive' );
9382 } else {
9383 add_option( 'elementor_experiment-e_font_icon_svg', 'inactive' );
9384 }
9385
9386 $response = array(
9387 'message' => esc_html__( 'Plugin Setting updated', 'wdesignkit' ),
9388 'description' => esc_html__( 'Plugin Setting updated', 'wdesignkit' ),
9389 'success' => true,
9390 );
9391 } else {
9392 $response = array(
9393 'message' => esc_html__( 'Plugin not found', 'wdesignkit' ),
9394 'description' => esc_html__( 'Plugin not found', 'wdesignkit' ),
9395 'success' => false,
9396 );
9397 }
9398
9399 return $response;
9400 }
9401
9402 /**
9403 * generate different color logo
9404 *
9405 * @since 2.0.0
9406 */
9407 protected function wkit_generate_site_logo() {
9408
9409 if ( empty( $_POST['image_url'] ) ) {
9410 wp_send_json_error( 'Image URL not provided.' );
9411 }
9412
9413 if ( isset( $_POST['colors'] ) ) {
9414 $img_colors = ! empty( $_POST['colors'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['colors'] ) ), true ) : array();
9415 }
9416
9417 if ( empty( $img_colors ) ) {
9418 wp_send_json_error( 'Image color not provided.' );
9419 }
9420
9421 $image_url = esc_url_raw( $_POST['image_url'] );
9422
9423 if ( ! wdesignkit_validate_external_url( $image_url ) ) {
9424 wp_send_json_error( 'Image could not be downloaded.' );
9425 }
9426
9427 $tmp_file = download_url( $image_url );
9428 if ( is_wp_error( $tmp_file ) ) {
9429 wp_send_json_error( 'Image could not be downloaded.' );
9430 }
9431
9432 if ( mime_content_type( $tmp_file ) !== 'image/png' ) {
9433 unlink( $tmp_file );
9434 wp_send_json_error( 'Not a PNG file.' );
9435 }
9436
9437 $src = imagecreatefrompng( $tmp_file );
9438 imagesavealpha( $src, true );
9439
9440 $width = imagesx( $src );
9441 $height = imagesy( $src );
9442
9443 $hasTransparency = false;
9444 for ( $x = 0; $x < $width; $x++ ) {
9445 for ( $y = 0; $y < $height; $y++ ) {
9446 $rgba = imagecolorat( $src, $x, $y );
9447 $alpha = ( $rgba & 0x7F000000 ) >> 24;
9448
9449 if ( $alpha > 0 ) {
9450 $hasTransparency = true;
9451 break 2;
9452 }
9453 }
9454 }
9455
9456 if ( ! $hasTransparency ) {
9457 unlink( $tmp_file );
9458 wp_send_json_error( 'PNG has no transparent pixels.' );
9459 }
9460
9461 $upload_dir = wp_upload_dir();
9462 $result_urls = array();
9463
9464 $colour_index = 0;
9465 foreach ( $img_colors as $name => $rgb ) {
9466 // $name is a key from the posted colours payload and went straight into the output
9467 // filename, so traversal sequences in it steered imagepng() outside the upload
9468 // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
9469 // path segment; a key made only of dots/separators sanitizes to empty, so fall back
9470 // to a positional index rather than writing to a bare "colored--<time>.png".
9471 ++$colour_index;
9472 $safe_name = sanitize_file_name( (string) $name );
9473 if ( '' === $safe_name ) {
9474 $safe_name = 'colour-' . $colour_index;
9475 }
9476
9477 $new = imagecreatetruecolor( $width, $height );
9478 imagesavealpha( $new, true );
9479 imagealphablending( $new, false );
9480
9481 $transparent = imagecolorallocatealpha( $new, 0, 0, 0, 127 );
9482 imagefill( $new, 0, 0, $transparent );
9483
9484 for ( $x = 0; $x < $width; $x++ ) {
9485 for ( $y = 0; $y < $height; $y++ ) {
9486 $rgba = imagecolorat( $src, $x, $y );
9487 $alpha = ( $rgba & 0x7F000000 ) >> 24;
9488
9489 // Skip fully transparent pixels
9490 if ( $alpha === 127 ) {
9491 continue;
9492 }
9493
9494 // Replace pixel color directly
9495 $new_r = $rgb[0];
9496 $new_g = $rgb[1];
9497 $new_b = $rgb[2];
9498
9499 $color = imagecolorallocatealpha( $new, $new_r, $new_g, $new_b, $alpha );
9500 imagesetpixel( $new, $x, $y, $color );
9501 }
9502 }
9503
9504 $filename = 'colored-' . $safe_name . '-' . time() . '.png';
9505 $filepath = $upload_dir['path'] . '/' . $filename;
9506
9507 imagepng( $new, $filepath );
9508 imagedestroy( $new );
9509
9510 $attachment = array(
9511 'post_mime_type' => 'image/png',
9512 'post_title' => sanitize_file_name( $filename ),
9513 'post_content' => '',
9514 'post_status' => 'inherit',
9515 );
9516
9517 $attach_id = wp_insert_attachment( $attachment, $filepath );
9518 require_once ABSPATH . 'wp-admin/includes/image.php';
9519 $attach_data = wp_generate_attachment_metadata( $attach_id, $filepath );
9520 wp_update_attachment_metadata( $attach_id, $attach_data );
9521
9522 $result_urls[ $name ] = wp_get_attachment_url( $attach_id );
9523 }
9524
9525 imagedestroy( $src );
9526 unlink( $tmp_file );
9527
9528 wp_send_json_success( $result_urls );
9529 }
9530
9531 /**
9532 * change theme setting for import kit
9533 *
9534 * @since 2.0.0
9535 */
9536 protected function update_theme_setting() {
9537 $response = $this->wdkit_apply_theme_settings_data();
9538
9539 wp_send_json( $response );
9540 wp_die();
9541 }
9542
9543 /**
9544 * Apply the Nexter theme container settings an imported kit expects.
9545 *
9546 * Extracted from update_theme_setting() so the PHP import runner can reach it without
9547 * `$_POST`. Note that this method takes no input at all and never has: it writes a
9548 * fixed set of `nxt-theme-options` sub-keys, which is why it is inherently idempotent
9549 * and why there is nothing here a caller could steer.
9550 *
9551 * The AJAX action above is now a thin adapter over this and its response is unchanged.
9552 *
9553 * @since 2.6.5
9554 *
9555 * @return array Response array, exactly as the AJAX action used to emit.
9556 */
9557 public function wdkit_apply_theme_settings_data() {
9558 $theme_db = get_option( 'nxt-theme-options', false );
9559
9560 $container_type = 'container-fluid';
9561 $fluid_spacing = array(
9562 'md' => array(
9563 'left' => '0',
9564 'right' => '0',
9565 ),
9566 'sm' => array(
9567 'left' => '',
9568 'right' => '',
9569 ),
9570 'xs' => array(
9571 'left' => '',
9572 'right' => '',
9573 ),
9574 'md-unit' => 'px',
9575 'sm-unit' => 'px',
9576 'xs-unit' => 'px',
9577 );
9578
9579 if ( isset( $theme_db ) ) {
9580 $nexter_setting = $theme_db;
9581 $nexter_setting['site-header-container'] = $container_type;
9582 $nexter_setting['site-footer-container'] = $container_type;
9583 $nexter_setting['site-layout-container'] = $container_type;
9584 $nexter_setting['site-page-container'] = $container_type;
9585
9586 $nexter_setting['header-fluid-spacing'] = $fluid_spacing;
9587 $nexter_setting['footer-fluid-spacing'] = $fluid_spacing;
9588 $nexter_setting['site-fluid-spacing'] = $fluid_spacing;
9589 $nexter_setting['page-fluid-spacing'] = $fluid_spacing;
9590
9591 update_option( 'nxt-theme-options', $nexter_setting );
9592 } else {
9593 $nexter_setting = array(
9594 'site-header-container' => 'container-fluid',
9595 'header-fluid-spacing' => array(
9596 'md' => array(
9597 'left' => '0',
9598 'right' => '0',
9599 ),
9600 'sm' => array(
9601 'left' => '',
9602 'right' => '',
9603 ),
9604 'xs' => array(
9605 'left' => '',
9606 'right' => '',
9607 ),
9608 'md-unit' => 'px',
9609 'sm-unit' => 'px',
9610 'xs-unit' => 'px',
9611 ),
9612 'site-footer-container' => 'container-fluid',
9613 'footer-fluid-spacing' => array(
9614 'md' => array(
9615 'left' => '0',
9616 'right' => '0',
9617 ),
9618 'sm' => array(
9619 'left' => '',
9620 'right' => '',
9621 ),
9622 'xs' => array(
9623 'left' => '',
9624 'right' => '',
9625 ),
9626 'md-unit' => 'px',
9627 'sm-unit' => 'px',
9628 'xs-unit' => 'px',
9629 ),
9630 'site-layout-container' => 'container-fluid',
9631 'site-fluid-spacing' => array(
9632 'md' => array(
9633 'left' => '0',
9634 'right' => '0',
9635 ),
9636 'sm' => array(
9637 'left' => '',
9638 'right' => '',
9639 ),
9640 'xs' => array(
9641 'left' => '',
9642 'right' => '',
9643 ),
9644 'md-unit' => 'px',
9645 'sm-unit' => 'px',
9646 'xs-unit' => 'px',
9647 ),
9648 'site-page-container' => 'container-fluid',
9649 'page-fluid-spacing' => array(
9650 'md' => array(
9651 'left' => '0',
9652 'right' => '0',
9653 ),
9654 'sm' => array(
9655 'left' => '',
9656 'right' => '',
9657 ),
9658 'xs' => array(
9659 'left' => '',
9660 'right' => '',
9661 ),
9662 'md-unit' => 'px',
9663 'sm-unit' => 'px',
9664 'xs-unit' => 'px',
9665 ),
9666 'site-page-container' => '',
9667 'site-posts-container' => '',
9668 'site-archive-container' => '',
9669 );
9670
9671 add_option( 'nxt-theme-options', $nexter_setting );
9672 }
9673
9674 $response = array(
9675 'message' => esc_html__( 'Theme Setting updated', 'wdesignkit' ),
9676 'description' => esc_html__( 'Theme Setting updated', 'wdesignkit' ),
9677 'success' => true,
9678 );
9679
9680 return $response;
9681 }
9682
9683 /**
9684 * change site setting for import kit
9685 *
9686 * @since 2.0.0
9687 */
9688 protected function update_site_setting() {
9689
9690 // This is the import's "Finalizing Settings" request, and it is not the cheap
9691 // bookkeeping call its name suggests: it generates the site logo/icon and then runs
9692 // wdkit_sweep_attachment_ids() across every page that was just created, which resolves
9693 // attachment ids and can pull WordPress into Imagick subsize generation. Every other
9694 // heavy handler here already extends the limit (see the bundle import, the page-section
9695 // import, the deferred-media cron); this one did not, so on a kit with a large image it
9696 // fataled at PHP's default 30s. The client has no recovery path for that - the step
9697 // simply stays on "Finalizing Settings" and polls forever, which is exactly the hang
9698 // reported against Elementor kits. Harmless no-op where set_time_limit() is disabled.
9699 if ( function_exists( 'set_time_limit' ) ) {
9700 @set_time_limit( 300 );
9701 }
9702
9703 $temp_id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
9704 $shop_id = isset( $_POST['shop_id'] ) ? sanitize_text_field( wp_unslash( $_POST['shop_id'] ) ) : '';
9705 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
9706 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
9707 $site_tagline = isset( $_POST['site_tagline'] ) ? sanitize_text_field( wp_unslash( $_POST['site_tagline'] ) ) : '';
9708
9709 $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9710 $page_information = json_decode( $page_information, true );
9711
9712 $response = $this->wdkit_apply_site_settings_data(
9713 array(
9714 'id' => $temp_id,
9715 'shop_id' => $shop_id,
9716 'temp_type' => $temp_type,
9717 'site_name' => $site_name,
9718 'site_tagline' => $site_tagline,
9719 'page_information' => $page_information,
9720 )
9721 );
9722
9723 wp_send_json( $response );
9724 wp_die();
9725 }
9726
9727 /**
9728 * Apply a kit's site settings: front page, shop page, name and tagline.
9729 *
9730 * Extracted from update_site_setting() so the PHP import runner can reach it without
9731 * `$_POST`. The set of options written is closed and hard-coded here —
9732 * `show_on_front`, `page_on_front`, `woocommerce_shop_page_id`, `blogname`,
9733 * `blogdescription`. There is deliberately no way for a caller to name an option.
9734 *
9735 * Theme-builder condition rewriting still runs through
9736 * wdkit_nxt_thembuilder_update(), which now takes its page map as an argument.
9737 *
9738 * The AJAX action above is now a thin adapter over this and its response is unchanged.
9739 *
9740 * @since 2.6.5
9741 *
9742 * @param array $args {id, shop_id, temp_type, site_name, site_tagline, page_information,
9743 * defer_attachment_downloads}.
9744 * @return array Response array, exactly as the AJAX action used to emit.
9745 */
9746 public function wdkit_apply_site_settings_data( $args ) {
9747 $args = is_array( $args ) ? $args : array();
9748
9749 $temp_id = isset( $args['id'] ) ? sanitize_text_field( (string) $args['id'] ) : '';
9750 $shop_id = isset( $args['shop_id'] ) ? sanitize_text_field( (string) $args['shop_id'] ) : '';
9751 $temp_type = isset( $args['temp_type'] ) ? sanitize_text_field( (string) $args['temp_type'] ) : 'page';
9752 $site_name = isset( $args['site_name'] ) ? sanitize_text_field( (string) $args['site_name'] ) : '';
9753 $site_tagline = isset( $args['site_tagline'] ) ? sanitize_text_field( (string) $args['site_tagline'] ) : '';
9754
9755 $this->wdkit_nxt_thembuilder_update(
9756 isset( $args['page_information'] ) ? $args['page_information'] : null,
9757 ! empty( $args['defer_attachment_downloads'] )
9758 );
9759
9760 if ( ! empty( $shop_id ) ) {
9761 update_option( 'woocommerce_shop_page_id', $shop_id );
9762 }
9763
9764 if ( $temp_id ) {
9765 update_option( 'show_on_front', $temp_type );
9766 update_option( 'page_on_front', $temp_id );
9767
9768 if ( ! empty( $site_name ) ) {
9769 update_option( 'blogname', $site_name );
9770 }
9771
9772 if ( ! empty( $site_tagline ) ) {
9773 update_option( 'blogdescription', $site_tagline );
9774
9775 /* Remembered so a LATER import can tell a tagline this importer wrote from
9776 * one the site's owner wrote. Only the former is ours to clear. */
9777 update_option( 'wdkit_applied_tagline', $site_tagline );
9778 } else {
9779 /* Skipped the tagline question - which is not the same as "leave whatever is
9780 * there". Importing a second kit onto a site that already carries the FIRST
9781 * import's tagline left the new business sitting under the old one's slogan
9782 * (a SaaS kit headed "Golden Crust Melts Inside"), because nothing ever
9783 * cleared it.
9784 *
9785 * Cleared only when the current tagline is character-for-character the one a
9786 * previous import wrote. A tagline the site's own owner typed is left
9787 * completely alone - a kit import has no business wiping it. */
9788 $applied = (string) get_option( 'wdkit_applied_tagline', '' );
9789
9790 if ( '' !== $applied && $applied === (string) get_option( 'blogdescription', '' ) ) {
9791 update_option( 'blogdescription', '' );
9792 delete_option( 'wdkit_applied_tagline' );
9793 }
9794 }
9795
9796 $response = array(
9797 'message' => esc_html__( 'Site link updated', 'wdesignkit' ),
9798 'description' => esc_html__( 'Site link updated', 'wdesignkit' ),
9799 'site_link' => get_site_url(),
9800 'success' => true,
9801 );
9802 } else {
9803 $response = array(
9804 'message' => esc_html__( 'Site not found', 'wdesignkit' ),
9805 'description' => esc_html__( 'Site not found', 'wdesignkit' ),
9806 'success' => false,
9807 );
9808 }
9809
9810 return $response;
9811 }
9812
9813 /**
9814 * update theme builder
9815 *
9816 * @since 2.0.4
9817 *
9818 * @param array|null $page_information Page map; falls back to $_POST when null.
9819 * @param bool $local_only Passed straight through to
9820 * wdkit_sweep_attachment_ids() — see its docblock.
9821 */
9822 public function wdkit_nxt_thembuilder_update( $page_information = null, $local_only = false ) {
9823
9824 /* Falls back to `$_POST` when called with no argument, so every existing caller —
9825 * and the AJAX path — behaves exactly as before. The PHP runner passes the map in. */
9826 if ( null === $page_information ) {
9827 $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9828 $page_information = json_decode( $page_information, true );
9829 }
9830
9831 $t_finalize = microtime( true );
9832
9833 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
9834
9835 // Every page and attachment now exists, so resolve any image ID the per-page
9836 // pass could not (siblings import concurrently and share icons).
9837 $t_sweep = microtime( true );
9838 $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ), $local_only );
9839
9840 // This walks every page's content looking for unresolved attachment ids, so it
9841 // scales with kit size and was not covered by the per-template numbers.
9842 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
9843 error_log( sprintf(
9844 'WDKIT finalize: sweep_attachment_ids %dms over %d pages',
9845 (int) round( ( microtime( true ) - $t_sweep ) * 1000 ),
9846 count( $page_information )
9847 ) );
9848 }
9849
9850 // Step 1: banavo mapping [ old_id => new_id ]
9851 $id_mapping = array();
9852 foreach ( $page_information as $page_info ) {
9853 if ( ! empty( $page_info['old_page_id'] ) ) {
9854 $id_mapping[ $page_info['old_page_id'] ] = $page_info['inserted_id'];
9855 }
9856 }
9857
9858 // Step 2: loop karo and update exclude
9859 foreach ( $page_information as $page_info ) {
9860
9861 $post_id = $page_info['inserted_id'] ?? '';
9862 $old_post_id = $page_info['old_page_id'] ?? '';
9863 $post_type = $page_info['post_type'] ?? '';
9864
9865 if ( empty( $old_post_id ) ) {
9866 continue; // only update where old id exists
9867 }
9868
9869 if ( $post_type != 'nxt_builder' ) {
9870 continue;
9871 }
9872
9873 $include_specific = get_post_meta( $post_id, 'nxt-hooks-layout-specific', true );
9874 if ( ! empty( $include_specific ) && is_array( $include_specific ) ) {
9875
9876 foreach ( $include_specific as $key => $val ) {
9877
9878 // check karo ke koi old_id ka post match kare che ke nahi
9879 foreach ( $id_mapping as $old_id => $new_id ) {
9880 $search = 'post-' . $old_id;
9881 $replace = 'post-' . $new_id;
9882
9883 if ( $val === $search ) {
9884 $include_specific[ $key ] = $replace;
9885 }
9886 }
9887 }
9888
9889 // save back updated array
9890 update_post_meta( $post_id, 'nxt-hooks-layout-specific', $include_specific );
9891 }
9892
9893 // Get exclude meta
9894 $exclude_specific = get_post_meta( $post_id, 'nxt-hooks-layout-exclude-specific', true );
9895 if ( ! empty( $exclude_specific ) && is_array( $exclude_specific ) ) {
9896
9897 foreach ( $exclude_specific as $key => $val ) {
9898
9899 // check karo ke koi old_id ka post match kare che ke nahi
9900 foreach ( $id_mapping as $old_id => $new_id ) {
9901 $search = 'post-' . $old_id;
9902 $replace = 'post-' . $new_id;
9903
9904 if ( $val === $search ) {
9905 $exclude_specific[ $key ] = $replace;
9906 }
9907 }
9908 }
9909
9910 // save back updated array
9911 update_post_meta( $post_id, 'nxt-hooks-layout-exclude-specific', $exclude_specific );
9912 }
9913 }
9914 }
9915 }
9916
9917 /**
9918 *
9919 * select team image for import kit
9920 *
9921 * @since 2.2.2
9922 */
9923 public function wdkit_check_user_credit() {
9924 $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
9925
9926 /* The caller sends the token from its own localStorage, which is per browser. The
9927 * SITE's cloud session is not - it lives in a transient written at login and is what
9928 * every import actually authenticates with. Reading only the browser copy meant a
9929 * connected site told a second browser, an incognito window or another WP admin that
9930 * it needed to log in, while an import from that same site would have worked
9931 * (ClickUp 14ynqxywrg4). */
9932 if ( '' === $token && function_exists( 'wdkit_kit_import_resolve_token' ) ) {
9933 $token = (string) wdkit_kit_import_resolve_token();
9934 }
9935
9936 if ( '' === $token ) {
9937 wp_send_json(
9938 array(
9939 'success' => false,
9940 'reason' => 'no_session',
9941 'logged_in' => false,
9942 'message' => esc_html__( 'Not signed into WDesignKit on this site.', 'wdesignkit' ),
9943 )
9944 );
9945 wp_die();
9946 }
9947
9948 $array_data = array( 'token' => $token );
9949
9950 $response = $this->wkit_api_call( $array_data, 'ai/credits/get' );
9951 $success = ! empty( $response['success'] ) ? $response['success'] : false;
9952
9953 if ( empty( $success ) ) {
9954 /* Why it failed, not just that it did. The caller used to receive one flat
9955 * "Data Not Found" for every failure and read it as "no credits left", so a
9956 * cloud request that merely timed out was reported to the user as "your site
9957 * limit for today has been reached" - a limit that had not been reached. A
9958 * transport failure says so through wkit_api_call()'s own message. */
9959 $massage = isset( $response['massage'] ) ? (string) $response['massage'] : '';
9960 $unreachable = ( '' !== $massage && false !== stripos( $massage, 'API request error' ) );
9961
9962 wp_send_json(
9963 array(
9964 'success' => false,
9965 'reason' => $unreachable ? 'unreachable' : 'rejected',
9966
9967 /* A token existed and was sent, so whatever went wrong here is not the
9968 * user being signed out. */
9969 'logged_in' => true,
9970 'message' => $unreachable
9971 ? esc_html__( 'Could not reach WDesignKit.', 'wdesignkit' )
9972 : esc_html__( 'Data Not Found', 'wdesignkit' ),
9973 'description' => $massage,
9974 )
9975 );
9976 wp_die();
9977 }
9978
9979 $response = json_decode( wp_json_encode( $response['data'] ), true );
9980
9981 if ( is_array( $response ) ) {
9982 /* The cloud answered for this token, so the site is signed in - said plainly so
9983 * the caller does not have to infer it from its own localStorage. */
9984 $response['logged_in'] = true;
9985 }
9986
9987 $this->wdkit_cache_cloud_usage( $response );
9988
9989 wp_send_json( $response );
9990 wp_die();
9991 }
9992
9993 /**
9994 * Caches the storage / credit figures this response carried.
9995 *
9996 * This handler is the ONLY place those numbers ever exist on the site: the cloud endpoint
9997 * authenticates with a user token that only a logged-in dashboard request carries, so the
9998 * analytics heartbeat — which runs on cron with no user at all — can never fetch them itself.
9999 * Caching them here is what lets Posimyth_Tracker_WDK report them, and it reports the cache's
10000 * age alongside so a stale reading is recognisable as one.
10001 *
10002 * Field names are probed rather than assumed: the cloud has renamed these before, and the
10003 * licence ability already carries six spellings of its own key field for the same reason. An
10004 * unrecognised shape simply caches nothing rather than storing a wrong number.
10005 *
10006 * Only the figures are kept. No token, no account id, no email — the analytics consent copy
10007 * promises non-sensitive data only, and this is read by the payload builder.
10008 *
10009 * @since 2.6.4
10010 *
10011 * @param mixed $data Decoded `data` object from the credits endpoint.
10012 * @return void
10013 */
10014 private function wdkit_cache_cloud_usage( $data ) {
10015 if ( ! is_array( $data ) ) {
10016 return;
10017 }
10018
10019 $pick = static function ( $source, array $fields ) {
10020 foreach ( $fields as $field ) {
10021 if ( isset( $source[ $field ] ) && is_numeric( $source[ $field ] ) ) {
10022 return (float) $source[ $field ];
10023 }
10024 }
10025 return null;
10026 };
10027
10028 $usage = array(
10029 'storage_used' => $pick( $data, array( 'used_storage', 'storage_used', 'used_space' ) ),
10030 'storage_total' => $pick( $data, array( 'total_storage', 'storage_total', 'storage', 'total_space' ) ),
10031 'credit_used' => $pick( $data, array( 'used_credit', 'credit_used', 'used_credits' ) ),
10032 'credit_total' => $pick( $data, array( 'total_credit', 'credit_total', 'credits', 'real_credit' ) ),
10033 );
10034
10035 $usage = array_filter(
10036 $usage,
10037 static function ( $value ) {
10038 return null !== $value;
10039 }
10040 );
10041
10042 if ( empty( $usage ) ) {
10043 return;
10044 }
10045
10046 $usage['cached_at'] = gmdate( 'Y-m-d H:i:s' );
10047
10048 // Not autoloaded: read once a week by the heartbeat, never on a front-end request.
10049 update_option( 'wdkit_cloud_usage', $usage, false );
10050 }
10051
10052 public function wdkit_nxt_thembuilder_reset() {
10053 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
10054 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
10055
10056 if ( ( ! empty( $sections_layout ) && ( $sections_layout == 'header' || $sections_layout == 'footer' || $sections_layout == 'breadcrumb' || $sections_layout == 'hooks' ) ) ) {
10057 if ( get_post_meta( $post_id, 'nxt-add-display-rule' ) ) {
10058 delete_post_meta( $post_id, 'nxt-add-display-rule' );
10059 }
10060
10061 if ( get_post_meta( $post_id, 'nxt-hooks-layout-specific' ) ) {
10062 update_post_meta( $post_id, 'nxt-hooks-layout-specific', '' );
10063 }
10064
10065 if ( get_post_meta( $post_id, 'nxt-exclude-display-rule' ) ) {
10066 update_post_meta( $post_id, 'nxt-exclude-display-rule', '' );
10067 }
10068
10069 if ( get_post_meta( $post_id, 'nxt-hooks-layout-exclude-specific' ) ) {
10070 update_post_meta( $post_id, 'nxt-hooks-layout-exclude-specific', '' );
10071 }
10072 }
10073 }
10074
10075
10076 /**
10077 * Share with Me Template and widgets
10078 *
10079 * @since 1.0.0
10080 */
10081 protected function wdkit_shared_with_me() {
10082 $data = isset( $_POST['api_info'] ) ? json_decode( stripslashes( sanitize_text_field( wp_unslash( $_POST['api_info'] ) ) ) ) : '';
10083
10084 $array_data = array(
10085 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
10086 'type' => isset( $data->type ) ? sanitize_text_field( wp_unslash( $data->type ) ) : '',
10087 'ParPage' => isset( $data->par_page ) ? (int) $data->par_page : 12,
10088 'CurrentPage' => isset( $data->current_page ) ? (int) $data->current_page : 1,
10089 'builder' => isset( $data->builder ) ? sanitize_text_field( wp_unslash( $data->builder ) ) : '',
10090 );
10091
10092 $response = $this->wkit_api_call( $array_data, 'shared_with_me' );
10093 $success = ! empty( $response['success'] ) ? $response['success'] : false;
10094
10095 wp_send_json( $response );
10096 wp_die();
10097 }
10098
10099 /**
10100 * Add new WorkSpace
10101 *
10102 * @since 1.0.0
10103 */
10104 protected function wdkit_manage_workspace() {
10105 $args = $this->wdkit_parse_args( $_POST );
10106
10107 $user_email = ! empty( $args['email'] ) ? strtolower( sanitize_email( $args['email'] ) ) : '';
10108 $current_wid = ! empty( $_POST['current_wid'] ) ? strtolower( sanitize_text_field( $_POST['current_wid'] ) ) : '';
10109
10110 $args['current_wid'] = $current_wid;
10111
10112 if ( empty( $user_email ) ) {
10113 $response = array(
10114 'message' => $this->e_msg_login,
10115 'description' => $this->e_desc_login,
10116 'success' => false,
10117 );
10118
10119 wp_send_json( $response );
10120 wp_die();
10121 }
10122
10123 $args['token'] = $this->wdkit_login_user_token( $user_email );
10124 unset( $user_email );
10125
10126 $response = WDesignKit_Data_Query::get_data( 'manage_workspace', $args );
10127
10128 return $response;
10129 }
10130
10131 /**
10132 *
10133 * It is Use for manage workspace
10134 *
10135 * @since 1.0.0
10136 */
10137 protected function wdkit_manage_widget_workspace() {
10138 $workspace_info = isset( $_POST['workspace_info'] ) ? sanitize_text_field( wp_unslash( $_POST['workspace_info'] ) ) : array();
10139 $data = isset( $workspace_info ) ? json_decode( stripslashes( $workspace_info ) ) : array();
10140
10141 $array_data = array(
10142 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
10143 'wstype' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
10144 'widget_id' => isset( $data->widget_id ) ? (int) $data->widget_id : '',
10145 'wid' => isset( $data->wid ) ? (int) $data->wid : '',
10146 'current_wid' => isset( $data->current_wid ) ? (int) $data->current_wid : '',
10147 );
10148
10149 $response = $this->wkit_api_call( $array_data, 'manage_workspace' );
10150
10151 wp_send_json( $response['data'] );
10152 wp_die();
10153 }
10154
10155 /**
10156 *
10157 * It is Use for manage api key page
10158 *
10159 * @since 1.0.0
10160 */
10161 protected function wdkit_activate_key() {
10162 $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
10163 $response = '';
10164
10165 // Bug C fix: variable was $user_email but only $email is set above — always triggered empty() guard.
10166 if ( empty( $email ) ) {
10167 $response = array(
10168 'message' => $this->e_msg_login,
10169 'description' => $this->e_desc_login,
10170 'success' => false,
10171 );
10172
10173 wp_send_json( $response );
10174 wp_die();
10175 }
10176
10177 $token = $this->wdkit_login_user_token( $email );
10178 $apikey = isset( $_POST['apikey'] ) ? sanitize_key( wp_unslash( $_POST['apikey'] ) ) : '';
10179 $product = isset( $_POST['product'] ) ? sanitize_text_field( wp_unslash( $_POST['product'] ) ) : '';
10180 $product_action = isset( $_POST['product_action'] ) ? sanitize_text_field( wp_unslash( $_POST['product_action'] ) ) : 'activate';
10181 if ( ! empty( $token ) && ! empty( $product ) && ! empty( $product_action ) ) {
10182 $args = array(
10183 'token' => $token,
10184 'product' => $product,
10185 'product_action' => $product_action,
10186 );
10187
10188 if ( 'activate' === $product_action ) {
10189 $args['apikey'] = $apikey;
10190 $args['site_url'] = home_url();
10191 }
10192
10193 $response = Wdkit_Data_Hooks::get_data( 'wkit_activate_key', $args );
10194 }
10195
10196 wp_send_json( $response );
10197 wp_die();
10198 }
10199
10200 /**
10201 *
10202 * Get list local Widget List
10203 *
10204 * @since 1.0.0
10205 */
10206 protected function wdkit_get_local_widgets() {
10207 $builder = array();
10208 $a_c_s_d_s_c = array();
10209 $j_s_o_n_array = array();
10210
10211 if ( Wdkit_Wdesignkit::wdkit_is_compatible( 'bricks', 'widget' ) ) {
10212 array_push( $builder, 'bricks' );
10213 }
10214
10215 if ( Wdkit_Wdesignkit::wdkit_is_compatible( 'elementor', 'widget' ) ) {
10216 array_push( $builder, 'elementor' );
10217 }
10218
10219 if ( Wdkit_Wdesignkit::wdkit_is_compatible( 'gutenberg', 'widget' ) ) {
10220 array_push( $builder, 'gutenberg' );
10221 }
10222
10223 if ( Wdkit_Wdesignkit::wdkit_is_compatible( 'gutenberg_core', 'widget' ) ) {
10224 array_push( $builder, 'gutenberg_core' );
10225 }
10226
10227 foreach ( $builder as $key => $name ) {
10228 $elementor_dir = WDKIT_BUILDER_PATH . '/' . $name;
10229
10230 if ( ! empty( $elementor_dir ) && is_dir( $elementor_dir ) ) {
10231 $elementor_list = scandir( $elementor_dir );
10232 $elementor_list = array_diff( $elementor_list, array( '.', '..' ) );
10233
10234 if ( ! empty( $elementor_list ) ) {
10235 foreach ( $elementor_list as $key => $value ) {
10236 $a_c_s_d_s_c[ filemtime( "{$elementor_dir}/{$value}" ) . $key ]['data'] = $value;
10237 $a_c_s_d_s_c[ filemtime( "{$elementor_dir}/{$value}" ) . $key ]['builder'] = $name;
10238 }
10239 }
10240 }
10241 }
10242
10243 ksort( $a_c_s_d_s_c );
10244 $a_c_s_d_s_c = array_reverse( $a_c_s_d_s_c );
10245
10246 foreach ( $a_c_s_d_s_c as $key => $value ) {
10247 $elementor_dir = WDKIT_BUILDER_PATH . '/' . $value['builder'];
10248
10249 if ( file_exists( "{$elementor_dir}/{$value['data']}" ) && is_dir( "{$elementor_dir}/{$value['data']}" ) ) {
10250 $sub_dir = scandir( "{$elementor_dir}/{$value['data']}" );
10251 $sub = array_diff( $sub_dir, array( '.', '..' ) );
10252
10253 foreach ( $sub as $sub_dir_value ) {
10254 $file = new SplFileInfo( $sub_dir_value );
10255 $check_ext = $file->getExtension();
10256 $ext = pathinfo( $sub_dir_value, PATHINFO_EXTENSION );
10257
10258 if ( 'json' === $ext ) {
10259 $widget1 = WDKIT_BUILDER_PATH . "/{$value['builder']}/{$value['data']}/{$sub_dir_value}";
10260 $filedata = wp_json_file_decode( $widget1 );
10261 $decode_data = json_decode( wp_json_encode( $filedata ), true );
10262 array_push( $j_s_o_n_array, $decode_data['widget_data'] );
10263 }
10264 }
10265 }
10266 }
10267
10268 return $j_s_o_n_array;
10269 }
10270
10271 /**
10272 *
10273 * It is Use for manage widget category.
10274 *
10275 * @since 1.0.0
10276 */
10277 protected function wdkit_manage_widget_category() {
10278 $data = isset( $_POST['info'] ) ? sanitize_text_field( wp_unslash( $_POST['info'] ) ) : '';
10279 $data = json_decode( stripslashes( $data ) );
10280
10281 $type = isset( $data->manage_type ) ? sanitize_text_field( wp_unslash( $data->manage_type ) ) : '';
10282 $wkit_builder_option = get_option( 'wkit_builder' );
10283
10284 if ( empty( $wkit_builder_option ) ) {
10285 add_option( 'wkit_builder', array( 'WDesignKit' ), '', 'yes' );
10286 }
10287
10288 if ( 'get' === $type ) {
10289 if ( ! in_array( 'WDesignKit', $wkit_builder_option ) ) {
10290 update_option( 'wkit_builder', array( 'WDesignKit' ) );
10291 }
10292 } elseif ( 'update' === $type ) {
10293 $list = isset( $data->category_list ) ? $data->category_list : array();
10294 $list = array_unique( $list );
10295 $list = array_values( $list );
10296
10297 if ( ! empty( $list ) ) {
10298 update_option( 'wkit_builder', $list );
10299 }
10300 }
10301
10302 wp_send_json( get_option( 'wkit_builder' ) );
10303 }
10304
10305 /**
10306 * Get Workspace data
10307 *
10308 * @since 2.2.5
10309 */
10310 public function wdkit_get_workspace_data() {
10311
10312 $wid = isset( $_POST['wid'] ) ? sanitize_text_field( $_POST['wid'] ) : '';
10313 $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
10314
10315 if ( empty( $wid ) ) {
10316 return array(
10317 'success' => false,
10318 'message' => esc_html__( 'Workspace ID Not Found', 'wdesignkit' ),
10319 'description' => esc_html__( 'Workspace ID is required', 'wdesignkit' ),
10320 );
10321 }
10322
10323 if ( empty( $token ) ) {
10324 return array(
10325 'success' => false,
10326 'message' => esc_html__( 'Token Not Found', 'wdesignkit' ),
10327 'description' => esc_html__( 'Token is required', 'wdesignkit' ),
10328 );
10329 }
10330
10331 $args = array(
10332 'token' => $token,
10333 'wid' => $wid,
10334 );
10335
10336 $this->wdkit_api = $this->wdkit_api_v2;
10337
10338 $url = "workspace/{$wid}/get";
10339
10340 $response = $this->wkit_api_call( $args, $url );
10341
10342 wp_send_json( $response['data'] );
10343 wp_die();
10344 }
10345
10346 /**
10347 *
10348 * Custom_upload_dir
10349 *
10350 * @since 1.0.0
10351 *
10352 * @param array $upload store data.
10353 */
10354 public function custom_upload_dir( $upload ) {
10355 // Specify the path to your custom upload directory.
10356 if ( isset( $this->widget_folder_u_r_l ) && ! empty( $this->widget_folder_u_r_l ) ) {
10357
10358 // Set the custom directory as the upload path.
10359 $upload['path'] = $this->widget_folder_u_r_l;
10360 // Set the URL for the uploaded file.
10361 $upload['url'] = $upload['baseurl'] . $upload['subdir'];
10362 }
10363
10364 return $upload;
10365 }
10366
10367 /**
10368 *
10369 * It is Use for delete widget from server
10370 *
10371 * @since 1.0.0
10372 */
10373 protected function wkit_widget_json() {
10374 $widget_type = ! empty( $_POST['widget_type'] ) ? wp_unslash( $_POST['widget_type'] ) : '';
10375 $folder_name = ! empty( $_POST['folder_name'] ) ? wp_unslash( $_POST['folder_name'] ) : '';
10376 $file_name = ! empty( $_POST['file_name'] ) ? ( wp_unslash( $_POST['file_name'] ) ) : '';
10377
10378 if ( empty( $widget_type ) || empty( $folder_name ) || empty( $file_name ) ) {
10379 return array(
10380 'success' => false,
10381 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10382 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
10383 );
10384 }
10385
10386 // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
10387 // three segments arrive from $_POST with only wp_unslash() applied — which strips
10388 // nothing path-relevant — so "../" in any of them walked out of the builder directory
10389 // and this handler returned the decoded contents of any .json file the web server user
10390 // could read (CWE-22, ClickUp 86d41zaun).
10391 $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
10392
10393 if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
10394 return array(
10395 'success' => false,
10396 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10397 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10398 );
10399 }
10400
10401 $json_path = $safe_path['base'];
10402
10403 // Re-check the resolved file: the component guard above cannot see a symlink. Returns
10404 // false for a path that does not exist, which is the same answer we want anyway.
10405 if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
10406 return array(
10407 'success' => false,
10408 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10409 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
10410 );
10411 }
10412
10413 $json_data = wp_json_file_decode( "$json_path.json" );
10414 if ( ! empty( $json_data ) ) {
10415 $result = (object) array(
10416 'success' => true,
10417 'data' => $json_data,
10418 'message' => esc_html__( 'Widget get Successfully', 'wdesignkit' ),
10419 'description' => esc_html__( 'Widget JSON get Successfully', 'wdesignkit' ),
10420 );
10421 } else {
10422 $result = (object) array(
10423 'success' => false,
10424 'message' => esc_html__( 'Widget not get', 'wdesignkit' ),
10425 'description' => esc_html__( 'Widget JSON not get', 'wdesignkit' ),
10426 );
10427 }
10428
10429 wp_send_json( $result );
10430 wp_die();
10431 }
10432
10433 /**
10434 *
10435 * It is Use for download widget from widget listing.
10436 *
10437 * @since 1.0.0
10438 */
10439 protected function wdkit_download_widget() {
10440 $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
10441 $data = json_decode( stripslashes( $data ) );
10442
10443 $array_data = array(
10444 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
10445 'type' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
10446 'w_unique' => isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '',
10447 // Bug F fix: u_id (widget owner's user ID) was missing — cloud cannot locate the widget without it.
10448 'u_id' => isset( $data->u_id ) ? sanitize_text_field( $data->u_id ) : '',
10449 );
10450
10451 $response = $this->wkit_api_call( $array_data, 'save_widget' );
10452 $success = ! empty( $response['success'] ) ? $response['success'] : false;
10453
10454 if ( empty( $success ) ) {
10455 $massage = ! empty( $response['massage'] ) ? $response['massage'] : esc_html__( 'server error', 'wdesignkit' );
10456 $result = (object) array(
10457 'success' => false,
10458 'message' => $massage,
10459 'description' => esc_html__( ' Widget not Downloaded', 'wdesignkit' ),
10460 );
10461
10462 wp_send_json( $result );
10463 wp_die();
10464 }
10465
10466 $response = json_decode( wp_json_encode( $response['data'] ), true );
10467
10468 if ( empty( $response ) || empty( $response['data'] ) ) {
10469 $message = ! empty( $response['message'] ) ? $response['message'] : 'No Response Found';
10470 $description = ! empty( $response['description'] ) ? $response['description'] : 'Widget not Downloaded';
10471
10472 $result = (object) array(
10473 'success' => false,
10474 'message' => esc_html( $message ),
10475 'description' => esc_html( $description ),
10476 );
10477
10478 wp_send_json( $result );
10479 wp_die();
10480 }
10481
10482 $img_url = ! empty( $response['data']['image'] ) ? $response['data']['image'] : '';
10483 $json_data = ! empty( $response['data']['json'] ) ? json_decode( $response['data']['json'], true ) : '';
10484
10485 // Bug E fix (part 1): $responce was a typo of $response — sent undefined variable (null) to frontend.
10486 if ( empty( $response['success'] ) ) {
10487 wp_send_json( $response );
10488 wp_die();
10489 }
10490
10491 if ( empty( $img_url ) && empty( $json_data ) ) {
10492 $responce = (object) array(
10493 'success' => false,
10494 'message' => esc_html__( 'No Response Found', 'wdesignkit' ),
10495 'description' => esc_html__( 'Widget not Downloaded', 'wdesignkit' ),
10496 );
10497
10498 wp_send_json( $responce );
10499 wp_die();
10500 }
10501
10502 include_once ABSPATH . 'wp-admin/includes/file.php';
10503 \WP_Filesystem();
10504 global $wp_filesystem;
10505
10506 if ( ! is_array( $json_data ) ) {
10507 $json_data = json_decode( $json_data, true );
10508 }
10509
10510 // Sanitize as filenames before use in the widget path (CWE-22): sanitize_file_name()
10511 // on name/id and sanitize_key() + allowlist on the builder strip path separators and
10512 // dots so a crafted cloud response cannot escape WDKIT_BUILDER_PATH.
10513 $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['name'] ) : '';
10514 $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_key( $json_data['widget_data']['widgetdata']['type'] ) : '';
10515 $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
10516
10517 $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
10518 if ( '' === $title || '' === $w_uniq || ! in_array( $builder, $allowed_builders, true ) ) {
10519 $responce = (object) array(
10520 'success' => false,
10521 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
10522 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10523 );
10524
10525 wp_send_json( $responce );
10526 wp_die();
10527 }
10528
10529 // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
10530 // already sanitized, which collapsed spaces to hyphens and left the underscore pass
10531 // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
10532 // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
10533 // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
10534 $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10535 $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
10536 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
10537
10538 if ( ! is_dir( $builder_type_path ) ) {
10539 wp_mkdir_p( $builder_type_path );
10540 }
10541
10542 if ( ! is_dir( $builder_type_path . $folder_name ) ) {
10543 wp_mkdir_p( $builder_type_path . $folder_name );
10544 }
10545
10546 if ( ! empty( $img_url ) ) {
10547 // SSRF guard (CWE-918): validate the resolved host before fetching.
10548 $img_body = wdesignkit_safe_remote_get( $img_url );
10549 if ( ! is_wp_error( $img_body ) ) {
10550 // The remote extension was written verbatim here, so a cloud response naming a
10551 // ".php" image put executable PHP in the builder directory (CWE-434,
10552 // ClickUp 86d41cczd). An empty return means the bytes are not an image.
10553 $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
10554
10555 if ( '' !== $img_ext ) {
10556 $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
10557 $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10558 }
10559 }
10560 }
10561
10562 if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
10563 wdesignkit_invalidate_widget_registry( $builder );
10564 }
10565
10566 // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
10567 $result = (object) array(
10568 'success' => true,
10569 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
10570 'description' => '',
10571 'json' => wp_json_encode( $json_data ),
10572 );
10573
10574 wp_send_json( $result );
10575 wp_die();
10576 }
10577
10578 /**
10579 *
10580 * It is Use for sync widget to server
10581 *
10582 * @since 1.0.0
10583 */
10584 protected function wdkit_add_widget() {
10585 $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
10586 $data = base64_decode( $data );
10587 $data = json_decode( $data );
10588
10589 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
10590 $builder = isset( $data->builder ) ? sanitize_text_field( $data->builder ) : '';
10591 $w_uniq = isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '';
10592 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
10593
10594 if ( ! empty( $w_image ) ) {
10595 $w_image = str_replace( '\\', '', $w_image );
10596 // SSRF guard (CWE-918): validate the resolved host before fetching.
10597 $fetched = wdesignkit_safe_remote_get( $w_image );
10598 $w_image = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
10599 }
10600
10601 $array_data = array(
10602 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
10603 'type' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
10604 'title' => isset( $data->title ) ? sanitize_text_field( $data->title ) : '',
10605 'content' => isset( $data->content ) ? sanitize_text_field( $data->content ) : '',
10606 'builder' => isset( $data->builder ) ? sanitize_text_field( $data->builder ) : '',
10607 'w_data' => isset( $data->w_data ) ? $data->w_data : '',
10608 'w_unique' => isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '',
10609 'w_image' => $w_image,
10610 'w_imgext' => isset( $data->w_imgext ) ? sanitize_text_field( $data->w_imgext ) : '',
10611 'w_version' => isset( $data->w_version ) ? $data->w_version : '',
10612 'w_updates' => ! empty( $data->w_updates ) ? serialize( $data->w_updates ) : serialize( array() ),
10613 'r_id' => isset( $data->r_id ) ? $data->r_id : 0,
10614 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
10615 );
10616
10617 $response = $this->wkit_api_call( $array_data, 'save_widget' );
10618 $success = ! empty( $response['success'] ) ? $response['success'] : false;
10619
10620 if ( empty( $success ) ) {
10621 $massage = ! empty( $response['massage'] ) ? $response['massage'] : esc_html__( 'server error', 'wdesignkit' );
10622
10623 $result = (object) array(
10624 'success' => false,
10625 'message' => $massage,
10626 'description' => esc_html__( 'Widget Not Added', 'wdesignkit' ),
10627 );
10628
10629 wp_send_json( $result );
10630 wp_die();
10631 }
10632
10633 $res = ! empty( $response['data'] ) ? $response['data'] : array();
10634
10635 $response = json_decode( wp_json_encode( $res ), true );
10636 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
10637
10638 if ( ! empty( $img_url ) && 'error' !== $res ) {
10639
10640 // SSRF guard (CWE-918): validate the resolved host before fetching.
10641 $img_body = wdesignkit_safe_remote_get( $img_url );
10642 if ( ! is_wp_error( $img_body ) ) {
10643 // Verified against the payload rather than trusted from the URL (CWE-434,
10644 // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
10645 $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
10646 include_once ABSPATH . 'wp-admin/includes/file.php';
10647 \WP_Filesystem();
10648 global $wp_filesystem;
10649 // Canonical helpers, so the JSON read and the image write here address the same
10650 // base name every other writer uses (ClickUp 86d41cck5). They also apply
10651 // sanitize_file_name(), which $title and $w_uniq had not been through.
10652 $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10653 $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
10654
10655 // $builder reaches here with only sanitize_text_field() applied and no
10656 // allowlist, so it was a live traversal segment in this path (CWE-22,
10657 // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
10658 // one had neither the builder allowlist nor a containment check.
10659 $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
10660 if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
10661 wp_send_json(
10662 (object) array(
10663 'success' => false,
10664 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
10665 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10666 )
10667 );
10668 wp_die();
10669 }
10670
10671 $builder = $safe_path['builder'];
10672 $file_path = $safe_path['base'];
10673
10674 $u_r_l = wp_json_file_decode( "$file_path.json" );
10675
10676 if ( '' !== $img_ext ) {
10677 $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10678 $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
10679 }
10680
10681 $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
10682 }
10683 }
10684
10685 wp_send_json( $response );
10686 wp_die();
10687 }
10688
10689 /**
10690 *
10691 * It is Use for manage favourite widget
10692 *
10693 * @since 1.0.0
10694 */
10695 protected function wdkit_favourite_widget() {
10696 $data = isset( $_POST['widget_info'] ) ? json_decode( stripslashes( sanitize_text_field( wp_unslash( $_POST['widget_info'] ) ) ) ) : '';
10697 $array_data = array(
10698 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
10699 'type' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
10700 'w_unique' => isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '',
10701 );
10702
10703 $response = $this->wkit_api_call( $array_data, 'save_widget' );
10704 $success = ! empty( $response['success'] ) ? $response['success'] : false;
10705
10706 if ( empty( $success ) ) {
10707 $massage = ! empty( $response['massage'] ) ? $response['massage'] : esc_html__( 'server error', 'wdesignkit' );
10708
10709 $result = (object) array(
10710 'success' => false,
10711 'message' => $massage,
10712 'description' => '',
10713 );
10714
10715 wp_send_json( $result );
10716 wp_die();
10717 }
10718
10719 wp_send_json( $response );
10720 wp_die();
10721 }
10722
10723 /**
10724 * It is Used Setting Panel Defalut Data Get.
10725 *
10726 * @since 1.0.0
10727 */
10728 protected function wdkit_setting_panel() {
10729 $event = ! empty( $_POST['event'] ) ? sanitize_text_field( wp_unslash( $_POST['event'] ) ) : 'get';
10730
10731 if ( 'get' === $event ) {
10732 return self::wkit_get_settings_panel();
10733 } elseif ( 'set' === $event ) {
10734 $data = ! empty( $_POST['data'] ) ? stripslashes( sanitize_text_field( wp_unslash( $_POST['data'] ) ) ) : array();
10735
10736 $data = json_decode( $data, true );
10737
10738 update_option( 'wkit_settings_panel', $data );
10739 return self::wkit_get_settings_panel();
10740 } else {
10741 return false;
10742 }
10743 }
10744
10745 /**
10746 * Get Setting Panal Data
10747 *
10748 * @since 1.0.0
10749 */
10750 protected static function wkit_get_settings_panel() {
10751 $new_version = '';
10752 $current_version = WDKIT_VERSION;
10753 $response = wp_remote_get( 'https://api.wordpress.org/plugins/info/1.0/wdesignkit.json' );
10754
10755 if ( is_wp_error( $response ) ) {
10756 return false;
10757 }
10758
10759 $body = wp_remote_retrieve_body( $response );
10760 $data = json_decode( $body );
10761
10762 if ( isset( $data->version ) ) {
10763 $new_version = $data->version;
10764 }
10765
10766 $version_check = array();
10767
10768 if ( $new_version && version_compare( $current_version, $new_version, '<' ) ) {
10769 $version_check['success'] = true;
10770 $version_check['version'] = $new_version;
10771 } else {
10772 $version_check['success'] = false;
10773 $version_check['version'] = $new_version;
10774 }
10775
10776 $get_setting = get_option( 'wkit_settings_panel', false );
10777
10778 $setting_data = array(
10779 'builder' => isset( $get_setting['builder'] ) ? $get_setting['builder'] : true,
10780 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
10781 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
10782 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
10783 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
10784 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
10785 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
10786 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
10787 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
10788 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
10789 'cross_copy_paste_elementor' => isset( $get_setting['cross_copy_paste_elementor'] ) ? $get_setting['cross_copy_paste_elementor'] : false,
10790 'cross_copy_paste_gutenberg' => isset( $get_setting['cross_copy_paste_gutenberg'] ) ? $get_setting['cross_copy_paste_gutenberg'] : false,
10791 'cross_copy_paste_bricks' => isset( $get_setting['cross_copy_paste_bricks'] ) ? $get_setting['cross_copy_paste_bricks'] : false,
10792 'plugin_version' => $version_check,
10793 );
10794
10795 if ( isset( $get_setting['remove_db'] ) ) {
10796 $setting_data['remove_db'] = $get_setting['remove_db'];
10797 }
10798
10799 if ( isset( $get_setting['debugger_mode'] ) ) {
10800 $setting_data['debugger_mode'] = $get_setting['debugger_mode'];
10801 }
10802
10803 return $setting_data;
10804 }
10805
10806 /**
10807 * Updated White Label Data.
10808 *
10809 * @since 1.1.8
10810 */
10811 protected function wkit_white_label() {
10812
10813 $get_wl_data = ! empty( $_POST['WhiteLabelData'] ) ? wp_unslash( $_POST['WhiteLabelData'] ) : array();
10814
10815 if ( ! empty( $get_wl_data ) ) {
10816 $white_label_data = json_decode( $get_wl_data, true );
10817 $plugin_name = $white_label_data['plugin_name'];
10818 } else {
10819 $result = array(
10820 'success' => false,
10821 'message' => esc_html__( 'Data Not Found', 'wdesignkit' ),
10822 );
10823
10824 wp_send_json( $result );
10825 wp_die();
10826 }
10827
10828 if ( ! empty( $plugin_name ) ) {
10829 $get_white_label = get_option( 'wkit_white_label', false );
10830 if ( ! empty( $get_white_label ) ) {
10831 update_option( 'wkit_white_label', $white_label_data );
10832 } else {
10833 add_option( 'wkit_white_label', $white_label_data );
10834 }
10835 } else {
10836 $result = array(
10837 'success' => false,
10838 'message' => esc_html__( 'Plugin Name Not Found', 'wdesignkit' ),
10839 );
10840
10841 wp_send_json( $result );
10842 wp_die();
10843 }
10844
10845 $get_updated_data = get_option( 'wkit_white_label', false );
10846 $response = array(
10847 'message' => __( 'Data Added successfully', 'wdesignkit' ),
10848 'success' => true,
10849 'data' => $get_updated_data,
10850 );
10851
10852 wp_send_json( $response );
10853 }
10854
10855 /**
10856 * Reset White Label Data.
10857 *
10858 * @since 1.1.8
10859 */
10860 public function wkit_reset_wl() {
10861 $wl_data = get_option( 'wkit_white_label' );
10862
10863 if ( ! empty( $wl_data ) ) {
10864 delete_option( 'wkit_white_label' );
10865
10866 $result = array(
10867 'success' => true,
10868 'message' => esc_html__( 'Reset White Label Successfully', 'wdesignkit' ),
10869 );
10870
10871 wp_send_json( $result );
10872 wp_die();
10873 }
10874 }
10875
10876 /**
10877 *
10878 * Use for Add new licence key.
10879 *
10880 * @since 1.0.0
10881 */
10882 protected function wdkit_activate_licence() {
10883 $args = array(
10884 'token' => ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '',
10885 'licencekey' => ! empty( $_POST['licencekey'] ) ? sanitize_text_field( wp_unslash( $_POST['licencekey'] ) ) : '',
10886 'licencename' => ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '',
10887 'uichemyid' => ! empty( $_POST['uichemyid'] ) ? sanitize_text_field( wp_unslash( $_POST['uichemyid'] ) ) : '',
10888 );
10889
10890 $response = $this->wkit_api_call( $args, 'wkit_activate_key' );
10891
10892 if ( ! empty( $response['data'] ) ) {
10893 $response = json_decode( wp_json_encode( $response['data'] ), true );
10894
10895 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
10896 $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
10897 }
10898
10899 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
10900 $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
10901 }
10902
10903 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
10904 $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
10905 }
10906
10907 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
10908 $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
10909
10910 // Store WDesignKit license status locally for quick access
10911 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
10912 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
10913 }
10914 }
10915
10916 if ( ! empty( $response['data']['wdkit_licence_extra'] ) && is_serialized( $response['data']['wdkit_licence_extra'] ) ) {
10917 $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
10918 }
10919 }
10920
10921 wp_send_json( $response );
10922 wp_die();
10923 }
10924
10925 /**
10926 *
10927 * Use for Delete licence key.
10928 *
10929 * @since 1.0.0
10930 */
10931 protected function wdkit_delete_licence_key() {
10932 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
10933 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
10934 $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
10935
10936 $args = array(
10937 'token' => $token,
10938 'licencename' => $licencename,
10939 'apikey' => $apikey,
10940 );
10941
10942 $response = $this->wkit_api_call( $args, 'licence_delete' );
10943
10944 // Remove local WDesignKit license data if deleting WDesignKit license
10945 if ( 'wdkit' === $licencename ) {
10946 delete_option( 'wdkit_licence_data' );
10947 }
10948
10949 wp_send_json( $response['data'] );
10950 wp_die();
10951 }
10952
10953 /**
10954 *
10955 * Use for Sync licence key.
10956 *
10957 * @since 1.0.0
10958 */
10959 protected function wdkit_sync_licence_key() {
10960 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
10961 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
10962 // Needed to identify which extra-credit key to sync (wdkit_extra / wdkit_ai_extra
10963 // are arrays matched by the api key's last digits on the server).
10964 $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
10965
10966 $args = array(
10967 'token' => $token,
10968 'licencename' => $licencename,
10969 'apikey' => $apikey,
10970 );
10971
10972 $response = $this->wkit_api_call( $args, 'licence_sync' );
10973
10974 wp_send_json( $response['data'] );
10975 wp_die();
10976 }
10977
10978 /**
10979 * Rollback to Previous Versions
10980 *
10981 * @since 1.1.0
10982 */
10983 protected function wdkit_prev_version() {
10984
10985 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
10986
10987 $plugin_info = plugins_api(
10988 'plugin_information',
10989 array(
10990 'slug' => 'wdesignkit',
10991 )
10992 );
10993
10994 if ( empty( $plugin_info->versions ) || ! is_array( $plugin_info->versions ) ) {
10995 return array();
10996 }
10997
10998 krsort( $plugin_info->versions );
10999
11000 $versions_list = array();
11001 $index = 0;
11002
11003 foreach ( $plugin_info->versions as $version => $download_link ) {
11004
11005 $lowercase_version = strtolower( $version );
11006
11007 $is_valid_version = ! preg_match( '/(beta|rc|trunk|dev)/i', $lowercase_version );
11008
11009 $is_valid_version = apply_filters( 'wdkit_check_rollback_version', $is_valid_version, $lowercase_version );
11010
11011 if ( ! $is_valid_version || version_compare( $version, WDKIT_VERSION, '>=' ) ) {
11012 continue;
11013 }
11014
11015 $versions_list[] = $version;
11016 ++$index;
11017 }
11018
11019 // set_transient( 'wdkit_rollback_version_' . WDKIT_VERSION, $versions_list, WEEK_IN_SECONDS );
11020
11021 return $versions_list;
11022 }
11023
11024 /**
11025 * Rollback to Previous Versions
11026 *
11027 * @since 1.1.0
11028 */
11029 protected function wdkit_rollback_check() {
11030
11031 $current_ver = isset( $_POST['version'] ) ? sanitize_text_field( wp_unslash( $_POST['version'] ) ) : '';
11032 $rv = $this->wdkit_prev_version();
11033
11034 if ( empty( $current_ver ) || ! in_array( $current_ver, $rv ) ) {
11035 return array(
11036 'message' => esc_html__( 'Invalid Nonce or version not found', 'wdesignkit' ),
11037 'status' => 'error',
11038 'success' => false,
11039 );
11040 }
11041
11042 $plugin_slug = basename( WDKIT_PBNAME, '.php' );
11043
11044 $this_version = $current_ver;
11045 $this_pluginname = WDKIT_PBNAME;
11046 $this_plugin_u_r_l = sprintf( 'https://downloads.wordpress.org/plugin/%s.%s.zip', $plugin_slug, $this_version );
11047
11048 $plugin_info = new \stdClass();
11049 $plugin_info->new_version = $this_version;
11050 $plugin_info->slug = $plugin_slug;
11051 $plugin_info->package = $this_plugin_u_r_l;
11052 $plugin_info->url = 'https://wdesignkit.com/';
11053
11054 $update_plugins_data = get_site_transient( 'update_plugins' );
11055
11056 if ( ! is_object( $update_plugins_data ) ) {
11057 $update_plugins_data = new \stdClass();
11058 }
11059
11060 $update_plugins_data->response[ $this_pluginname ] = $plugin_info;
11061
11062 set_site_transient( 'update_plugins', $update_plugins_data );
11063
11064 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
11065
11066 $logo_url = WDKIT_URL . 'assets/images/jpg/Wdesignkit-logo.png';
11067
11068 $args = array(
11069 'url' => 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $this_pluginname ),
11070 'plugin' => $this_pluginname,
11071 'nonce' => 'upgrade-plugin_' . $this_pluginname,
11072 'title' => '<img src="' . esc_url( $logo_url ) . '" alt="wdesignkit-logo"><div class="theplus-rb-subtitle">' . esc_html__( 'Rollback to Previous Version', 'wdesignkit' ) . '</div>',
11073 );
11074
11075 $upgrader_plugin = new \Plugin_Upgrader( new \Plugin_Upgrader_Skin( $args ) );
11076 $upgrader_plugin->upgrade( $this_pluginname );
11077
11078 activate_plugin( $this_pluginname );
11079
11080 return array(
11081 'message' => esc_html__( 'Rollback Successful, Plugin Re-activated', 'wdesignkit' ),
11082 'status' => 'Success',
11083 'success' => true,
11084 );
11085 }
11086
11087 /**
11088 *
11089 * It is Use for logout.
11090 *
11091 * @since 1.0.0
11092 */
11093 protected function wdkit_logout() {
11094 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
11095 $logout_type = isset( $_POST['logout_type'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['logout_type'] ) ) ) : '';
11096
11097 $response = '';
11098
11099 if ( ! empty( $email ) ) {
11100 $token = $this->wdkit_login_user_token( $email );
11101 $args = array( 'token' => $token );
11102
11103 if ( 'session' !== $logout_type ) {
11104 delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
11105
11106 /* Logged out means the site no longer acts for this account - see
11107 * Wdkit_Import_Remote::forget_registration(). */
11108 if ( class_exists( 'Wdkit_Import_Remote' ) ) {
11109 Wdkit_Import_Remote::forget_registration();
11110 }
11111
11112 // Clear stored license data on logout so banner shows again
11113 delete_option( 'wdkit_licence_data' );
11114 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
11115 }
11116 }
11117
11118 wp_send_json( $response );
11119 wp_die();
11120 }
11121
11122 /**
11123 *
11124 * It is Use for get token of login user.
11125 *
11126 * @since 1.0.0
11127 *
11128 * @param string $email check user email.
11129 */
11130 protected function wdkit_login_user_token( $email = '' ) {
11131
11132 if ( ! empty( $email ) ) {
11133 $user_key = wdesignkit_cloud_session_key( $email );
11134 $get_login = get_transient( 'wdkit_auth_' . $user_key );
11135
11136 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
11137 return $get_login['token'];
11138 }
11139 }
11140
11141 return false;
11142 }
11143
11144 /**
11145 * Parse args $_POST
11146 *
11147 * @since 1.0.0
11148 *
11149 * @param string $data send all post data.
11150 * @param string $type store text data.
11151 * @param string $condition store text data.
11152 */
11153 protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
11154
11155 if ( 'none' === $condition ) {
11156 return $data[ $type ];
11157 } elseif ( 'cr_widget' === $condition ) {
11158 return $data[ $type ];
11159 }
11160
11161 return null;
11162 }
11163
11164
11165 /**
11166 * Parse args $_POST
11167 *
11168 * @since 1.0.0
11169 *
11170 * @param string $data send all post data.
11171 */
11172 protected function wdkit_parse_args( $data = array() ) {
11173 if ( empty( $data ) ) {
11174 return array();
11175 }
11176
11177 $args = array();
11178 if ( isset( $data['email'] ) ) {
11179 $args['email'] = isset( $data['email'] ) ? sanitize_email( $data['email'] ) : '';
11180 }
11181
11182 if ( isset( $data['data'] ) ) {
11183 $args['data'] = isset( $data['data'] ) ? wp_unslash( $data['data'] ) : array();
11184 }
11185
11186 if ( isset( $data['plugins'] ) ) {
11187 $args['plugins'] = isset( $data['plugins'] ) ? wp_unslash( $data['plugins'] ) : array();
11188 }
11189
11190 if ( isset( $data['template_id'] ) ) {
11191 $args['template_id'] = isset( $data['template_id'] ) ? intval( strtolower( sanitize_text_field( $data['template_id'] ) ) ) : '';
11192 }
11193
11194 if ( isset( $data['builder'] ) ) {
11195 $args['builder'] = isset( $data['builder'] ) ? wp_unslash( $data['builder'] ) : '';
11196 }
11197
11198 if ( isset( $data['editor'] ) ) {
11199 $args['editor'] = isset( $data['editor'] ) ? sanitize_text_field( $data['editor'] ) : '';
11200 }
11201
11202 if ( isset( $data['type_upload'] ) ) {
11203 $args['type_upload'] = isset( $data['type_upload'] ) ? sanitize_text_field( $data['type_upload'] ) : '';
11204 }
11205
11206 if ( isset( $data['title'] ) ) {
11207 $args['title'] = isset( $data['title'] ) ? wp_strip_all_tags( $data['title'] ) : '';
11208 }
11209
11210 if ( isset( $data['template_type'] ) ) {
11211 $args['template_type'] = isset( $data['template_type'] ) ? sanitize_text_field( $data['template_type'] ) : '';
11212 }
11213
11214 if ( isset( $data['wstype'] ) ) {
11215 $args['wstype'] = isset( $data['wstype'] ) ? wp_strip_all_tags( $data['wstype'] ) : '';
11216 }
11217
11218 if ( isset( $data['wid'] ) ) {
11219 $args['wid'] = isset( $data['wid'] ) ? intval( strtolower( sanitize_text_field( $data['wid'] ) ) ) : '';
11220 }
11221
11222 if ( isset( $data['perpage'] ) ) {
11223 $args['perpage'] = isset( $data['perpage'] ) ? intval( strtolower( sanitize_text_field( $data['perpage'] ) ) ) : 12;
11224 }
11225
11226 if ( isset( $data['page'] ) ) {
11227 $args['page'] = isset( $data['page'] ) ? intval( strtolower( sanitize_text_field( $data['page'] ) ) ) : 1;
11228 }
11229
11230 if ( isset( $data['buildertype'] ) ) {
11231 $args['buildertype'] = isset( $data['buildertype'] ) ? sanitize_text_field( $data['buildertype'] ) : '';
11232 }
11233
11234 if ( isset( $data['search'] ) ) {
11235 $args['search'] = isset( $data['search'] ) ? sanitize_text_field( $data['search'] ) : '';
11236 }
11237
11238 if ( isset( $data['plugin'] ) ) {
11239 $args['plugin'] = isset( $data['plugin'] ) ? wp_unslash( $data['plugin'] ) : array();
11240 }
11241
11242 if ( isset( $data['plugin_exclude'] ) ) {
11243 $args['plugin_exclude'] = isset( $data['plugin_exclude'] ) ? wp_unslash( $data['plugin_exclude'] ) : array();
11244 }
11245
11246 if ( isset( $data['ai_compatibility'] ) ) {
11247 $args['ai_compatibility'] = isset( $data['ai_compatibility'] ) ? wp_unslash( $data['ai_compatibility'] ) : array();
11248 }
11249
11250 // if ( isset( $data['global_color'] ) ) {
11251 // $args['global_color'] = isset( $data['global_color'] ) ? wp_unslash( $data['global_color'] ) : array();
11252 // }
11253
11254 // if ( isset( $data['global_font_family'] ) ) {
11255 // $args['global_font_family'] = isset( $data['global_font_family'] ) ? wp_unslash( $data['global_font_family'] ) : array();
11256 // }
11257
11258 if ( isset( $data['global_data'] ) ) {
11259 $args['global_data'] = isset( $data['global_data'] ) ? wp_unslash( $data['global_data'] ) : array();
11260 }
11261
11262 if ( isset( $data['tag'] ) ) {
11263 $args['tag'] = isset( $data['tag'] ) ? wp_unslash( $data['tag'] ) : array();
11264 }
11265
11266 if ( isset( $data['category'] ) ) {
11267 $args['category'] = isset( $data['category'] ) ? wp_unslash( $data['category'] ) : array();
11268 }
11269
11270 if ( isset( $data['free_pro'] ) ) {
11271 $args['free_pro'] = isset( $data['free_pro'] ) ? sanitize_text_field( wp_unslash( $data['free_pro'] ) ) : '';
11272 }
11273
11274 if ( isset( $data['wp_post_type'] ) ) {
11275 $args['wp_post_type'] = isset( $data['wp_post_type'] ) ? sanitize_text_field( wp_unslash( $data['wp_post_type'] ) ) : '';
11276 }
11277
11278 if ( isset( $data['favorite'] ) ) {
11279 $args['favorite'] = isset( $data['favorite'] ) ? sanitize_text_field( wp_unslash( $data['favorite'] ) ) : '';
11280 }
11281
11282 if ( isset( $data['content'] ) ) {
11283 $args['content'] = isset( $data['content'] ) ? wp_unslash( $data['content'] ) : '';
11284 }
11285
11286 if ( isset( $data['page_type'] ) ) {
11287 $args['page_type'] = isset( $data['page_type'] ) ? wp_unslash( $data['page_type'] ) : array();
11288 }
11289
11290 return $args;
11291 }
11292
11293 /**
11294 * Dark Mode
11295 *
11296 * @since 2.0.0
11297 *
11298 * @param string store darkmode value in database.
11299 */
11300 protected function wdkit_dark_mode() {
11301 $dark_mode = ! empty( $_POST['dark_mode'] ) ? sanitize_text_field( $_POST['dark_mode'] ) : 'light';
11302
11303 if ( get_option( 'wdkit_dark_mode' ) ) {
11304 update_option( 'wdkit_dark_mode', $dark_mode );
11305 } else {
11306 add_option( 'wdkit_dark_mode', $dark_mode );
11307 }
11308
11309 $response = array(
11310 'message' => esc_html__( 'Dark Mode Updated', 'wdesignkit' ),
11311 'status' => 'Success',
11312 'success' => true,
11313 );
11314
11315 wp_send_json( $response );
11316 wp_die();
11317 }
11318
11319 /**
11320 * The Gutenberg global-options record a site starts from.
11321 *
11322 * Nexter has no defaults of its own until something writes `tpgb_global_options`, and
11323 * this is what WDesignKit seeds it with: five base colours, the base type scale, the
11324 * gradients, spacing and box-shadow entries, and an empty container width.
11325 *
11326 * Extracted from wdkit_get_global_val() so the PHP import runner can seed the same
11327 * record. The runner appends the kit's palette to whatever the site already has, and on
11328 * a site where nothing had written the option yet it was appending to NOTHING — a
11329 * headless import came out with 13 colours instead of 18, 12 type entries instead of 19
11330 * and no gradients, spacing or box shadows at all, because the baseline it should have
11331 * built on did not exist. The wizard never saw it: the globals screen calls
11332 * wdkit_get_global_val() first, which seeds the option as a side effect.
11333 *
11334 * @since 2.7.2
11335 *
11336 * @return array
11337 */
11338 public static function wdkit_default_gutenberg_globals() {
11339 return array(
11340 'active' => 'preset1',
11341 'darkMode' => 'none',
11342 'presets' => array(
11343 'preset1' => array(
11344 'name' => 'Preset 1',
11345 'key' => 'preset1',
11346 'colors' => array(
11347 array(
11348 'label' => 'Primary',
11349 'value' => '#8072FC',
11350 ),
11351 array(
11352 'label' => 'Secondary',
11353 'value' => '#6FC784',
11354 ),
11355 array(
11356 'label' => 'Tertiary',
11357 'value' => '#FF5A6E',
11358 ),
11359 array(
11360 'label' => 'Accent',
11361 'value' => '#F3F3F3',
11362 ),
11363 array(
11364 'label' => 'Background',
11365 'value' => '#888888',
11366 ),
11367 ),
11368 'gradient' => array(
11369 array(
11370 'label' => 'Primary',
11371 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11372 ),
11373
11374 array(
11375 'label' => 'Secondary',
11376 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11377 ),
11378
11379 array(
11380 'label' => 'Tertiary',
11381 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11382 ),
11383
11384 array(
11385 'label' => 'Accent',
11386 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11387 ),
11388
11389 array(
11390 'label' => 'Background',
11391 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11392 ),
11393 ),
11394 'spacing' => array(
11395 array(
11396 'label' => 'Large',
11397 'value' => array(
11398 'md' => 70,
11399 'unit' => 'px',
11400 ),
11401 ),
11402 array(
11403 'label' => 'Medium',
11404 'value' => array(
11405 'md' => 40,
11406 'unit' => 'px',
11407 ),
11408 ),
11409 array(
11410 'label' => 'Small',
11411 'value' => array(
11412 'md' => 20,
11413 'unit' => 'px',
11414 ),
11415
11416 ),
11417 ),
11418 'typography' => array(
11419 array(
11420 'label' => 'Display Text',
11421 'value' => array(
11422 'openTypography' => 1,
11423 'size' => array(
11424 'md' => 65,
11425 'unit' => 'px',
11426 ),
11427 'height' => array(
11428 'md' => 75,
11429 'unit' => 'px',
11430 ),
11431 'fontFamily' => array(
11432 'family' => 'Roboto',
11433 'type' => 'sans-serif',
11434 'fontWeight' => 700,
11435 ),
11436 'spacing' => array(
11437 'md' => 0,
11438 'unit' => 'px',
11439 ),
11440 ),
11441 ),
11442 array(
11443 'label' => 'Headline',
11444 'value' => array(
11445 'openTypography' => 1,
11446 'size' => array(
11447 'md' => 45,
11448 'unit' => 'px',
11449 ),
11450 'height' => array(
11451 'md' => 60,
11452 'unit' => 'px',
11453 ),
11454 'fontFamily' => array(
11455 'family' => 'Roboto',
11456 'type' => 'sans-serif',
11457 'fontWeight' => 700,
11458 ),
11459 'spacing' => array(
11460 'md' => 0,
11461 'unit' => 'px',
11462 ),
11463 ),
11464 ),
11465 array(
11466 'label' => 'Sub Headline',
11467 'value' => array(
11468 'openTypography' => 1,
11469 'size' => array(
11470 'md' => 38,
11471 'unit' => 'px',
11472 ),
11473 'height' => array(
11474 'md' => 45,
11475 'unit' => 'px',
11476 ),
11477 'fontFamily' => array(
11478 'family' => 'Roboto',
11479 'type' => 'sans-serif',
11480 'fontWeight' => 500,
11481 ),
11482 'spacing' => array(
11483 'md' => 0,
11484 'unit' => 'px',
11485 ),
11486 ),
11487 ),
11488 array(
11489 'label' => 'Title 1',
11490 'value' => array(
11491 'openTypography' => 1,
11492 'size' => array(
11493 'md' => 30,
11494 'unit' => 'px',
11495 ),
11496 'height' => array(
11497 'md' => 40,
11498 'unit' => 'px',
11499 ),
11500 'fontFamily' => array(
11501 'family' => 'Roboto',
11502 'type' => 'sans-serif',
11503 'fontWeight' => 500,
11504 ),
11505 'spacing' => array(
11506 'md' => 0,
11507 'unit' => 'px',
11508 ),
11509 ),
11510 ),
11511 array(
11512 'label' => 'Title 2',
11513 'value' => array(
11514 'openTypography' => 1,
11515 'size' => array(
11516 'md' => 25,
11517 'unit' => 'px',
11518 ),
11519 'height' => array(
11520 'md' => 30,
11521 'unit' => 'px',
11522 ),
11523 'fontFamily' => array(
11524 'family' => 'Roboto',
11525 'type' => 'sans-serif',
11526 'fontWeight' => 400,
11527 ),
11528 'spacing' => array(
11529 'md' => 0,
11530 'unit' => 'px',
11531 ),
11532 ),
11533 ),
11534 array(
11535 'label' => 'Body',
11536 'value' => array(
11537 'openTypography' => 1,
11538 'size' => array(
11539 'md' => 17,
11540 'unit' => 'px',
11541 ),
11542 'height' => array(
11543 'md' => 22,
11544 'unit' => 'px',
11545 ),
11546 'fontFamily' => array(
11547 'family' => 'Roboto',
11548 'type' => 'sans-serif',
11549 'fontWeight' => 400,
11550 ),
11551 'spacing' => array(
11552 'md' => 0,
11553 'unit' => 'px',
11554 ),
11555 ),
11556 ),
11557 array(
11558 'label' => 'Captions',
11559 'value' => array(
11560 'openTypography' => 1,
11561 'size' => array(
11562 'md' => 13,
11563 'unit' => 'px',
11564 ),
11565 'height' => array(
11566 'md' => 16,
11567 'unit' => 'px',
11568 ),
11569 'fontFamily' => array(
11570 'family' => 'Roboto',
11571 'type' => 'sans-serif',
11572 'fontWeight' => 400,
11573 ),
11574 'spacing' => array(
11575 'md' => 0,
11576 'unit' => 'px',
11577 ),
11578 ),
11579 ),
11580 ),
11581 'boxshadow' => array(
11582 array(
11583 'label' => 'Normal Shadow',
11584 'value' => array(
11585 'openShadow' => 1,
11586 'inset' => 0,
11587 'horizontal' => 2,
11588 'vertical' => 6,
11589 'blur' => 10,
11590 'spread' => 0,
11591 'color' => 'rgba(0,0,0,0.15)',
11592 ),
11593 ),
11594 array(
11595 'label' => 'Hover Shadow',
11596 'value' => array(
11597 'openShadow' => 1,
11598 'inset' => 0,
11599 'horizontal' => 2,
11600 'vertical' => 5,
11601 'blur' => 14,
11602 'spread' => 3,
11603 'color' => 'rgba(0,0,0,0.2)',
11604 ),
11605 ),
11606 ),
11607 ),
11608 'preset2' => array(
11609 'name' => 'Preset 2',
11610 'key' => 'preset2',
11611 'colors' => array(
11612 array(
11613 'label' => 'Primary',
11614 'value' => '#8072FC',
11615 ),
11616 array(
11617 'label' => 'Secondary',
11618 'value' => '#6FC784',
11619 ),
11620 array(
11621 'label' => 'Tertiary',
11622 'value' => '#FF5A6E',
11623 ),
11624 array(
11625 'label' => 'Accent',
11626 'value' => '#F3F3F3',
11627 ),
11628 array(
11629 'label' => 'Background',
11630 'value' => '#888888',
11631 ),
11632 ),
11633 'gradient' => array(
11634 array(
11635 'label' => 'Primary',
11636 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11637 ),
11638
11639 array(
11640 'label' => 'Secondary',
11641 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11642 ),
11643
11644 array(
11645 'label' => 'Tertiary',
11646 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11647 ),
11648
11649 array(
11650 'label' => 'Accent',
11651 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11652 ),
11653
11654 array(
11655 'label' => 'Background',
11656 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11657 ),
11658 ),
11659 'spacing' => array(
11660 array(
11661 'label' => 'Large',
11662 'value' => array(
11663 'md' => 70,
11664 'unit' => 'px',
11665 ),
11666 ),
11667 array(
11668 'label' => 'Medium',
11669 'value' => array(
11670 'md' => 40,
11671 'unit' => 'px',
11672 ),
11673 ),
11674 array(
11675 'label' => 'Small',
11676 'value' => array(
11677 'md' => 20,
11678 'unit' => 'px',
11679 ),
11680
11681 ),
11682 ),
11683 'typography' => array(
11684 array(
11685 'label' => 'Display Text',
11686 'value' => array(
11687 'openTypography' => 1,
11688 'size' => array(
11689 'md' => 65,
11690 'unit' => 'px',
11691 ),
11692 'height' => array(
11693 'md' => 75,
11694 'unit' => 'px',
11695 ),
11696 'fontFamily' => array(
11697 'family' => 'Roboto',
11698 'type' => 'sans-serif',
11699 'fontWeight' => 700,
11700 ),
11701 'spacing' => array(
11702 'md' => 0,
11703 'unit' => 'px',
11704 ),
11705 ),
11706 ),
11707 array(
11708 'label' => 'Headline',
11709 'value' => array(
11710 'openTypography' => 1,
11711 'size' => array(
11712 'md' => 45,
11713 'unit' => 'px',
11714 ),
11715 'height' => array(
11716 'md' => 60,
11717 'unit' => 'px',
11718 ),
11719 'fontFamily' => array(
11720 'family' => 'Roboto',
11721 'type' => 'sans-serif',
11722 'fontWeight' => 700,
11723 ),
11724 'spacing' => array(
11725 'md' => 0,
11726 'unit' => 'px',
11727 ),
11728 ),
11729 ),
11730 array(
11731 'label' => 'Sub Headline',
11732 'value' => array(
11733 'openTypography' => 1,
11734 'size' => array(
11735 'md' => 38,
11736 'unit' => 'px',
11737 ),
11738 'height' => array(
11739 'md' => 45,
11740 'unit' => 'px',
11741 ),
11742 'fontFamily' => array(
11743 'family' => 'Roboto',
11744 'type' => 'sans-serif',
11745 'fontWeight' => 500,
11746 ),
11747 'spacing' => array(
11748 'md' => 0,
11749 'unit' => 'px',
11750 ),
11751 ),
11752 ),
11753 array(
11754 'label' => 'Title 1',
11755 'value' => array(
11756 'openTypography' => 1,
11757 'size' => array(
11758 'md' => 30,
11759 'unit' => 'px',
11760 ),
11761 'height' => array(
11762 'md' => 40,
11763 'unit' => 'px',
11764 ),
11765 'fontFamily' => array(
11766 'family' => 'Roboto',
11767 'type' => 'sans-serif',
11768 'fontWeight' => 500,
11769 ),
11770 'spacing' => array(
11771 'md' => 0,
11772 'unit' => 'px',
11773 ),
11774 ),
11775 ),
11776 array(
11777 'label' => 'Title 2',
11778 'value' => array(
11779 'openTypography' => 1,
11780 'size' => array(
11781 'md' => 25,
11782 'unit' => 'px',
11783 ),
11784 'height' => array(
11785 'md' => 30,
11786 'unit' => 'px',
11787 ),
11788 'fontFamily' => array(
11789 'family' => 'Roboto',
11790 'type' => 'sans-serif',
11791 'fontWeight' => 400,
11792 ),
11793 'spacing' => array(
11794 'md' => 0,
11795 'unit' => 'px',
11796 ),
11797 ),
11798 ),
11799 array(
11800 'label' => 'Body',
11801 'value' => array(
11802 'openTypography' => 1,
11803 'size' => array(
11804 'md' => 17,
11805 'unit' => 'px',
11806 ),
11807 'height' => array(
11808 'md' => 22,
11809 'unit' => 'px',
11810 ),
11811 'fontFamily' => array(
11812 'family' => 'Roboto',
11813 'type' => 'sans-serif',
11814 'fontWeight' => 400,
11815 ),
11816 'spacing' => array(
11817 'md' => 0,
11818 'unit' => 'px',
11819 ),
11820 ),
11821 ),
11822 array(
11823 'label' => 'Captions',
11824 'value' => array(
11825 'openTypography' => 1,
11826 'size' => array(
11827 'md' => 13,
11828 'unit' => 'px',
11829 ),
11830 'height' => array(
11831 'md' => 16,
11832 'unit' => 'px',
11833 ),
11834 'fontFamily' => array(
11835 'family' => 'Roboto',
11836 'type' => 'sans-serif',
11837 'fontWeight' => 400,
11838 ),
11839 'spacing' => array(
11840 'md' => 0,
11841 'unit' => 'px',
11842 ),
11843 ),
11844 ),
11845 ),
11846 'boxshadow' => array(
11847 array(
11848 'label' => 'Normal Shadow',
11849 'value' => array(
11850 'openShadow' => 1,
11851 'inset' => 0,
11852 'horizontal' => 2,
11853 'vertical' => 6,
11854 'blur' => 10,
11855 'spread' => 0,
11856 'color' => 'rgba(0,0,0,0.15)',
11857 ),
11858 ),
11859 array(
11860 'label' => 'Hover Shadow',
11861 'value' => array(
11862 'openShadow' => 1,
11863 'inset' => 0,
11864 'horizontal' => 2,
11865 'vertical' => 5,
11866 'blur' => 14,
11867 'spread' => 3,
11868 'color' => 'rgba(0,0,0,0.2)',
11869 ),
11870 ),
11871 ),
11872 ),
11873 ),
11874 'globalContainer' => array(
11875 'md' => '',
11876 'unit' => 'px',
11877 ),
11878 );
11879 }
11880 }
11881
11882 Wdkit_Api_Call::get_instance();
11883 }
11884