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
← All changes | includes/admin/class-api.php +7516 -761 2.5.0 → 2.7.0 View file →
@@ -107,8 +107,14 @@
107 107 * Define the core functionality of the plugin.
108 108 */
109 109 public function __construct() {
110 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 );
111 117 }
112 118
113 119 /**
114 120 * Error JSON message
@@ -135,9 +141,84 @@
135 141 wp_send_json_success( $data, $status );
136 142 wp_die();
137 143 }
138 144
145 +
139 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 + /**
140 221 * Get Wdkit Api Call Ajax.
141 222 */
142 223 public function wdkit_api_call() {
143 224
@@ -200,8 +281,14 @@
200 281 break;
201 282 case 'save_wp_images':
202 283 $data = $this->wdkit_save_wp_images();
203 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;
204 291 case 'get_global_val':
205 292 $data = $this->wdkit_get_global_val();
206 293 break;
207 294 case 'update_global_val':
@@ -230,8 +317,11 @@
230 317 break;
231 318 case 'install_plugins_depends':
232 319 $data = $this->wdkit_install_plugins_depends();
233 320 break;
321 + case 'install_plugins_depends_batch':
322 + $data = $this->wdkit_install_plugins_depends_batch();
323 + break;
234 324 case 'generate_site_logo':
235 325 $data = $this->wkit_generate_site_logo();
236 326 break;
237 327 case 'generate_ai_content':
@@ -236,8 +326,11 @@
236 326 break;
237 327 case 'generate_ai_content':
238 328 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content' );
239 329 break;
330 + case 'generate_ai_content_batch':
331 + $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content_batch' );
332 + break;
240 333 case 'reset_site':
241 334 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'reset_site' );
242 335 break;
243 336 case 'wdkit_nxt_thembuilder_reset':
@@ -293,11 +386,17 @@
293 386 break;
294 387 case 'import_page_section':
295 388 $data = $this->import_page_section_content();
296 389 break;
390 + case 'wdkit_import_stage':
391 + $data = $this->wdkit_import_stage();
392 + break;
297 393 case 'wkit_update_elementor_template':
298 394 $data = $this->wkit_update_elementor_template();
299 395 break;
396 + case 'wdkit_update_page_content':
397 + $data = $this->wdkit_update_page_content();
398 + break;
300 399 case 'update_plugin_setting':
301 400 $data = $this->update_plugin_setting();
302 401 break;
303 402 case 'update_theme_setting':
@@ -308,8 +407,17 @@
308 407 break;
309 408 case 'import_kit_template':
310 409 $data = $this->wdkit_import_kit_template();
311 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;
312 420 case 'enable_template_widgets':
313 421 $data = $this->wdkit_enable_template_widgets();
314 422 break;
315 423 case 'scan_nexter_widgets':
@@ -330,9 +438,9 @@
330 438
331 439 wp_send_json(
332 440 array(
333 441 'success' => false,
334 - 'message' => 'No block names received or filter not found.',
442 + 'message' => __( 'No block names received or filter not found.', 'wdesignkit' ),
335 443 'description' => 'Ensure blockNames are posted and the filter is attached.',
336 444 )
337 445 );
338 446 wp_die();
@@ -364,8 +472,11 @@
364 472 break;
365 473 case 'wkit_check_widget_versions':
366 474 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_check_widget_versions' );
367 475 break;
476 + case 'wkit_plugin_download_get':
477 + $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_plugin_download_get' );
478 + break;
368 479 case 'wkit_manage_widget_workspace':
369 480 $data = $this->wdkit_manage_widget_workspace();
370 481 break;
371 482 case 'wkit_activate_key':
@@ -682,15 +793,22 @@
682 793 $nexter_active_check = is_plugin_active( 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php' );
683 794
684 795 $theplus_licence = get_option( 'tpaep_licence_data', array() );
685 796
686 - if ( ! empty( $theplus_active_check ) && ! empty( $theplus_licence ) ) {
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 ) ) {
687 802 $manage_licence['tpae'] = $theplus_licence;
688 803 }
689 804
690 805 $nexter_licence = get_option( 'tpgb_activate', array() );
691 806
692 - if ( ! empty( $nexter_active_check ) && ! empty( $nexter_licence ) && ! empty( $nexter_licence['tpgb_activate_key'] ) ) {
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'] ) ) {
693 811 $tpgb_license_status = get_option( 'tpgbp_license_status', array() );
694 812 $tpgb_license_status['license_key'] = $nexter_licence['tpgb_activate_key'];
695 813 $manage_licence['tpag'] = $tpgb_license_status;
696 814 }
@@ -731,14 +849,24 @@
731 849 'site_url' => $site_url,
732 850 );
733 851
734 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 +
735 863 $status = ( ! empty( $response['status'] ) ) ? sanitize_text_field( $response['status'] ) : 'error';
736 864 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
737 865
738 866 /**Condtion user for user logout & expire token*/
739 867 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
740 - delete_transient( 'wdkit_auth_' . $email );
868 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
741 869 // Clear stored license data when token expires so banner shows again
742 870 delete_option( 'wdkit_licence_data' );
743 871 }
744 872
@@ -750,9 +878,9 @@
750 878 if ( ! empty( $response['credits']['wdkit_licence'] ) && is_array( $response['credits']['wdkit_licence'] ) ) {
751 879 $wdkit_licence = $response['credits']['wdkit_licence'];
752 880 // Handle serialized data
753 881 if ( is_string( $wdkit_licence ) && is_serialized( $wdkit_licence ) ) {
754 - $wdkit_licence = unserialize( $wdkit_licence );
882 + $wdkit_licence = unserialize( $wdkit_licence, array( 'allowed_classes' => false ) );
755 883 }
756 884 if ( ! empty( $wdkit_licence ) && is_array( $wdkit_licence ) ) {
757 885 update_option( 'wdkit_licence_data', $wdkit_licence );
758 886 }
@@ -783,8 +911,16 @@
783 911 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
784 912 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
785 913 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
786 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 +
787 923 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
788 924
789 925 $local_list = $this->wdkit_get_local_widgets();
790 926
@@ -870,13 +1006,31 @@
870 1006 );
871 1007 }
872 1008 }
873 1009
874 - $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
875 - if ( empty( $get_db_widget ) ) {
876 - add_option( 'wkit_deactivate_widgets', $db_widget, '', 'yes' );
877 - } else {
878 - update_option( 'wkit_deactivate_widgets', $db_widget );
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 + }
879 1033 }
880 1034
881 1035 return $final;
882 1036 }
@@ -890,8 +1044,16 @@
890 1044 $args = $this->wdkit_parse_args( $_POST );
891 1045
892 1046 $response = WDesignKit_Data_Query::get_data( 'browse_page', $args );
893 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 +
894 1056 $manage_licence = array();
895 1057 $manage_licence['theplus_elementor_addon'] = ! empty( defined( 'THEPLUS_VERSION' ) ) ? true : false;
896 1058 $manage_licence['tpag'] = ! empty( defined( 'TPGBP_VERSION' ) ) ? true : false;
897 1059 $manage_licence['elementor-pro'] = ! empty( defined( 'ELEMENTOR_PRO_VERSION' ) ) ? true : false;
@@ -1005,8 +1167,24 @@
1005 1167 }
1006 1168
1007 1169 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1008 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 +
1009 1187 wp_send_json( $response );
1010 1188 wp_die();
1011 1189 }
1012 1190
@@ -1030,9 +1208,11 @@
1030 1208 'success' => false,
1031 1209 );
1032 1210 } else {
1033 1211 $temp_content = str_replace( '\\', '', $temp_content );
1034 - $temp_content = wp_remote_get( $temp_content )['body'];
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 );
1035 1215 $temp_content = base64_encode( $temp_content );
1036 1216
1037 1217 $args = array(
1038 1218 'token' => $token,
@@ -1070,10 +1250,54 @@
1070 1250 *
1071 1251 * @since 2.3.3
1072 1252 */
1073 1253 protected function wdkit_save_wp_images() {
1254 +
1074 1255 $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1075 -
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.
1076 1300 if ( empty( $image_url ) ) {
1077 1301 $response = array(
1078 1302 'message' => __( 'No Image Provided', 'wdesignkit' ),
1079 1303 'description' => __( 'No Image URL provided for save.', 'wdesignkit' ),
@@ -1080,10 +1304,43 @@
1080 1304 'success' => false,
1081 1305 );
1082 1306 } else {
1083 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 +
1084 1341 $attachment_id = media_sideload_image( $image_url, 0, null, 'id' );
1085 -
1342 +
1086 1343 if ( is_wp_error( $attachment_id ) ) {
1087 1344 $response = array(
1088 1345 'message' => __( 'Upload Failed', 'wdesignkit' ),
1089 1346 'description' => $attachment_id->get_error_message(),
@@ -1090,35 +1347,1594 @@
1090 1347 'success' => false,
1091 1348 );
1092 1349 } else {
1093 1350 $saved_url = wp_get_attachment_url( $attachment_id );
1094 -
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 +
1095 1364 $response = array(
1096 1365 'message' => __( 'Image Saved', 'wdesignkit' ),
1097 1366 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1098 1367 'success' => true,
1099 - 'url' => $saved_url,
1368 + 'url' => $saved_url,
1369 + 'id' => $attachment_id,
1100 1370 );
1101 1371 }
1102 1372
1103 1373 }
1104 1374
1105 - wp_send_json( $response );
1106 - wp_die();
1375 + return $response;
1107 1376 }
1108 1377
1109 1378 /**
1379 + * The two bundle/media primitives the PHP import runner needs, reachable without a
1380 + * request.
1110 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 + *
1111 2683 * Get Elementor Global color and Typography.
1112 2684 *
1113 2685 * @since 1.1.16
1114 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 +
1115 2914 protected function wdkit_get_global_val() {
1116 2915
1117 2916 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
1118 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 +
1119 2927 if ( 'elementor' === $builder ) {
1120 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 + }
1121 2937 if ( empty( $kit_id ) ) {
1122 2938 $response = array(
1123 2939 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1124 2940 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1193,10 +3009,40 @@
1193 3009 'viewport_md' => 768,
1194 3010 'viewport_lg' => 1025,
1195 3011 );
1196 3012
1197 - $kit_meta = $static_meta;
1198 - update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
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 + }
1199 3045 }
1200 3046
1201 3047 $system_colors = ! empty( $kit_meta['system_colors'] ) ? $kit_meta['system_colors'] : array();
1202 3048 $custom_colors = ! empty( $kit_meta['custom_colors'] ) ? $kit_meta['custom_colors'] : array();
@@ -1224,549 +3070,10 @@
1224 3070 $plus_settings = ! empty( $plus_settings ) ? json_decode( $plus_settings, true ) : json_decode( '[]' );
1225 3071
1226 3072 if ( empty( $plus_settings ) ) {
1227 3073
1228 - $static_meta = array(
1229 - 'active' => 'preset1',
1230 - 'darkMode' => 'none',
1231 - 'presets' => array(
1232 - 'preset1' => array(
1233 - 'name' => 'Preset 1',
1234 - 'key' => 'preset1',
1235 - 'colors' => array(
1236 - array(
1237 - 'label' => 'Primary',
1238 - 'value' => '#8072FC',
1239 - ),
1240 - array(
1241 - 'label' => 'Secondary',
1242 - 'value' => '#6FC784',
1243 - ),
1244 - array(
1245 - 'label' => 'Tertiary',
1246 - 'value' => '#FF5A6E',
1247 - ),
1248 - array(
1249 - 'label' => 'Accent',
1250 - 'value' => '#F3F3F3',
1251 - ),
1252 - array(
1253 - 'label' => 'Background',
1254 - 'value' => '#888888',
1255 - ),
1256 - ),
1257 - 'gradient' => array(
1258 - array(
1259 - 'label' => 'Primary',
1260 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1261 - ),
3074 + $static_meta = self::wdkit_default_gutenberg_globals();
1262 3075
1263 - array(
1264 - 'label' => 'Secondary',
1265 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1266 - ),
1267 -
1268 - array(
1269 - 'label' => 'Tertiary',
1270 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1271 - ),
1272 -
1273 - array(
1274 - 'label' => 'Accent',
1275 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1276 - ),
1277 -
1278 - array(
1279 - 'label' => 'Background',
1280 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1281 - ),
1282 - ),
1283 - 'spacing' => array(
1284 - array(
1285 - 'label' => 'Large',
1286 - 'value' => array(
1287 - 'md' => 70,
1288 - 'unit' => 'px',
1289 - ),
1290 - ),
1291 - array(
1292 - 'label' => 'Medium',
1293 - 'value' => array(
1294 - 'md' => 40,
1295 - 'unit' => 'px',
1296 - ),
1297 - ),
1298 - array(
1299 - 'label' => 'Small',
1300 - 'value' => array(
1301 - 'md' => 20,
1302 - 'unit' => 'px',
1303 - ),
1304 -
1305 - ),
1306 - ),
1307 - 'typography' => array(
1308 - array(
1309 - 'label' => 'Display Text',
1310 - 'value' => array(
1311 - 'openTypography' => 1,
1312 - 'size' => array(
1313 - 'md' => 65,
1314 - 'unit' => 'px',
1315 - ),
1316 - 'height' => array(
1317 - 'md' => 75,
1318 - 'unit' => 'px',
1319 - ),
1320 - 'fontFamily' => array(
1321 - 'family' => 'Roboto',
1322 - 'type' => 'sans-serif',
1323 - 'fontWeight' => 700,
1324 - ),
1325 - 'spacing' => array(
1326 - 'md' => 0,
1327 - 'unit' => 'px',
1328 - ),
1329 - ),
1330 - ),
1331 - array(
1332 - 'label' => 'Headline',
1333 - 'value' => array(
1334 - 'openTypography' => 1,
1335 - 'size' => array(
1336 - 'md' => 45,
1337 - 'unit' => 'px',
1338 - ),
1339 - 'height' => array(
1340 - 'md' => 60,
1341 - 'unit' => 'px',
1342 - ),
1343 - 'fontFamily' => array(
1344 - 'family' => 'Roboto',
1345 - 'type' => 'sans-serif',
1346 - 'fontWeight' => 700,
1347 - ),
1348 - 'spacing' => array(
1349 - 'md' => 0,
1350 - 'unit' => 'px',
1351 - ),
1352 - ),
1353 - ),
1354 - array(
1355 - 'label' => 'Sub Headline',
1356 - 'value' => array(
1357 - 'openTypography' => 1,
1358 - 'size' => array(
1359 - 'md' => 38,
1360 - 'unit' => 'px',
1361 - ),
1362 - 'height' => array(
1363 - 'md' => 45,
1364 - 'unit' => 'px',
1365 - ),
1366 - 'fontFamily' => array(
1367 - 'family' => 'Roboto',
1368 - 'type' => 'sans-serif',
1369 - 'fontWeight' => 500,
1370 - ),
1371 - 'spacing' => array(
1372 - 'md' => 0,
1373 - 'unit' => 'px',
1374 - ),
1375 - ),
1376 - ),
1377 - array(
1378 - 'label' => 'Title 1',
1379 - 'value' => array(
1380 - 'openTypography' => 1,
1381 - 'size' => array(
1382 - 'md' => 30,
1383 - 'unit' => 'px',
1384 - ),
1385 - 'height' => array(
1386 - 'md' => 40,
1387 - 'unit' => 'px',
1388 - ),
1389 - 'fontFamily' => array(
1390 - 'family' => 'Roboto',
1391 - 'type' => 'sans-serif',
1392 - 'fontWeight' => 500,
1393 - ),
1394 - 'spacing' => array(
1395 - 'md' => 0,
1396 - 'unit' => 'px',
1397 - ),
1398 - ),
1399 - ),
1400 - array(
1401 - 'label' => 'Title 2',
1402 - 'value' => array(
1403 - 'openTypography' => 1,
1404 - 'size' => array(
1405 - 'md' => 25,
1406 - 'unit' => 'px',
1407 - ),
1408 - 'height' => array(
1409 - 'md' => 30,
1410 - 'unit' => 'px',
1411 - ),
1412 - 'fontFamily' => array(
1413 - 'family' => 'Roboto',
1414 - 'type' => 'sans-serif',
1415 - 'fontWeight' => 400,
1416 - ),
1417 - 'spacing' => array(
1418 - 'md' => 0,
1419 - 'unit' => 'px',
1420 - ),
1421 - ),
1422 - ),
1423 - array(
1424 - 'label' => 'Body',
1425 - 'value' => array(
1426 - 'openTypography' => 1,
1427 - 'size' => array(
1428 - 'md' => 17,
1429 - 'unit' => 'px',
1430 - ),
1431 - 'height' => array(
1432 - 'md' => 22,
1433 - 'unit' => 'px',
1434 - ),
1435 - 'fontFamily' => array(
1436 - 'family' => 'Roboto',
1437 - 'type' => 'sans-serif',
1438 - 'fontWeight' => 400,
1439 - ),
1440 - 'spacing' => array(
1441 - 'md' => 0,
1442 - 'unit' => 'px',
1443 - ),
1444 - ),
1445 - ),
1446 - array(
1447 - 'label' => 'Captions',
1448 - 'value' => array(
1449 - 'openTypography' => 1,
1450 - 'size' => array(
1451 - 'md' => 13,
1452 - 'unit' => 'px',
1453 - ),
1454 - 'height' => array(
1455 - 'md' => 16,
1456 - 'unit' => 'px',
1457 - ),
1458 - 'fontFamily' => array(
1459 - 'family' => 'Roboto',
1460 - 'type' => 'sans-serif',
1461 - 'fontWeight' => 400,
1462 - ),
1463 - 'spacing' => array(
1464 - 'md' => 0,
1465 - 'unit' => 'px',
1466 - ),
1467 - ),
1468 - ),
1469 - ),
1470 - 'boxshadow' => array(
1471 - array(
1472 - 'label' => 'Normal Shadow',
1473 - 'value' => array(
1474 - 'openShadow' => 1,
1475 - 'inset' => 0,
1476 - 'horizontal' => 2,
1477 - 'vertical' => 6,
1478 - 'blur' => 10,
1479 - 'spread' => 0,
1480 - 'color' => 'rgba(0,0,0,0.15)',
1481 - ),
1482 - ),
1483 - array(
1484 - 'label' => 'Hover Shadow',
1485 - 'value' => array(
1486 - 'openShadow' => 1,
1487 - 'inset' => 0,
1488 - 'horizontal' => 2,
1489 - 'vertical' => 5,
1490 - 'blur' => 14,
1491 - 'spread' => 3,
1492 - 'color' => 'rgba(0,0,0,0.2)',
1493 - ),
1494 - ),
1495 - ),
1496 - ),
1497 - 'preset2' => array(
1498 - 'name' => 'Preset 2',
1499 - 'key' => 'preset2',
1500 - 'colors' => array(
1501 - array(
1502 - 'label' => 'Primary',
1503 - 'value' => '#8072FC',
1504 - ),
1505 - array(
1506 - 'label' => 'Secondary',
1507 - 'value' => '#6FC784',
1508 - ),
1509 - array(
1510 - 'label' => 'Tertiary',
1511 - 'value' => '#FF5A6E',
1512 - ),
1513 - array(
1514 - 'label' => 'Accent',
1515 - 'value' => '#F3F3F3',
1516 - ),
1517 - array(
1518 - 'label' => 'Background',
1519 - 'value' => '#888888',
1520 - ),
1521 - ),
1522 - 'gradient' => array(
1523 - array(
1524 - 'label' => 'Primary',
1525 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1526 - ),
1527 -
1528 - array(
1529 - 'label' => 'Secondary',
1530 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1531 - ),
1532 -
1533 - array(
1534 - 'label' => 'Tertiary',
1535 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1536 - ),
1537 -
1538 - array(
1539 - 'label' => 'Accent',
1540 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1541 - ),
1542 -
1543 - array(
1544 - 'label' => 'Background',
1545 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1546 - ),
1547 - ),
1548 - 'spacing' => array(
1549 - array(
1550 - 'label' => 'Large',
1551 - 'value' => array(
1552 - 'md' => 70,
1553 - 'unit' => 'px',
1554 - ),
1555 - ),
1556 - array(
1557 - 'label' => 'Medium',
1558 - 'value' => array(
1559 - 'md' => 40,
1560 - 'unit' => 'px',
1561 - ),
1562 - ),
1563 - array(
1564 - 'label' => 'Small',
1565 - 'value' => array(
1566 - 'md' => 20,
1567 - 'unit' => 'px',
1568 - ),
1569 -
1570 - ),
1571 - ),
1572 - 'typography' => array(
1573 - array(
1574 - 'label' => 'Display Text',
1575 - 'value' => array(
1576 - 'openTypography' => 1,
1577 - 'size' => array(
1578 - 'md' => 65,
1579 - 'unit' => 'px',
1580 - ),
1581 - 'height' => array(
1582 - 'md' => 75,
1583 - 'unit' => 'px',
1584 - ),
1585 - 'fontFamily' => array(
1586 - 'family' => 'Roboto',
1587 - 'type' => 'sans-serif',
1588 - 'fontWeight' => 700,
1589 - ),
1590 - 'spacing' => array(
1591 - 'md' => 0,
1592 - 'unit' => 'px',
1593 - ),
1594 - ),
1595 - ),
1596 - array(
1597 - 'label' => 'Headline',
1598 - 'value' => array(
1599 - 'openTypography' => 1,
1600 - 'size' => array(
1601 - 'md' => 45,
1602 - 'unit' => 'px',
1603 - ),
1604 - 'height' => array(
1605 - 'md' => 60,
1606 - 'unit' => 'px',
1607 - ),
1608 - 'fontFamily' => array(
1609 - 'family' => 'Roboto',
1610 - 'type' => 'sans-serif',
1611 - 'fontWeight' => 700,
1612 - ),
1613 - 'spacing' => array(
1614 - 'md' => 0,
1615 - 'unit' => 'px',
1616 - ),
1617 - ),
1618 - ),
1619 - array(
1620 - 'label' => 'Sub Headline',
1621 - 'value' => array(
1622 - 'openTypography' => 1,
1623 - 'size' => array(
1624 - 'md' => 38,
1625 - 'unit' => 'px',
1626 - ),
1627 - 'height' => array(
1628 - 'md' => 45,
1629 - 'unit' => 'px',
1630 - ),
1631 - 'fontFamily' => array(
1632 - 'family' => 'Roboto',
1633 - 'type' => 'sans-serif',
1634 - 'fontWeight' => 500,
1635 - ),
1636 - 'spacing' => array(
1637 - 'md' => 0,
1638 - 'unit' => 'px',
1639 - ),
1640 - ),
1641 - ),
1642 - array(
1643 - 'label' => 'Title 1',
1644 - 'value' => array(
1645 - 'openTypography' => 1,
1646 - 'size' => array(
1647 - 'md' => 30,
1648 - 'unit' => 'px',
1649 - ),
1650 - 'height' => array(
1651 - 'md' => 40,
1652 - 'unit' => 'px',
1653 - ),
1654 - 'fontFamily' => array(
1655 - 'family' => 'Roboto',
1656 - 'type' => 'sans-serif',
1657 - 'fontWeight' => 500,
1658 - ),
1659 - 'spacing' => array(
1660 - 'md' => 0,
1661 - 'unit' => 'px',
1662 - ),
1663 - ),
1664 - ),
1665 - array(
1666 - 'label' => 'Title 2',
1667 - 'value' => array(
1668 - 'openTypography' => 1,
1669 - 'size' => array(
1670 - 'md' => 25,
1671 - 'unit' => 'px',
1672 - ),
1673 - 'height' => array(
1674 - 'md' => 30,
1675 - 'unit' => 'px',
1676 - ),
1677 - 'fontFamily' => array(
1678 - 'family' => 'Roboto',
1679 - 'type' => 'sans-serif',
1680 - 'fontWeight' => 400,
1681 - ),
1682 - 'spacing' => array(
1683 - 'md' => 0,
1684 - 'unit' => 'px',
1685 - ),
1686 - ),
1687 - ),
1688 - array(
1689 - 'label' => 'Body',
1690 - 'value' => array(
1691 - 'openTypography' => 1,
1692 - 'size' => array(
1693 - 'md' => 17,
1694 - 'unit' => 'px',
1695 - ),
1696 - 'height' => array(
1697 - 'md' => 22,
1698 - 'unit' => 'px',
1699 - ),
1700 - 'fontFamily' => array(
1701 - 'family' => 'Roboto',
1702 - 'type' => 'sans-serif',
1703 - 'fontWeight' => 400,
1704 - ),
1705 - 'spacing' => array(
1706 - 'md' => 0,
1707 - 'unit' => 'px',
1708 - ),
1709 - ),
1710 - ),
1711 - array(
1712 - 'label' => 'Captions',
1713 - 'value' => array(
1714 - 'openTypography' => 1,
1715 - 'size' => array(
1716 - 'md' => 13,
1717 - 'unit' => 'px',
1718 - ),
1719 - 'height' => array(
1720 - 'md' => 16,
1721 - 'unit' => 'px',
1722 - ),
1723 - 'fontFamily' => array(
1724 - 'family' => 'Roboto',
1725 - 'type' => 'sans-serif',
1726 - 'fontWeight' => 400,
1727 - ),
1728 - 'spacing' => array(
1729 - 'md' => 0,
1730 - 'unit' => 'px',
1731 - ),
1732 - ),
1733 - ),
1734 - ),
1735 - 'boxshadow' => array(
1736 - array(
1737 - 'label' => 'Normal Shadow',
1738 - 'value' => array(
1739 - 'openShadow' => 1,
1740 - 'inset' => 0,
1741 - 'horizontal' => 2,
1742 - 'vertical' => 6,
1743 - 'blur' => 10,
1744 - 'spread' => 0,
1745 - 'color' => 'rgba(0,0,0,0.15)',
1746 - ),
1747 - ),
1748 - array(
1749 - 'label' => 'Hover Shadow',
1750 - 'value' => array(
1751 - 'openShadow' => 1,
1752 - 'inset' => 0,
1753 - 'horizontal' => 2,
1754 - 'vertical' => 5,
1755 - 'blur' => 14,
1756 - 'spread' => 3,
1757 - 'color' => 'rgba(0,0,0,0.2)',
1758 - ),
1759 - ),
1760 - ),
1761 - ),
1762 - ),
1763 - 'globalContainer' => array(
1764 - 'md' => '',
1765 - 'unit' => 'px',
1766 - ),
1767 - );
1768 -
1769 3076 update_option( 'tpgb_global_options', json_encode( $static_meta ) );
1770 3077 $plus_settings = $static_meta;
1771 3078 }
1772 3079
@@ -1807,8 +3114,14 @@
1807 3114 */
1808 3115 protected function wdkit_get_site_setting() {
1809 3116
1810 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 +
1811 3124 if ( empty( $kit_id ) ) {
1812 3125 $response = array(
1813 3126 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1814 3127 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1852,10 +3165,40 @@
1852 3165
1853 3166 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
1854 3167 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
1855 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 +
1856 3194 if ( 'elementor' == $builder ) {
1857 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 +
1858 3201 if ( ! $kit_id ) {
1859 3202 $response = array(
1860 3203 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1861 3204 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1861,22 +3204,16 @@
1861 3204 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
1862 3205 'success' => false,
1863 3206 );
1864 3207
1865 - wp_send_json( $response );
1866 - wp_die();
3208 + return $response;
1867 3209 }
1868 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.
1869 3213 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1870 - if ( empty( $kit_meta ) ) {
1871 - $response = array(
1872 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1873 - 'description' => __( 'No site data found in kit', 'wdesignkit' ),
1874 - 'success' => false,
1875 - );
1876 -
1877 - wp_send_json( $response );
1878 - wp_die();
3214 + if ( ! is_array( $kit_meta ) ) {
3215 + $kit_meta = array();
1879 3216 }
1880 3217
1881 3218 $kit_meta['container_width'] = ! empty( $site_data['container_width'] ) ? $site_data['container_width'] : array();
1882 3219 $kit_meta['__globals__'] = ! empty( $site_data['globals'] ) ? $site_data['globals'] : array();
@@ -1883,8 +3220,13 @@
1883 3220 $kit_meta['body_background_color'] = ! empty( $site_data['body_background_color'] ) ? $site_data['body_background_color'] : array();
1884 3221
1885 3222 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1886 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 +
1887 3229 $response = array(
1888 3230 'message' => __( 'Site data Updated', 'wdesignkit' ),
1889 3231 'description' => __( 'Site Globals Updated', 'wdesignkit' ),
1890 3232 'success' => true,
@@ -1911,10 +3253,9 @@
1911 3253 'success' => true,
1912 3254 );
1913 3255 }
1914 3256
1915 - wp_send_json( $response );
1916 - wp_die();
3257 + return $response;
1917 3258 }
1918 3259
1919 3260 /**
1920 3261 *
@@ -1925,15 +3266,57 @@
1925 3266 protected function wdkit_update_global_val() {
1926 3267
1927 3268 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
1928 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 +
1929 3304 if ( 'elementor' == $builder ) {
1930 3305
1931 - $g_color = ! empty( $_POST['g_color'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_color'] ) ), true ) : array();
1932 - $g_typo = ! empty( $_POST['g_typography'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_typography'] ) ), true ) : array();
3306 + $g_color = is_array( $g_color ) ? $g_color : array();
3307 + $g_typo = is_array( $g_typo ) ? $g_typo : array();
1933 3308
1934 3309 // Get colors from Elementor Site Kit
1935 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 +
1936 3319 if ( ! $kit_id ) {
1937 3320 $response = array(
1938 3321 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1939 3322 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1939,29 +3322,28 @@
1939 3322 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
1940 3323 'success' => false,
1941 3324 );
1942 3325
1943 - wp_send_json( $response );
1944 - wp_die();
3326 + return $response;
1945 3327 }
1946 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.
1947 3331 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1948 - if ( empty( $kit_meta ) ) {
1949 - $response = array(
1950 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1951 - 'description' => __( 'No meta data found in kit', 'wdesignkit' ),
1952 - 'success' => false,
1953 - );
1954 -
1955 - wp_send_json( $response );
1956 - wp_die();
3332 + if ( ! is_array( $kit_meta ) ) {
3333 + $kit_meta = array();
1957 3334 }
1958 3335
1959 - $kit_meta['custom_colors'] = array_merge( $g_color, $kit_meta['custom_colors'] );
1960 - $kit_meta['custom_typography'] = array_merge( $g_typo, $kit_meta['custom_typography'] );
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() );
1961 3338
1962 3339 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1963 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 +
1964 3346 $response = array(
1965 3347 'message' => __( 'Global data Updated', 'wdesignkit' ),
1966 3348 'description' => __( 'Global Color and Typography Updated', 'wdesignkit' ),
1967 3349 'success' => true,
@@ -1967,9 +3349,9 @@
1967 3349 'success' => true,
1968 3350 );
1969 3351
1970 3352 } elseif ( 'gutenberg' == $builder ) {
1971 - $new_preset = ! empty( $_POST['new_preset'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['new_preset'] ) ), true ) : array();
3353 + $new_preset = is_array( $new_preset ) ? $new_preset : array();
1972 3354 $new_preset_id = ! empty( $new_preset['key'] ) ? $new_preset['key'] : '';
1973 3355 $plus_settings = get_option( 'tpgb_global_options' );
1974 3356 $site_preset = json_decode( $plus_settings, true );
1975 3357
@@ -1991,14 +3373,33 @@
1991 3373 'success' => true,
1992 3374 );
1993 3375 }
1994 3376
1995 - wp_send_json( $response );
1996 - wp_die();
3377 + return $response;
1997 3378 }
1998 3379
1999 3380 /**
3381 + * Regenerate Elementor's cached CSS files after the active kit's
3382 + * `_elementor_page_settings` meta has been changed directly.
2000 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 + *
2001 3402 * Create Gutenberg page and save for re-generate css file.
2002 3403 *
2003 3404 * @since 1.2.3
2004 3405 */
@@ -2252,8 +3653,32 @@
2252 3653 * @version 1.0.9
2253 3654 */
2254 3655 protected function wdkit_install_plugins_depends() {
2255 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();
2256 3681 $type = ! empty( $plugins['type'] ) ? $plugins['type'] : 'plugin';
2257 3682 $p_id = ! empty( $plugins['p_id'] ) ? $plugins['p_id'] : 'plugin';
2258 3683
2259 3684 $responce = '';
@@ -2311,12 +3736,160 @@
2311 3736 );
2312 3737 }
2313 3738 }
2314 3739
2315 - wp_send_json( $responce );
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 + );
2316 3845 wp_die();
2317 3846 }
2318 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 +
2319 3892 protected function wdkit_install_theme_depends( $name = 'nexter' ) {
2320 3893
2321 3894 if ( ! current_user_can( 'install_themes' ) ) {
2322 3895 $response = $this->tpae_set_response( false, 'Invalid nonce.', 'The security check failed. Please refresh the page and try again.' );
@@ -2362,34 +3935,56 @@
2362 3935 $error_message = $response->get_error_message();
2363 3936
2364 3937 $result = $this->tpae_set_response( false, 'oops', 'oops', '' );
2365 3938 } else {
2366 - $theme_info = unserialize( $response['body'] );
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' ) ) );
2367 3945 $theme_name = $theme_info->name;
2368 3946 $theme_zip_url = $theme_info->download_link;
2369 3947
2370 - global $wp_filesystem;
2371 - // Install the theme
2372 - $theme = wp_remote_get( $theme_zip_url, array( 'timeout' => 30 ) );
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 + }
2373 3958
2374 3959 if ( ! function_exists( 'WP_Filesystem' ) ) {
2375 3960 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
2376 3961 }
2377 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 +
2378 3966 WP_Filesystem();
2379 3967
2380 3968 $active_theme = wp_get_theme();
2381 3969 $theme_name = $active_theme->get( 'Name' );
2382 3970
2383 - $wp_filesystem->put_contents( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip', $theme['body'] );
2384 - $zip = new ZipArchive();
2385 - if ( $zip->open( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' ) === true ) {
2386 - $zip->extractTo( WP_CONTENT_DIR . '/themes/' );
2387 - $zip->close();
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 + );
2388 3985 }
2389 3986
2390 - $wp_filesystem->delete( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' );
2391 -
2392 3987 $activate_result = switch_theme( $name );
2393 3988
2394 3989 if ( ! is_wp_error( $activate_result ) ) {
2395 3990 $response = array(
@@ -2469,8 +4064,37 @@
2469 4064 protected function wdkit_import_taxonomy() {
2470 4065 $category = isset( $_POST['category'] ) ? json_decode( wp_unslash( $_POST['category'] ) ) : array();
2471 4066 $tags = isset( $_POST['tags'] ) ? json_decode( wp_unslash( $_POST['tags'] ) ) : array();
2472 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 +
2473 4097 $response = array(
2474 4098 'success' => false,
2475 4099 'categories' => array(),
2476 4100 'tags' => array(),
@@ -2535,10 +4159,14 @@
2535 4159
2536 4160 $response['success'] = true;
2537 4161 }
2538 4162
2539 - wp_send_json( $response );
2540 - wp_die();
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;
2541 4169 }
2542 4170
2543 4171 /**
2544 4172 * Get Download Template Content
@@ -2565,9 +4193,27 @@
2565 4193 $args['token'] = $this->wdkit_login_user_token( $args['email'] );
2566 4194
2567 4195 unset( $args['email'] );
2568 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 +
2569 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 +
2570 4216 $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
2571 4217
2572 4218 /** Custom meta Field */
2573 4219 if ( ! empty( $custom_meta ) && 'true' === $custom_meta && ! empty( $response ) && ! empty( $response['content'] ) ) {
@@ -2578,9 +4224,9 @@
2578 4224
2579 4225 if ( ! empty( $meta_data ) ) {
2580 4226 foreach ( $meta_data as $meta_key => $meta_val ) {
2581 4227 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
2582 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4228 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
2583 4229 }
2584 4230
2585 4231 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
2586 4232 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
@@ -2591,8 +4237,34 @@
2591 4237 }
2592 4238 }
2593 4239 }
2594 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 +
2595 4267 wp_send_json( $response );
2596 4268 wp_die();
2597 4269 }
2598 4270
@@ -2603,10 +4275,860 @@
2603 4275 *
2604 4276 * @param array $content store media content.
2605 4277 * @param string $editor it is check editor.
2606 4278 */
2607 - public function wdkit_media_import( $content = array(), $editor = '' ) {
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();
2608 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 +
2609 5131 if ( empty( $content ) && empty( $editor ) ) {
2610 5132 $args = $this->wdkit_parse_args( $_POST );
2611 5133 $content = ! empty( $args['content'] ) ? json_decode( $args['content'], true ) : array();
2612 5134 } else {
@@ -2624,17 +5146,164 @@
2624 5146 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
2625 5147 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
2626 5148 }
2627 5149
5150 +
2628 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 +
2629 5206 $media_import = array( $content );
2630 - $media_import = self::blocks_import_media_copy_content( $media_import );
5207 + $media_import = self::blocks_import_media_copy_content( $media_import, $defer_media );
2631 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();
2632 5252 } elseif ( ! empty( $args['editor'] ) && 'elementor' === $args['editor'] && ! empty( $content ) ) {
2633 5253 $media_import = array( $content );
5254 + // Element IDs must stay unique regardless of the media path, so this always runs.
2634 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 +
2635 5299 $media_import = self::widgets_import_media_copy_content( $media_import );
2636 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 );
2637 5306 }
2638 5307
2639 5308 return $content;
2640 5309 }
@@ -2705,9 +5374,13 @@
2705 5374 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
2706 5375 $control_name = $get_control['name'];
2707 5376
2708 5377 if ( ! $control_type ) {
2709 - return $get_element_instance;
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;
2710 5383 }
2711 5384
2712 5385 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
2713 5386 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
@@ -2721,9 +5394,9 @@
2721 5394 * Blocks Recursively data
2722 5395 *
2723 5396 * @param string $data_import gutenber data import.
2724 5397 */
2725 - public static function blocks_import_media_copy_content( $data_import ) {
5398 + public static function blocks_import_media_copy_content( $data_import, $defer_media = false ) {
2726 5399 if ( ! empty( $data_import ) ) {
2727 5400 foreach ( $data_import[0] as $key => $val ) {
2728 5401 if ( array_key_exists( 'blockName', $val ) && ( empty( $val['blockName'] ) || null === $val['blockName'] || empty( $val['blockName'] ) || ' ' === $val['blockName'] ) ) {
2729 5402 unset( $data_import[0][ $key ] );
@@ -2732,12 +5405,13 @@
2732 5405 }
2733 5406
2734 5407 return self::blocks_array_recursively_data(
2735 5408 $data_import,
2736 - function ( $block_data ) {
2737 - $elements = self::blocks_data_instance( $block_data );
5409 + function ( $block_data, $args ) {
5410 + $elements = self::blocks_data_instance( $block_data, $args );
2738 5411 return $elements;
2739 - }
5412 + },
5413 + array( 'defer_media' => $defer_media )
2740 5414 );
2741 5415 }
2742 5416
2743 5417 /**
@@ -2782,73 +5456,1115 @@
2782 5456 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
2783 5457
2784 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'] ) ) ) {
2785 5459 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
2786 - foreach ( $blocks_attr as $block_key => $block_val ) {
2787 - if ( isset( $block_val['url'] ) && isset( $block_val['id'] ) && ! empty( $block_val['url'] ) ) {
2788 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2789 - $blocks_attr[ $block_key ] = $new_media;
2790 - } elseif ( isset( $block_val['url'] ) && ! empty( $block_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $block_val['url'] ) ) {
2791 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2792 - $blocks_attr[ $block_key ] = $new_media;
2793 - } elseif ( is_array( $block_val ) && ! empty( $block_val ) ) {
2794 - if ( ! array_key_exists( 'md', $block_val ) && ! array_key_exists( 'openTypography', $block_val ) && ! array_key_exists( 'openBorder', $block_val ) && ! array_key_exists( 'openShadow', $block_val ) && ! array_key_exists( 'openFilter', $block_val ) ) {
2795 - foreach ( $block_val as $key => $val ) {
2796 - if ( is_array( $val ) && ! empty( $val ) ) {
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 + }
2797 5466
2798 - if ( isset( $val['url'] ) && ( isset( $val['Id'] ) || isset( $val['id'] ) ) && ! empty( $val['url'] ) ) {
2799 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2800 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2801 - } elseif ( isset( $val['url'] ) && ! empty( $val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $val['url'] ) ) {
2802 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2803 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2804 - } else {
2805 - foreach ( $val as $sub_key => $sub_val ) {
2806 - if ( isset( $sub_val['url'] ) && ( isset( $sub_val['Id'] ) || isset( $sub_val['id'] ) ) && ! empty( $sub_val['url'] ) ) {
2807 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
5467 + $block_data = self::wdkit_relink_block_markup( $block_data );
5468 + }
2808 5469
2809 - if ( is_array( $sub_val ) && is_array( $new_media ) ) {
2810 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = array_merge( $sub_val, $new_media );
2811 - } else {
2812 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2813 - }
2814 - } elseif ( isset( $sub_val['url'] ) && ! empty( $sub_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val['url'] ) ) {
2815 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
2816 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2817 - } elseif ( is_array( $sub_val ) && ! empty( $sub_val ) ) {
2818 - foreach ( $sub_val as $sub_key1 => $sub_val1 ) {
2819 - if ( isset( $sub_val1['url'] ) && ( isset( $sub_val1['Id'] ) || isset( $sub_val1['id'] ) ) && ! empty( $sub_val1['url'] ) ) {
2820 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
5470 + return $block_data;
5471 + }
2821 5472
2822 - if ( is_array( $sub_val1 ) && is_array( $new_media ) ) {
2823 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = array_merge( $sub_val1, $new_media );
2824 - } else {
2825 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2826 - }
2827 - } elseif ( isset( $sub_val1['url'] ) && ! empty( $sub_val1['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val1['url'] ) ) {
2828 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
2829 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2830 - }
2831 - }
2832 - }
2833 - }
2834 - }
2835 - }
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 );
2836 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 + }
2837 6159 }
6160 +
6161 + if ( $new_id && (int) $node['id'] !== $new_id ) {
6162 + $node['id'] = $new_id;
6163 + ++$changed;
6164 + }
2838 6165 }
2839 6166 }
2840 - if ( isset( $block_data['attributes'] ) ) {
2841 - $block_data['attributes'] = $blocks_attr;
2842 - } elseif ( isset( $block_data['attrs'] ) ) {
2843 - $block_data['attrs'] = $blocks_attr;
6167 +
6168 + foreach ( $node as $key => &$child ) {
6169 + if ( is_array( $child ) ) {
6170 + $remap( $child );
6171 + }
2844 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;
2845 6197 }
2846 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 +
2847 6511 return $block_data;
2848 6512 }
2849 6513
2850 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 + /**
2851 6567 * Kit Template Import Pages/Sections
2852 6568 *
2853 6569 * @since 1.0.0
2854 6570 * */
@@ -2901,8 +6617,12 @@
2901 6617 'website_kit' => $website_kit,
2902 6618 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
2903 6619 );
2904 6620
6621 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6622 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
6623 + }
6624 +
2905 6625 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
2906 6626 $output = array();
2907 6627
2908 6628 if ( is_wp_error( $response ) ) {
@@ -2927,43 +6647,1926 @@
2927 6647 $output['description'] = $response['description'];
2928 6648 $output['data'] = $result;
2929 6649 $output['success'] = $response['success'];
2930 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 +
2931 6671 wp_send_json( $output );
2932 6672 wp_die();
2933 6673 }
2934 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 +
2935 8459 public function wdkit_enable_template_widgets() {
2936 8460 $widget_list = ! empty( $_POST['widget_list'] ) ? json_decode( wp_unslash( $_POST['widget_list'] ), true ) : array();
2937 8461 $extensions_list = ! empty( $_POST['extensions_list'] ) ? json_decode( wp_unslash( $_POST['extensions_list'] ), true ) : array();
2938 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 +
2939 8488 if ( empty( $widget_list ) && empty( $extensions_list ) ) {
2940 - $res = array(
8489 + return array(
2941 8490 'massage' => __( 'Widget array not found', 'wdesignkit' ),
2942 8491 'description' => __( 'Widget array not found', 'wdesignkit' ),
2943 8492 'success' => false,
2944 8493 );
2945 - wp_send_json( $res );
2946 - wp_die();
2947 8494 }
2948 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 +
2949 8500 if ( ! has_filter( 'tpae_enable_selected_widgets' ) ) {
2950 - $res = array(
2951 - 'massage' => __( 'Relevant Plugin not Activated', 'wdesignkit' ),
2952 - 'description' => __( 'Relevant Plugin not Installed / Activated', 'wdesignkit' ),
2953 - 'success' => false,
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(),
2954 8517 );
2955 - wp_send_json( $res );
2956 - wp_die();
8518 + update_option( 'theplus_options', $theplus_options );
2957 8519 }
2958 8520
2959 - $w_list = array(
2960 - 'widgets' => $widget_list,
2961 - 'extensions' => $extensions_list,
2962 - );
8521 + if ( has_filter( 'tpae_enable_selected_widgets' ) ) {
8522 + $w_list = array(
8523 + 'widgets' => $widget_list,
8524 + 'extensions' => $extensions_list,
8525 + );
2963 8526
2964 - $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
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 + }
2965 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 +
2966 8569 if ( ! empty( $result['success'] ) ) {
2967 8570 $res = array(
2968 8571 'massage' => __( 'Enabled widgets successfully', 'wdesignkit' ),
2969 8572 'description' => __( 'Used widgets have been enabled successfully', 'wdesignkit' ),
@@ -2980,10 +8583,14 @@
2980 8583 'success' => false,
2981 8584 );
2982 8585 }
2983 8586
2984 - wp_send_json( $res );
2985 - wp_die();
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;
2986 8593 }
2987 8594
2988 8595 /**
2989 8596 * Import single template and section from plugin only
@@ -3033,11 +8640,23 @@
3033 8640 'editor' => $args['editor'],
3034 8641 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
3035 8642 );
3036 8643
8644 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
8645 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
8646 + }
8647 +
3037 8648 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3038 8649
3039 - if ( 'error' === $response['content'] ) {
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'] ) {
3040 8659 wp_send_json( $response );
3041 8660 wp_die();
3042 8661 }
3043 8662
@@ -3105,8 +8724,19 @@
3105 8724 }
3106 8725
3107 8726 $document = \Elementor\Plugin::$instance->documents->get($template_id);
3108 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 +
3109 8739 $document->save([
3110 8740 'elements' => $content
3111 8741 ]);
3112 8742 }
@@ -3111,8 +8741,113 @@
3111 8741 ]);
3112 8742 }
3113 8743
3114 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 + /**
3115 8850 * Import single template and section from plugin only
3116 8851 *
3117 8852 * @param array $args store data.
3118 8853 * @param array $template_id store data.
@@ -3120,8 +8855,21 @@
3120 8855 * @param array $temp_data store data.
3121 8856 * */
3122 8857 protected function import_page_section_content() {
3123 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 +
3124 8872 if ( isset( $_POST['args'] ) ) {
3125 8873 $args = ! empty( $_POST['args'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['args'] ) ), true ) : array();
3126 8874 }
3127 8875
@@ -3138,8 +8886,11 @@
3138 8886 }
3139 8887
3140 8888 if ( isset( $_POST['thumb_image'] ) ) {
3141 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 );
3142 8893 }
3143 8894
3144 8895 if ( isset( $_POST['template_id'] ) ) {
3145 8896 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
@@ -3144,12 +8895,16 @@
3144 8895 if ( isset( $_POST['template_id'] ) ) {
3145 8896 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
3146 8897 }
3147 8898
3148 - if ( isset( $_POST['temp_type'] ) ) {
3149 - $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3150 - }
8899 + $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3151 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 +
3152 8907 if ( isset( $_POST['data'] ) ) {
3153 8908 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3154 8909 }
3155 8910
@@ -3169,9 +8924,27 @@
3169 8924 }
3170 8925
3171 8926 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3172 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 +
3173 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 +
3174 8947 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3175 8948 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3176 8949 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
3177 8950 $temp_con = '';
@@ -3180,9 +8953,9 @@
3180 8953 if ( empty( $content ) ) {
3181 8954 wp_send_json(
3182 8955 array(
3183 8956 'template_id' => $template_id,
3184 - 'message' => 'Content is Empty.',
8957 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3185 8958 )
3186 8959 );
3187 8960 wp_die();
3188 8961 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
@@ -3187,28 +8960,28 @@
3187 8960 wp_die();
3188 8961 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3189 8962
3190 8963 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
8964 +
3191 8965 $blocks = parse_blocks( stripslashes( $content ) );
3192 -
3193 - $blocks = $this->wdkit_media_import( $blocks, $editor );
8966 + $blocks = $this->wdkit_media_import( $blocks, $editor, $defer_media );
3194 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 +
3195 8977 $processor = new WDKIT_Nexter_Block_Processor();
3196 8978 $blocks = $processor->run( $blocks );
8979 +
3197 8980 $content = serialize_blocks( $blocks );
3198 -
8981 +
3199 8982 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3200 8983
3201 - if ( ! empty( $category_list ) && is_array( $category_list ) ) {
3202 - $category_ids = array_map( 'intval', $category_list );
3203 - wp_set_post_terms( $inserted_id, $category_ids, 'category' );
3204 - }
3205 -
3206 - if ( ! empty( $tag_list ) && is_array( $tag_list ) ) {
3207 - $tag_ids = array_map( 'intval', $tag_list );
3208 - wp_set_post_terms( $inserted_id, $tag_ids, 'post_tag' );
3209 - }
3210 -
3211 8984 $inserted_post = wp_insert_post(
3212 8985 array(
3213 8986 'post_status' => 'publish',
3214 8987 'post_type' => $post_type,
@@ -3217,8 +8990,12 @@
3217 8990 'post_content' => $content,
3218 8991 )
3219 8992 );
3220 8993
8994 + if ( ! is_wp_error( $inserted_post ) && $inserted_post ) {
8995 + self::wdkit_rebuild_block_css( $inserted_post );
8996 + }
8997 +
3221 8998 if ( is_wp_error( $inserted_post ) ) {
3222 8999 wp_send_json(
3223 9000 array(
3224 9001 'import_failed' => $inserted_post->get_error_message(),
@@ -3227,9 +9004,19 @@
3227 9004 );
3228 9005 wp_die();
3229 9006 }
3230 9007
3231 - if ( ! empty( $thumb_image ) ) {
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 ) ) {
3232 9019 // $featured_image_url = esc_url_raw( $thumb_image );
3233 9020 $tmp = download_url( $thumb_image );
3234 9021 if ( is_wp_error( $tmp ) ) {
3235 9022 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3265,9 +9052,9 @@
3265 9052 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3266 9053 if ( ! empty( $custom_meta ) ) {
3267 9054 foreach ( $custom_meta as $meta_key => $meta_val ) {
3268 9055 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3269 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9056 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3270 9057 }
3271 9058
3272 9059 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
3273 9060 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -3299,14 +9086,25 @@
3299 9086 Tpgb_Library()->remove_backend_dir_files();
3300 9087 }
3301 9088
3302 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 +
3303 9097 wp_send_json(
3304 9098 array(
3305 9099 $temp_id => $temp_detail,
3306 9100 'description' => 'Yay! Your Section has been Successfully Imported.',
3307 - 'message' => 'Successfully Imported.',
9101 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3308 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,
3309 9107 'success' => true,
3310 9108 )
3311 9109 );
3312 9110 wp_die();
@@ -3316,9 +9114,9 @@
3316 9114 if ( empty( $content ) ) {
3317 9115 wp_send_json(
3318 9116 array(
3319 9117 'template_id' => $template_id,
3320 - 'message' => 'Content is Empty.',
9118 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3321 9119 )
3322 9120 );
3323 9121 wp_die();
3324 9122 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'elementor' === $file_type ) {
@@ -3356,9 +9154,9 @@
3356 9154 }
3357 9155
3358 9156 $inserted_id = $new_document->get_main_id();
3359 9157
3360 - if ( ! empty( $thumb_image ) ) {
9158 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3361 9159 // $featured_image_url = esc_url_raw( $thumb_image );
3362 9160 $tmp = download_url( $thumb_image );
3363 9161 if ( is_wp_error( $tmp ) ) {
3364 9162 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3392,10 +9190,20 @@
3392 9190
3393 9191 $settings = ( isset( $post_content->settings ) && ! empty( $post_content->settings ) ) ? json_decode( wp_json_encode( $post_content->settings ), true ) : array();
3394 9192
3395 9193 $content = wp_json_encode( $content );
3396 - $content = $this->wdkit_media_import( $content, $file_type );
9194 + $content = $this->wdkit_media_import( $content, $file_type, $defer_media );
3397 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 +
3398 9206 $new_document->save(
3399 9207 array(
3400 9208 'elements' => $content,
3401 9209 'settings' => ! empty( $settings ) ? $settings : array(),
@@ -3412,9 +9220,9 @@
3412 9220 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3413 9221 if ( ! empty( $custom_meta ) ) {
3414 9222 foreach ( $custom_meta as $meta_key => $meta_val ) {
3415 9223 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3416 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9224 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3417 9225 }
3418 9226 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
3419 9227 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
3420 9228 }
@@ -3438,15 +9246,24 @@
3438 9246 }
3439 9247
3440 9248 \Elementor\Plugin::$instance->files_manager->clear_cache();
3441 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 +
3442 9254 wp_send_json(
3443 9255 array(
3444 9256 $temp_id => $temp_detail,
3445 9257 'content' => $temp_con,
3446 9258 'description' => 'Yay! Your Section has been Successfully Imported.',
3447 - 'message' => 'Successfully Imported.',
9259 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3448 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(),
3449 9266 'success' => true,
3450 9267 )
3451 9268 );
3452 9269 wp_die();
@@ -3476,9 +9293,14 @@
3476 9293 * Replace unicode glitch
3477 9294 *
3478 9295 * @since 2.0.0
3479 9296 */
3480 - private function replace_unicode_glitch( $content ) {
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 ) {
3481 9303
3482 9304 // Fix escaped unicode like \u003c → <
3483 9305 $content = preg_replace_callback(
3484 9306 '/\\\\u([0-9a-fA-F]{4})/',
@@ -3507,8 +9329,31 @@
3507 9329 */
3508 9330 protected function update_plugin_setting() {
3509 9331 $temp_id = isset( $_POST['plugin_type'] ) ? sanitize_text_field( $_POST['plugin_type'] ) : '';
3510 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 +
3511 9356 if ( $temp_id == 'elementor' ) {
3512 9357 $unfiltered_files = get_option( 'elementor_unfiltered_files_upload', false );
3513 9358 $load_fa4 = get_option( 'elementor_load_fa4_shim', false );
3514 9359 $Inline_font_icons = get_option( 'elementor_experiment-e_font_icon_svg', false );
@@ -3550,10 +9395,9 @@
3550 9395 'success' => false,
3551 9396 );
3552 9397 }
3553 9398
3554 - wp_send_json( $response );
3555 - wp_die();
9399 + return $response;
3556 9400 }
3557 9401
3558 9402 /**
3559 9403 * generate different color logo
@@ -3575,8 +9419,12 @@
3575 9419 }
3576 9420
3577 9421 $image_url = esc_url_raw( $_POST['image_url'] );
3578 9422
9423 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
9424 + wp_send_json_error( 'Image could not be downloaded.' );
9425 + }
9426 +
3579 9427 $tmp_file = download_url( $image_url );
3580 9428 if ( is_wp_error( $tmp_file ) ) {
3581 9429 wp_send_json_error( 'Image could not be downloaded.' );
3582 9430 }
@@ -3612,9 +9460,21 @@
3612 9460
3613 9461 $upload_dir = wp_upload_dir();
3614 9462 $result_urls = array();
3615 9463
9464 + $colour_index = 0;
3616 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 +
3617 9477 $new = imagecreatetruecolor( $width, $height );
3618 9478 imagesavealpha( $new, true );
3619 9479 imagealphablending( $new, false );
3620 9480
@@ -3640,9 +9500,9 @@
3640 9500 imagesetpixel( $new, $x, $y, $color );
3641 9501 }
3642 9502 }
3643 9503
3644 - $filename = 'colored-' . $name . '-' . time() . '.png';
9504 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
3645 9505 $filepath = $upload_dir['path'] . '/' . $filename;
3646 9506
3647 9507 imagepng( $new, $filepath );
3648 9508 imagedestroy( $new );
@@ -3673,8 +9533,29 @@
3673 9533 *
3674 9534 * @since 2.0.0
3675 9535 */
3676 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() {
3677 9558 $theme_db = get_option( 'nxt-theme-options', false );
3678 9559
3679 9560 $container_type = 'container-fluid';
3680 9561 $fluid_spacing = array(
@@ -3795,10 +9676,9 @@
3795 9676 'description' => esc_html__( 'Theme Setting updated', 'wdesignkit' ),
3796 9677 'success' => true,
3797 9678 );
3798 9679
3799 - wp_send_json( $response );
3800 - wp_die();
9680 + return $response;
3801 9681 }
3802 9682
3803 9683 /**
3804 9684 * change site setting for import kit
@@ -3805,8 +9685,22 @@
3805 9685 *
3806 9686 * @since 2.0.0
3807 9687 */
3808 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 +
3809 9703 $temp_id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
3810 9704 $shop_id = isset( $_POST['shop_id'] ) ? sanitize_text_field( wp_unslash( $_POST['shop_id'] ) ) : '';
3811 9705 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
3812 9706 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
@@ -3811,10 +9705,59 @@
3811 9705 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
3812 9706 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
3813 9707 $site_tagline = isset( $_POST['site_tagline'] ) ? sanitize_text_field( wp_unslash( $_POST['site_tagline'] ) ) : '';
3814 9708
3815 - $this->wdkit_nxt_thembuilder_update();
9709 + $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9710 + $page_information = json_decode( $page_information, true );
3816 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 +
3817 9760 if ( ! empty( $shop_id ) ) {
3818 9761 update_option( 'woocommerce_shop_page_id', $shop_id );
3819 9762 }
3820 9763
@@ -3827,8 +9770,28 @@
3827 9770 }
3828 9771
3829 9772 if ( ! empty( $site_tagline ) ) {
3830 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 + }
3831 9794 }
3832 9795
3833 9796 $response = array(
3834 9797 'message' => esc_html__( 'Site link updated', 'wdesignkit' ),
@@ -3843,10 +9806,9 @@
3843 9806 'success' => false,
3844 9807 );
3845 9808 }
3846 9809
3847 - wp_send_json( $response );
3848 - wp_die();
9810 + return $response;
3849 9811 }
3850 9812
3851 9813 /**
3852 9814 * update theme builder
@@ -3851,15 +9813,41 @@
3851 9813 /**
3852 9814 * update theme builder
3853 9815 *
3854 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.
3855 9821 */
3856 - public function wdkit_nxt_thembuilder_update() {
9822 + public function wdkit_nxt_thembuilder_update( $page_information = null, $local_only = false ) {
3857 9823
3858 - $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
3859 - $page_information = json_decode( $page_information, true );
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 + }
3860 9830
9831 + $t_finalize = microtime( true );
9832 +
3861 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 +
3862 9850 // Step 1: banavo mapping [ old_id => new_id ]
3863 9851 $id_mapping = array();
3864 9852 foreach ( $page_information as $page_info ) {
3865 9853 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -3932,32 +9920,136 @@
3932 9920 *
3933 9921 * @since 2.2.2
3934 9922 */
3935 9923 public function wdkit_check_user_credit() {
3936 - $array_data = array(
3937 - 'token' => isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '',
3938 - );
9924 + $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
3939 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 +
3940 9950 $response = $this->wkit_api_call( $array_data, 'ai/credits/get' );
3941 9951 $success = ! empty( $response['success'] ) ? $response['success'] : false;
3942 9952
3943 9953 if ( empty( $success ) ) {
3944 - $response = array(
3945 - 'success' => false,
3946 - 'message' => esc_html__( 'Data Not Found', 'wdesignkit' ),
3947 - 'description' => esc_html__( 'Data not found', 'wdesignkit' ),
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 + )
3948 9975 );
3949 -
3950 - wp_send_json( $response );
3951 9976 wp_die();
3952 9977 }
3953 9978
3954 9979 $response = json_decode( wp_json_encode( $response['data'] ), true );
3955 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 +
3956 9989 wp_send_json( $response );
3957 9990 wp_die();
3958 9991 }
3959 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 +
3960 10052 public function wdkit_nxt_thembuilder_reset() {
3961 10053 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
3962 10054 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
3963 10055
@@ -4290,10 +10382,35 @@
4290 10382 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
4291 10383 );
4292 10384 }
4293 10385
4294 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
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 );
4295 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 +
4296 10413 $json_data = wp_json_file_decode( "$json_path.json" );
4297 10414 if ( ! empty( $json_data ) ) {
4298 10415 $result = (object) array(
4299 10416 'success' => true,
@@ -4319,9 +10436,9 @@
4319 10436 *
4320 10437 * @since 1.0.0
4321 10438 */
4322 10439 protected function wdkit_download_widget() {
4323 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10440 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4324 10441 $data = json_decode( stripslashes( $data ) );
4325 10442
4326 10443 $array_data = array(
4327 10444 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4389,14 +10506,34 @@
4389 10506 if ( ! is_array( $json_data ) ) {
4390 10507 $json_data = json_decode( $json_data, true );
4391 10508 }
4392 10509
4393 - $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['name'] ) : '';
4394 - $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['type'] ) : '';
4395 - $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
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'] ) : '';
4396 10516
4397 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4398 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
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 );
4399 10536 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
4400 10537
4401 10538 if ( ! is_dir( $builder_type_path ) ) {
4402 10539 wp_mkdir_p( $builder_type_path );
@@ -4406,15 +10543,27 @@
4406 10543 wp_mkdir_p( $builder_type_path . $folder_name );
4407 10544 }
4408 10545
4409 10546 if ( ! empty( $img_url ) ) {
4410 - $img_body = wp_remote_get( $img_url );
4411 - $img_ext = pathinfo( $img_url )['extension'];
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'] );
4412 10554
4413 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
4414 - $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
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 + }
4415 10560 }
4416 10561
10562 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
10563 + wdesignkit_invalidate_widget_registry( $builder );
10564 + }
10565 +
4417 10566 // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
4418 10567 $result = (object) array(
4419 10568 'success' => true,
4420 10569 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
@@ -4432,9 +10581,9 @@
4432 10581 *
4433 10582 * @since 1.0.0
4434 10583 */
4435 10584 protected function wdkit_add_widget() {
4436 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10585 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4437 10586 $data = base64_decode( $data );
4438 10587 $data = json_decode( $data );
4439 10588
4440 10589 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -4443,9 +10592,11 @@
4443 10592 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
4444 10593
4445 10594 if ( ! empty( $w_image ) ) {
4446 10595 $w_image = str_replace( '\\', '', $w_image );
4447 - $w_image = wp_remote_get( $w_image )['body'];
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 );
4448 10599 }
4449 10600
4450 10601 $array_data = array(
4451 10602 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4485,22 +10636,51 @@
4485 10636 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
4486 10637
4487 10638 if ( ! empty( $img_url ) && 'error' !== $res ) {
4488 10639
4489 - $img_body = wp_remote_get( $img_url );
4490 - $img_ext = pathinfo( $img_url )['extension'];
4491 - include_once ABSPATH . 'wp-admin/includes/file.php';
4492 - \WP_Filesystem();
4493 - global $wp_filesystem;
4494 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4495 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
4496 - $file_path = WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name";
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 );
4497 10654
4498 - $u_r_l = wp_json_file_decode( "$file_path.json" );
4499 - $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
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 + }
4500 10670
4501 - $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
4502 - $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
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 + }
4503 10683 }
4504 10684
4505 10685 wp_send_json( $response );
4506 10686 wp_die();
@@ -4600,9 +10780,9 @@
4600 10780 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
4601 10781 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
4602 10782 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
4603 10783 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
4604 - 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
10784 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
4605 10785 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
4606 10786 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
4607 10787 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
4608 10788 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
@@ -4663,9 +10843,9 @@
4663 10843 }
4664 10844
4665 10845 $get_updated_data = get_option( 'wkit_white_label', false );
4666 10846 $response = array(
4667 - 'message' => 'Data Added successfully',
10847 + 'message' => __( 'Data Added successfully', 'wdesignkit' ),
4668 10848 'success' => true,
4669 10849 'data' => $get_updated_data,
4670 10850 );
4671 10851
@@ -4712,21 +10892,21 @@
4712 10892 if ( ! empty( $response['data'] ) ) {
4713 10893 $response = json_decode( wp_json_encode( $response['data'] ), true );
4714 10894
4715 10895 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
4716 - $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'] );
10896 + $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
4717 10897 }
4718 10898
4719 10899 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
4720 - $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'] );
10900 + $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
4721 10901 }
4722 10902
4723 10903 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
4724 - $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'] );
10904 + $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
4725 10905 }
4726 10906
4727 10907 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
4728 - $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'] );
10908 + $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
4729 10909
4730 10910 // Store WDesignKit license status locally for quick access
4731 10911 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
4732 10912 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
@@ -4733,9 +10913,9 @@
4733 10913 }
4734 10914 }
4735 10915
4736 10916 if ( ! empty( $response['data']['wdkit_licence_extra'] ) && is_serialized( $response['data']['wdkit_licence_extra'] ) ) {
4737 - $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'] );
10917 + $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
4738 10918 }
4739 10919 }
4740 10920
4741 10921 wp_send_json( $response );
@@ -4778,12 +10958,16 @@
4778 10958 */
4779 10959 protected function wdkit_sync_licence_key() {
4780 10960 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
4781 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'] ) ) : '';
4782 10965
4783 10966 $args = array(
4784 10967 'token' => $token,
4785 10968 'licencename' => $licencename,
10969 + 'apikey' => $apikey,
4786 10970 );
4787 10971
4788 10972 $response = $this->wkit_api_call( $args, 'licence_sync' );
4789 10973
@@ -4916,9 +11100,16 @@
4916 11100 $token = $this->wdkit_login_user_token( $email );
4917 11101 $args = array( 'token' => $token );
4918 11102
4919 11103 if ( 'session' !== $logout_type ) {
4920 - delete_transient( 'wdkit_auth_' . $email );
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 +
4921 11112 // Clear stored license data on logout so banner shows again
4922 11113 delete_option( 'wdkit_licence_data' );
4923 11114 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
4924 11115 }
@@ -4938,9 +11129,9 @@
4938 11129 */
4939 11130 protected function wdkit_login_user_token( $email = '' ) {
4940 11131
4941 11132 if ( ! empty( $email ) ) {
4942 - $user_key = strstr( $email, '@', true );
11133 + $user_key = wdesignkit_cloud_session_key( $email );
4943 11134 $get_login = get_transient( 'wdkit_auth_' . $user_key );
4944 11135
4945 11136 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
4946 11137 return $get_login['token'];
@@ -4958,9 +11149,9 @@
4958 11149 * @param string $data send all post data.
4959 11150 * @param string $type store text data.
4960 11151 * @param string $condition store text data.
4961 11152 */
4962 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
11153 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
4963 11154
4964 11155 if ( 'none' === $condition ) {
4965 11156 return $data[ $type ];
4966 11157 } elseif ( 'cr_widget' === $condition ) {
@@ -4965,8 +11156,10 @@
4965 11156 return $data[ $type ];
4966 11157 } elseif ( 'cr_widget' === $condition ) {
4967 11158 return $data[ $type ];
4968 11159 }
11160 +
11161 + return null;
4969 11162 }
4970 11163
4971 11164
4972 11165 /**
@@ -5120,8 +11313,570 @@
5120 11313 );
5121 11314
5122 11315 wp_send_json( $response );
5123 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 + );
5124 11879 }
5125 11880 }
5126 11881
5127 11882 Wdkit_Api_Call::get_instance();