PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 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 All 130 releases
← All changes | includes/admin/class-api.php +7484 -721 2.6.1 → 2.7.1 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':
@@ -685,15 +793,22 @@
685 793 $nexter_active_check = is_plugin_active( 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php' );
686 794
687 795 $theplus_licence = get_option( 'tpaep_licence_data', array() );
688 796
689 - 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 ) ) {
690 802 $manage_licence['tpae'] = $theplus_licence;
691 803 }
692 804
693 805 $nexter_licence = get_option( 'tpgb_activate', array() );
694 806
695 - 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'] ) ) {
696 811 $tpgb_license_status = get_option( 'tpgbp_license_status', array() );
697 812 $tpgb_license_status['license_key'] = $nexter_licence['tpgb_activate_key'];
698 813 $manage_licence['tpag'] = $tpgb_license_status;
699 814 }
@@ -749,9 +864,9 @@
749 864 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
750 865
751 866 /**Condtion user for user logout & expire token*/
752 867 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
753 - delete_transient( 'wdkit_auth_' . $email );
868 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
754 869 // Clear stored license data when token expires so banner shows again
755 870 delete_option( 'wdkit_licence_data' );
756 871 }
757 872
@@ -763,9 +878,9 @@
763 878 if ( ! empty( $response['credits']['wdkit_licence'] ) && is_array( $response['credits']['wdkit_licence'] ) ) {
764 879 $wdkit_licence = $response['credits']['wdkit_licence'];
765 880 // Handle serialized data
766 881 if ( is_string( $wdkit_licence ) && is_serialized( $wdkit_licence ) ) {
767 - $wdkit_licence = unserialize( $wdkit_licence );
882 + $wdkit_licence = unserialize( $wdkit_licence, array( 'allowed_classes' => false ) );
768 883 }
769 884 if ( ! empty( $wdkit_licence ) && is_array( $wdkit_licence ) ) {
770 885 update_option( 'wdkit_licence_data', $wdkit_licence );
771 886 }
@@ -796,8 +911,16 @@
796 911 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
797 912 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
798 913 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
799 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 +
800 923 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
801 924
802 925 $local_list = $this->wdkit_get_local_widgets();
803 926
@@ -883,13 +1006,31 @@
883 1006 );
884 1007 }
885 1008 }
886 1009
887 - $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
888 - if ( empty( $get_db_widget ) ) {
889 - add_option( 'wkit_deactivate_widgets', $db_widget, '', 'yes' );
890 - } else {
891 - 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 + }
892 1033 }
893 1034
894 1035 return $final;
895 1036 }
@@ -1026,8 +1167,24 @@
1026 1167 }
1027 1168
1028 1169 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1029 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 +
1030 1187 wp_send_json( $response );
1031 1188 wp_die();
1032 1189 }
1033 1190
@@ -1051,9 +1208,11 @@
1051 1208 'success' => false,
1052 1209 );
1053 1210 } else {
1054 1211 $temp_content = str_replace( '\\', '', $temp_content );
1055 - $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 );
1056 1215 $temp_content = base64_encode( $temp_content );
1057 1216
1058 1217 $args = array(
1059 1218 'token' => $token,
@@ -1091,10 +1250,54 @@
1091 1250 *
1092 1251 * @since 2.3.3
1093 1252 */
1094 1253 protected function wdkit_save_wp_images() {
1254 +
1095 1255 $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1096 -
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.
1097 1300 if ( empty( $image_url ) ) {
1098 1301 $response = array(
1099 1302 'message' => __( 'No Image Provided', 'wdesignkit' ),
1100 1303 'description' => __( 'No Image URL provided for save.', 'wdesignkit' ),
@@ -1101,10 +1304,43 @@
1101 1304 'success' => false,
1102 1305 );
1103 1306 } else {
1104 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 +
1105 1341 $attachment_id = media_sideload_image( $image_url, 0, null, 'id' );
1106 -
1342 +
1107 1343 if ( is_wp_error( $attachment_id ) ) {
1108 1344 $response = array(
1109 1345 'message' => __( 'Upload Failed', 'wdesignkit' ),
1110 1346 'description' => $attachment_id->get_error_message(),
@@ -1111,35 +1347,1594 @@
1111 1347 'success' => false,
1112 1348 );
1113 1349 } else {
1114 1350 $saved_url = wp_get_attachment_url( $attachment_id );
1115 -
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 +
1116 1364 $response = array(
1117 1365 'message' => __( 'Image Saved', 'wdesignkit' ),
1118 1366 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1119 1367 'success' => true,
1120 - 'url' => $saved_url,
1368 + 'url' => $saved_url,
1369 + 'id' => $attachment_id,
1121 1370 );
1122 1371 }
1123 1372
1124 1373 }
1125 1374
1126 - wp_send_json( $response );
1127 - wp_die();
1375 + return $response;
1128 1376 }
1129 1377
1130 1378 /**
1379 + * The two bundle/media primitives the PHP import runner needs, reachable without a
1380 + * request.
1131 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 + *
1132 2683 * Get Elementor Global color and Typography.
1133 2684 *
1134 2685 * @since 1.1.16
1135 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 +
1136 2914 protected function wdkit_get_global_val() {
1137 2915
1138 2916 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
1139 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 +
1140 2927 if ( 'elementor' === $builder ) {
1141 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 + }
1142 2937 if ( empty( $kit_id ) ) {
1143 2938 $response = array(
1144 2939 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1145 2940 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1214,10 +3009,40 @@
1214 3009 'viewport_md' => 768,
1215 3010 'viewport_lg' => 1025,
1216 3011 );
1217 3012
1218 - $kit_meta = $static_meta;
1219 - 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 + }
1220 3045 }
1221 3046
1222 3047 $system_colors = ! empty( $kit_meta['system_colors'] ) ? $kit_meta['system_colors'] : array();
1223 3048 $custom_colors = ! empty( $kit_meta['custom_colors'] ) ? $kit_meta['custom_colors'] : array();
@@ -1245,549 +3070,10 @@
1245 3070 $plus_settings = ! empty( $plus_settings ) ? json_decode( $plus_settings, true ) : json_decode( '[]' );
1246 3071
1247 3072 if ( empty( $plus_settings ) ) {
1248 3073
1249 - $static_meta = array(
1250 - 'active' => 'preset1',
1251 - 'darkMode' => 'none',
1252 - 'presets' => array(
1253 - 'preset1' => array(
1254 - 'name' => 'Preset 1',
1255 - 'key' => 'preset1',
1256 - 'colors' => array(
1257 - array(
1258 - 'label' => 'Primary',
1259 - 'value' => '#8072FC',
1260 - ),
1261 - array(
1262 - 'label' => 'Secondary',
1263 - 'value' => '#6FC784',
1264 - ),
1265 - array(
1266 - 'label' => 'Tertiary',
1267 - 'value' => '#FF5A6E',
1268 - ),
1269 - array(
1270 - 'label' => 'Accent',
1271 - 'value' => '#F3F3F3',
1272 - ),
1273 - array(
1274 - 'label' => 'Background',
1275 - 'value' => '#888888',
1276 - ),
1277 - ),
1278 - 'gradient' => array(
1279 - array(
1280 - 'label' => 'Primary',
1281 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1282 - ),
3074 + $static_meta = self::wdkit_default_gutenberg_globals();
1283 3075
1284 - array(
1285 - 'label' => 'Secondary',
1286 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1287 - ),
1288 -
1289 - array(
1290 - 'label' => 'Tertiary',
1291 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1292 - ),
1293 -
1294 - array(
1295 - 'label' => 'Accent',
1296 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1297 - ),
1298 -
1299 - array(
1300 - 'label' => 'Background',
1301 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1302 - ),
1303 - ),
1304 - 'spacing' => array(
1305 - array(
1306 - 'label' => 'Large',
1307 - 'value' => array(
1308 - 'md' => 70,
1309 - 'unit' => 'px',
1310 - ),
1311 - ),
1312 - array(
1313 - 'label' => 'Medium',
1314 - 'value' => array(
1315 - 'md' => 40,
1316 - 'unit' => 'px',
1317 - ),
1318 - ),
1319 - array(
1320 - 'label' => 'Small',
1321 - 'value' => array(
1322 - 'md' => 20,
1323 - 'unit' => 'px',
1324 - ),
1325 -
1326 - ),
1327 - ),
1328 - 'typography' => array(
1329 - array(
1330 - 'label' => 'Display Text',
1331 - 'value' => array(
1332 - 'openTypography' => 1,
1333 - 'size' => array(
1334 - 'md' => 65,
1335 - 'unit' => 'px',
1336 - ),
1337 - 'height' => array(
1338 - 'md' => 75,
1339 - 'unit' => 'px',
1340 - ),
1341 - 'fontFamily' => array(
1342 - 'family' => 'Roboto',
1343 - 'type' => 'sans-serif',
1344 - 'fontWeight' => 700,
1345 - ),
1346 - 'spacing' => array(
1347 - 'md' => 0,
1348 - 'unit' => 'px',
1349 - ),
1350 - ),
1351 - ),
1352 - array(
1353 - 'label' => 'Headline',
1354 - 'value' => array(
1355 - 'openTypography' => 1,
1356 - 'size' => array(
1357 - 'md' => 45,
1358 - 'unit' => 'px',
1359 - ),
1360 - 'height' => array(
1361 - 'md' => 60,
1362 - 'unit' => 'px',
1363 - ),
1364 - 'fontFamily' => array(
1365 - 'family' => 'Roboto',
1366 - 'type' => 'sans-serif',
1367 - 'fontWeight' => 700,
1368 - ),
1369 - 'spacing' => array(
1370 - 'md' => 0,
1371 - 'unit' => 'px',
1372 - ),
1373 - ),
1374 - ),
1375 - array(
1376 - 'label' => 'Sub Headline',
1377 - 'value' => array(
1378 - 'openTypography' => 1,
1379 - 'size' => array(
1380 - 'md' => 38,
1381 - 'unit' => 'px',
1382 - ),
1383 - 'height' => array(
1384 - 'md' => 45,
1385 - 'unit' => 'px',
1386 - ),
1387 - 'fontFamily' => array(
1388 - 'family' => 'Roboto',
1389 - 'type' => 'sans-serif',
1390 - 'fontWeight' => 500,
1391 - ),
1392 - 'spacing' => array(
1393 - 'md' => 0,
1394 - 'unit' => 'px',
1395 - ),
1396 - ),
1397 - ),
1398 - array(
1399 - 'label' => 'Title 1',
1400 - 'value' => array(
1401 - 'openTypography' => 1,
1402 - 'size' => array(
1403 - 'md' => 30,
1404 - 'unit' => 'px',
1405 - ),
1406 - 'height' => array(
1407 - 'md' => 40,
1408 - 'unit' => 'px',
1409 - ),
1410 - 'fontFamily' => array(
1411 - 'family' => 'Roboto',
1412 - 'type' => 'sans-serif',
1413 - 'fontWeight' => 500,
1414 - ),
1415 - 'spacing' => array(
1416 - 'md' => 0,
1417 - 'unit' => 'px',
1418 - ),
1419 - ),
1420 - ),
1421 - array(
1422 - 'label' => 'Title 2',
1423 - 'value' => array(
1424 - 'openTypography' => 1,
1425 - 'size' => array(
1426 - 'md' => 25,
1427 - 'unit' => 'px',
1428 - ),
1429 - 'height' => array(
1430 - 'md' => 30,
1431 - 'unit' => 'px',
1432 - ),
1433 - 'fontFamily' => array(
1434 - 'family' => 'Roboto',
1435 - 'type' => 'sans-serif',
1436 - 'fontWeight' => 400,
1437 - ),
1438 - 'spacing' => array(
1439 - 'md' => 0,
1440 - 'unit' => 'px',
1441 - ),
1442 - ),
1443 - ),
1444 - array(
1445 - 'label' => 'Body',
1446 - 'value' => array(
1447 - 'openTypography' => 1,
1448 - 'size' => array(
1449 - 'md' => 17,
1450 - 'unit' => 'px',
1451 - ),
1452 - 'height' => array(
1453 - 'md' => 22,
1454 - 'unit' => 'px',
1455 - ),
1456 - 'fontFamily' => array(
1457 - 'family' => 'Roboto',
1458 - 'type' => 'sans-serif',
1459 - 'fontWeight' => 400,
1460 - ),
1461 - 'spacing' => array(
1462 - 'md' => 0,
1463 - 'unit' => 'px',
1464 - ),
1465 - ),
1466 - ),
1467 - array(
1468 - 'label' => 'Captions',
1469 - 'value' => array(
1470 - 'openTypography' => 1,
1471 - 'size' => array(
1472 - 'md' => 13,
1473 - 'unit' => 'px',
1474 - ),
1475 - 'height' => array(
1476 - 'md' => 16,
1477 - 'unit' => 'px',
1478 - ),
1479 - 'fontFamily' => array(
1480 - 'family' => 'Roboto',
1481 - 'type' => 'sans-serif',
1482 - 'fontWeight' => 400,
1483 - ),
1484 - 'spacing' => array(
1485 - 'md' => 0,
1486 - 'unit' => 'px',
1487 - ),
1488 - ),
1489 - ),
1490 - ),
1491 - 'boxshadow' => array(
1492 - array(
1493 - 'label' => 'Normal Shadow',
1494 - 'value' => array(
1495 - 'openShadow' => 1,
1496 - 'inset' => 0,
1497 - 'horizontal' => 2,
1498 - 'vertical' => 6,
1499 - 'blur' => 10,
1500 - 'spread' => 0,
1501 - 'color' => 'rgba(0,0,0,0.15)',
1502 - ),
1503 - ),
1504 - array(
1505 - 'label' => 'Hover Shadow',
1506 - 'value' => array(
1507 - 'openShadow' => 1,
1508 - 'inset' => 0,
1509 - 'horizontal' => 2,
1510 - 'vertical' => 5,
1511 - 'blur' => 14,
1512 - 'spread' => 3,
1513 - 'color' => 'rgba(0,0,0,0.2)',
1514 - ),
1515 - ),
1516 - ),
1517 - ),
1518 - 'preset2' => array(
1519 - 'name' => 'Preset 2',
1520 - 'key' => 'preset2',
1521 - 'colors' => array(
1522 - array(
1523 - 'label' => 'Primary',
1524 - 'value' => '#8072FC',
1525 - ),
1526 - array(
1527 - 'label' => 'Secondary',
1528 - 'value' => '#6FC784',
1529 - ),
1530 - array(
1531 - 'label' => 'Tertiary',
1532 - 'value' => '#FF5A6E',
1533 - ),
1534 - array(
1535 - 'label' => 'Accent',
1536 - 'value' => '#F3F3F3',
1537 - ),
1538 - array(
1539 - 'label' => 'Background',
1540 - 'value' => '#888888',
1541 - ),
1542 - ),
1543 - 'gradient' => array(
1544 - array(
1545 - 'label' => 'Primary',
1546 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1547 - ),
1548 -
1549 - array(
1550 - 'label' => 'Secondary',
1551 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1552 - ),
1553 -
1554 - array(
1555 - 'label' => 'Tertiary',
1556 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1557 - ),
1558 -
1559 - array(
1560 - 'label' => 'Accent',
1561 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1562 - ),
1563 -
1564 - array(
1565 - 'label' => 'Background',
1566 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1567 - ),
1568 - ),
1569 - 'spacing' => array(
1570 - array(
1571 - 'label' => 'Large',
1572 - 'value' => array(
1573 - 'md' => 70,
1574 - 'unit' => 'px',
1575 - ),
1576 - ),
1577 - array(
1578 - 'label' => 'Medium',
1579 - 'value' => array(
1580 - 'md' => 40,
1581 - 'unit' => 'px',
1582 - ),
1583 - ),
1584 - array(
1585 - 'label' => 'Small',
1586 - 'value' => array(
1587 - 'md' => 20,
1588 - 'unit' => 'px',
1589 - ),
1590 -
1591 - ),
1592 - ),
1593 - 'typography' => array(
1594 - array(
1595 - 'label' => 'Display Text',
1596 - 'value' => array(
1597 - 'openTypography' => 1,
1598 - 'size' => array(
1599 - 'md' => 65,
1600 - 'unit' => 'px',
1601 - ),
1602 - 'height' => array(
1603 - 'md' => 75,
1604 - 'unit' => 'px',
1605 - ),
1606 - 'fontFamily' => array(
1607 - 'family' => 'Roboto',
1608 - 'type' => 'sans-serif',
1609 - 'fontWeight' => 700,
1610 - ),
1611 - 'spacing' => array(
1612 - 'md' => 0,
1613 - 'unit' => 'px',
1614 - ),
1615 - ),
1616 - ),
1617 - array(
1618 - 'label' => 'Headline',
1619 - 'value' => array(
1620 - 'openTypography' => 1,
1621 - 'size' => array(
1622 - 'md' => 45,
1623 - 'unit' => 'px',
1624 - ),
1625 - 'height' => array(
1626 - 'md' => 60,
1627 - 'unit' => 'px',
1628 - ),
1629 - 'fontFamily' => array(
1630 - 'family' => 'Roboto',
1631 - 'type' => 'sans-serif',
1632 - 'fontWeight' => 700,
1633 - ),
1634 - 'spacing' => array(
1635 - 'md' => 0,
1636 - 'unit' => 'px',
1637 - ),
1638 - ),
1639 - ),
1640 - array(
1641 - 'label' => 'Sub Headline',
1642 - 'value' => array(
1643 - 'openTypography' => 1,
1644 - 'size' => array(
1645 - 'md' => 38,
1646 - 'unit' => 'px',
1647 - ),
1648 - 'height' => array(
1649 - 'md' => 45,
1650 - 'unit' => 'px',
1651 - ),
1652 - 'fontFamily' => array(
1653 - 'family' => 'Roboto',
1654 - 'type' => 'sans-serif',
1655 - 'fontWeight' => 500,
1656 - ),
1657 - 'spacing' => array(
1658 - 'md' => 0,
1659 - 'unit' => 'px',
1660 - ),
1661 - ),
1662 - ),
1663 - array(
1664 - 'label' => 'Title 1',
1665 - 'value' => array(
1666 - 'openTypography' => 1,
1667 - 'size' => array(
1668 - 'md' => 30,
1669 - 'unit' => 'px',
1670 - ),
1671 - 'height' => array(
1672 - 'md' => 40,
1673 - 'unit' => 'px',
1674 - ),
1675 - 'fontFamily' => array(
1676 - 'family' => 'Roboto',
1677 - 'type' => 'sans-serif',
1678 - 'fontWeight' => 500,
1679 - ),
1680 - 'spacing' => array(
1681 - 'md' => 0,
1682 - 'unit' => 'px',
1683 - ),
1684 - ),
1685 - ),
1686 - array(
1687 - 'label' => 'Title 2',
1688 - 'value' => array(
1689 - 'openTypography' => 1,
1690 - 'size' => array(
1691 - 'md' => 25,
1692 - 'unit' => 'px',
1693 - ),
1694 - 'height' => array(
1695 - 'md' => 30,
1696 - 'unit' => 'px',
1697 - ),
1698 - 'fontFamily' => array(
1699 - 'family' => 'Roboto',
1700 - 'type' => 'sans-serif',
1701 - 'fontWeight' => 400,
1702 - ),
1703 - 'spacing' => array(
1704 - 'md' => 0,
1705 - 'unit' => 'px',
1706 - ),
1707 - ),
1708 - ),
1709 - array(
1710 - 'label' => 'Body',
1711 - 'value' => array(
1712 - 'openTypography' => 1,
1713 - 'size' => array(
1714 - 'md' => 17,
1715 - 'unit' => 'px',
1716 - ),
1717 - 'height' => array(
1718 - 'md' => 22,
1719 - 'unit' => 'px',
1720 - ),
1721 - 'fontFamily' => array(
1722 - 'family' => 'Roboto',
1723 - 'type' => 'sans-serif',
1724 - 'fontWeight' => 400,
1725 - ),
1726 - 'spacing' => array(
1727 - 'md' => 0,
1728 - 'unit' => 'px',
1729 - ),
1730 - ),
1731 - ),
1732 - array(
1733 - 'label' => 'Captions',
1734 - 'value' => array(
1735 - 'openTypography' => 1,
1736 - 'size' => array(
1737 - 'md' => 13,
1738 - 'unit' => 'px',
1739 - ),
1740 - 'height' => array(
1741 - 'md' => 16,
1742 - 'unit' => 'px',
1743 - ),
1744 - 'fontFamily' => array(
1745 - 'family' => 'Roboto',
1746 - 'type' => 'sans-serif',
1747 - 'fontWeight' => 400,
1748 - ),
1749 - 'spacing' => array(
1750 - 'md' => 0,
1751 - 'unit' => 'px',
1752 - ),
1753 - ),
1754 - ),
1755 - ),
1756 - 'boxshadow' => array(
1757 - array(
1758 - 'label' => 'Normal Shadow',
1759 - 'value' => array(
1760 - 'openShadow' => 1,
1761 - 'inset' => 0,
1762 - 'horizontal' => 2,
1763 - 'vertical' => 6,
1764 - 'blur' => 10,
1765 - 'spread' => 0,
1766 - 'color' => 'rgba(0,0,0,0.15)',
1767 - ),
1768 - ),
1769 - array(
1770 - 'label' => 'Hover Shadow',
1771 - 'value' => array(
1772 - 'openShadow' => 1,
1773 - 'inset' => 0,
1774 - 'horizontal' => 2,
1775 - 'vertical' => 5,
1776 - 'blur' => 14,
1777 - 'spread' => 3,
1778 - 'color' => 'rgba(0,0,0,0.2)',
1779 - ),
1780 - ),
1781 - ),
1782 - ),
1783 - ),
1784 - 'globalContainer' => array(
1785 - 'md' => '',
1786 - 'unit' => 'px',
1787 - ),
1788 - );
1789 -
1790 3076 update_option( 'tpgb_global_options', json_encode( $static_meta ) );
1791 3077 $plus_settings = $static_meta;
1792 3078 }
1793 3079
@@ -1828,8 +3114,14 @@
1828 3114 */
1829 3115 protected function wdkit_get_site_setting() {
1830 3116
1831 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 +
1832 3124 if ( empty( $kit_id ) ) {
1833 3125 $response = array(
1834 3126 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1835 3127 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1873,8 +3165,33 @@
1873 3165
1874 3166 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
1875 3167 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
1876 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 +
1877 3194 if ( 'elementor' == $builder ) {
1878 3195 $kit_id = get_option( 'elementor_active_kit' );
1879 3196 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
1880 3197 \Elementor\Core\Kits\Manager::create_default_kit();
@@ -1887,10 +3204,9 @@
1887 3204 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
1888 3205 'success' => false,
1889 3206 );
1890 3207
1891 - wp_send_json( $response );
1892 - wp_die();
3208 + return $response;
1893 3209 }
1894 3210
1895 3211 // A freshly created kit has no `_elementor_page_settings` meta yet,
1896 3212 // so an empty result here is a valid starting point, not an error.
@@ -1937,10 +3253,9 @@
1937 3253 'success' => true,
1938 3254 );
1939 3255 }
1940 3256
1941 - wp_send_json( $response );
1942 - wp_die();
3257 + return $response;
1943 3258 }
1944 3259
1945 3260 /**
1946 3261 *
@@ -1951,12 +3266,46 @@
1951 3266 protected function wdkit_update_global_val() {
1952 3267
1953 3268 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
1954 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 +
1955 3304 if ( 'elementor' == $builder ) {
1956 3305
1957 - $g_color = ! empty( $_POST['g_color'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_color'] ) ), true ) : array();
1958 - $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();
1959 3308
1960 3309 // Get colors from Elementor Site Kit
1961 3310 $kit_id = get_option( 'elementor_active_kit' );
1962 3311 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
@@ -1973,10 +3322,9 @@
1973 3322 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
1974 3323 'success' => false,
1975 3324 );
1976 3325
1977 - wp_send_json( $response );
1978 - wp_die();
3326 + return $response;
1979 3327 }
1980 3328
1981 3329 // A freshly created kit has no `_elementor_page_settings` meta yet,
1982 3330 // so an empty result here is a valid starting point, not an error.
@@ -2001,9 +3349,9 @@
2001 3349 'success' => true,
2002 3350 );
2003 3351
2004 3352 } elseif ( 'gutenberg' == $builder ) {
2005 - $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();
2006 3354 $new_preset_id = ! empty( $new_preset['key'] ) ? $new_preset['key'] : '';
2007 3355 $plus_settings = get_option( 'tpgb_global_options' );
2008 3356 $site_preset = json_decode( $plus_settings, true );
2009 3357
@@ -2025,10 +3373,9 @@
2025 3373 'success' => true,
2026 3374 );
2027 3375 }
2028 3376
2029 - wp_send_json( $response );
2030 - wp_die();
3377 + return $response;
2031 3378 }
2032 3379
2033 3380 /**
2034 3381 * Regenerate Elementor's cached CSS files after the active kit's
@@ -2306,8 +3653,32 @@
2306 3653 * @version 1.0.9
2307 3654 */
2308 3655 protected function wdkit_install_plugins_depends() {
2309 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();
2310 3681 $type = ! empty( $plugins['type'] ) ? $plugins['type'] : 'plugin';
2311 3682 $p_id = ! empty( $plugins['p_id'] ) ? $plugins['p_id'] : 'plugin';
2312 3683
2313 3684 $responce = '';
@@ -2365,12 +3736,160 @@
2365 3736 );
2366 3737 }
2367 3738 }
2368 3739
2369 - 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 + );
2370 3845 wp_die();
2371 3846 }
2372 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 +
2373 3892 protected function wdkit_install_theme_depends( $name = 'nexter' ) {
2374 3893
2375 3894 if ( ! current_user_can( 'install_themes' ) ) {
2376 3895 $response = $this->tpae_set_response( false, 'Invalid nonce.', 'The security check failed. Please refresh the page and try again.' );
@@ -2416,34 +3935,56 @@
2416 3935 $error_message = $response->get_error_message();
2417 3936
2418 3937 $result = $this->tpae_set_response( false, 'oops', 'oops', '' );
2419 3938 } else {
2420 - $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' ) ) );
2421 3945 $theme_name = $theme_info->name;
2422 3946 $theme_zip_url = $theme_info->download_link;
2423 3947
2424 - global $wp_filesystem;
2425 - // Install the theme
2426 - $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 + }
2427 3958
2428 3959 if ( ! function_exists( 'WP_Filesystem' ) ) {
2429 3960 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
2430 3961 }
2431 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 +
2432 3966 WP_Filesystem();
2433 3967
2434 3968 $active_theme = wp_get_theme();
2435 3969 $theme_name = $active_theme->get( 'Name' );
2436 3970
2437 - $wp_filesystem->put_contents( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip', $theme['body'] );
2438 - $zip = new ZipArchive();
2439 - if ( $zip->open( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' ) === true ) {
2440 - $zip->extractTo( WP_CONTENT_DIR . '/themes/' );
2441 - $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 + );
2442 3985 }
2443 3986
2444 - $wp_filesystem->delete( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' );
2445 -
2446 3987 $activate_result = switch_theme( $name );
2447 3988
2448 3989 if ( ! is_wp_error( $activate_result ) ) {
2449 3990 $response = array(
@@ -2523,8 +4064,37 @@
2523 4064 protected function wdkit_import_taxonomy() {
2524 4065 $category = isset( $_POST['category'] ) ? json_decode( wp_unslash( $_POST['category'] ) ) : array();
2525 4066 $tags = isset( $_POST['tags'] ) ? json_decode( wp_unslash( $_POST['tags'] ) ) : array();
2526 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 +
2527 4097 $response = array(
2528 4098 'success' => false,
2529 4099 'categories' => array(),
2530 4100 'tags' => array(),
@@ -2589,10 +4159,14 @@
2589 4159
2590 4160 $response['success'] = true;
2591 4161 }
2592 4162
2593 - wp_send_json( $response );
2594 - 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;
2595 4169 }
2596 4170
2597 4171 /**
2598 4172 * Get Download Template Content
@@ -2619,8 +4193,17 @@
2619 4193 $args['token'] = $this->wdkit_login_user_token( $args['email'] );
2620 4194
2621 4195 unset( $args['email'] );
2622 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 +
2623 4206 $response = WDesignKit_Data_Query::get_data( $api_type, $args );
2624 4207
2625 4208 if ( is_wp_error( $response ) ) {
2626 4209 wp_send_json( array(
@@ -2641,9 +4224,9 @@
2641 4224
2642 4225 if ( ! empty( $meta_data ) ) {
2643 4226 foreach ( $meta_data as $meta_key => $meta_val ) {
2644 4227 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
2645 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4228 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
2646 4229 }
2647 4230
2648 4231 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
2649 4232 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
@@ -2654,8 +4237,34 @@
2654 4237 }
2655 4238 }
2656 4239 }
2657 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 +
2658 4267 wp_send_json( $response );
2659 4268 wp_die();
2660 4269 }
2661 4270
@@ -2666,10 +4275,860 @@
2666 4275 *
2667 4276 * @param array $content store media content.
2668 4277 * @param string $editor it is check editor.
2669 4278 */
2670 - 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();
2671 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 +
2672 5131 if ( empty( $content ) && empty( $editor ) ) {
2673 5132 $args = $this->wdkit_parse_args( $_POST );
2674 5133 $content = ! empty( $args['content'] ) ? json_decode( $args['content'], true ) : array();
2675 5134 } else {
@@ -2687,17 +5146,164 @@
2687 5146 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
2688 5147 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
2689 5148 }
2690 5149
5150 +
2691 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 +
2692 5206 $media_import = array( $content );
2693 - $media_import = self::blocks_import_media_copy_content( $media_import );
5207 + $media_import = self::blocks_import_media_copy_content( $media_import, $defer_media );
2694 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();
2695 5252 } elseif ( ! empty( $args['editor'] ) && 'elementor' === $args['editor'] && ! empty( $content ) ) {
2696 5253 $media_import = array( $content );
5254 + // Element IDs must stay unique regardless of the media path, so this always runs.
2697 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 +
2698 5299 $media_import = self::widgets_import_media_copy_content( $media_import );
2699 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 );
2700 5306 }
2701 5307
2702 5308 return $content;
2703 5309 }
@@ -2768,9 +5374,13 @@
2768 5374 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
2769 5375 $control_name = $get_control['name'];
2770 5376
2771 5377 if ( ! $control_type ) {
2772 - 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;
2773 5383 }
2774 5384
2775 5385 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
2776 5386 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
@@ -2784,9 +5394,9 @@
2784 5394 * Blocks Recursively data
2785 5395 *
2786 5396 * @param string $data_import gutenber data import.
2787 5397 */
2788 - public static function blocks_import_media_copy_content( $data_import ) {
5398 + public static function blocks_import_media_copy_content( $data_import, $defer_media = false ) {
2789 5399 if ( ! empty( $data_import ) ) {
2790 5400 foreach ( $data_import[0] as $key => $val ) {
2791 5401 if ( array_key_exists( 'blockName', $val ) && ( empty( $val['blockName'] ) || null === $val['blockName'] || empty( $val['blockName'] ) || ' ' === $val['blockName'] ) ) {
2792 5402 unset( $data_import[0][ $key ] );
@@ -2795,12 +5405,13 @@
2795 5405 }
2796 5406
2797 5407 return self::blocks_array_recursively_data(
2798 5408 $data_import,
2799 - function ( $block_data ) {
2800 - $elements = self::blocks_data_instance( $block_data );
5409 + function ( $block_data, $args ) {
5410 + $elements = self::blocks_data_instance( $block_data, $args );
2801 5411 return $elements;
2802 - }
5412 + },
5413 + array( 'defer_media' => $defer_media )
2803 5414 );
2804 5415 }
2805 5416
2806 5417 /**
@@ -2845,73 +5456,1115 @@
2845 5456 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
2846 5457
2847 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'] ) ) ) {
2848 5459 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
2849 - foreach ( $blocks_attr as $block_key => $block_val ) {
2850 - if ( isset( $block_val['url'] ) && isset( $block_val['id'] ) && ! empty( $block_val['url'] ) ) {
2851 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2852 - $blocks_attr[ $block_key ] = $new_media;
2853 - } elseif ( isset( $block_val['url'] ) && ! empty( $block_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $block_val['url'] ) ) {
2854 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2855 - $blocks_attr[ $block_key ] = $new_media;
2856 - } elseif ( is_array( $block_val ) && ! empty( $block_val ) ) {
2857 - 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 ) ) {
2858 - foreach ( $block_val as $key => $val ) {
2859 - 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 + }
2860 5466
2861 - if ( isset( $val['url'] ) && ( isset( $val['Id'] ) || isset( $val['id'] ) ) && ! empty( $val['url'] ) ) {
2862 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2863 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2864 - } elseif ( isset( $val['url'] ) && ! empty( $val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $val['url'] ) ) {
2865 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2866 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2867 - } else {
2868 - foreach ( $val as $sub_key => $sub_val ) {
2869 - if ( isset( $sub_val['url'] ) && ( isset( $sub_val['Id'] ) || isset( $sub_val['id'] ) ) && ! empty( $sub_val['url'] ) ) {
2870 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
5467 + $block_data = self::wdkit_relink_block_markup( $block_data );
5468 + }
2871 5469
2872 - if ( is_array( $sub_val ) && is_array( $new_media ) ) {
2873 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = array_merge( $sub_val, $new_media );
2874 - } else {
2875 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2876 - }
2877 - } elseif ( isset( $sub_val['url'] ) && ! empty( $sub_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val['url'] ) ) {
2878 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
2879 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2880 - } elseif ( is_array( $sub_val ) && ! empty( $sub_val ) ) {
2881 - foreach ( $sub_val as $sub_key1 => $sub_val1 ) {
2882 - if ( isset( $sub_val1['url'] ) && ( isset( $sub_val1['Id'] ) || isset( $sub_val1['id'] ) ) && ! empty( $sub_val1['url'] ) ) {
2883 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
5470 + return $block_data;
5471 + }
2884 5472
2885 - if ( is_array( $sub_val1 ) && is_array( $new_media ) ) {
2886 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = array_merge( $sub_val1, $new_media );
2887 - } else {
2888 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2889 - }
2890 - } elseif ( isset( $sub_val1['url'] ) && ! empty( $sub_val1['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val1['url'] ) ) {
2891 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
2892 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2893 - }
2894 - }
2895 - }
2896 - }
2897 - }
2898 - }
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 );
2899 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 + }
2900 6159 }
6160 +
6161 + if ( $new_id && (int) $node['id'] !== $new_id ) {
6162 + $node['id'] = $new_id;
6163 + ++$changed;
6164 + }
2901 6165 }
2902 6166 }
2903 - if ( isset( $block_data['attributes'] ) ) {
2904 - $block_data['attributes'] = $blocks_attr;
2905 - } elseif ( isset( $block_data['attrs'] ) ) {
2906 - $block_data['attrs'] = $blocks_attr;
6167 +
6168 + foreach ( $node as $key => &$child ) {
6169 + if ( is_array( $child ) ) {
6170 + $remap( $child );
6171 + }
2907 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;
2908 6197 }
2909 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 +
2910 6511 return $block_data;
2911 6512 }
2912 6513
2913 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 + /**
2914 6567 * Kit Template Import Pages/Sections
2915 6568 *
2916 6569 * @since 1.0.0
2917 6570 * */
@@ -2964,8 +6617,12 @@
2964 6617 'website_kit' => $website_kit,
2965 6618 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
2966 6619 );
2967 6620
6621 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6622 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
6623 + }
6624 +
2968 6625 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
2969 6626 $output = array();
2970 6627
2971 6628 if ( is_wp_error( $response ) ) {
@@ -2990,43 +6647,1969 @@
2990 6647 $output['description'] = $response['description'];
2991 6648 $output['data'] = $result;
2992 6649 $output['success'] = $response['success'];
2993 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 +
2994 6671 wp_send_json( $output );
2995 6672 wp_die();
2996 6673 }
2997 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 + // The kit's Shop page is deliberately NOT made the WooCommerce shop page; see
7224 + // wdkit_release_imported_shop_page().
7225 + $this->wdkit_release_imported_shop_page();
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 + * Plus Addons widgets whose Elementor widget type is not their enable key spelled with dashes.
7589 + *
7590 + * The addon enables a widget by turning the type's dashes into underscores and looking the
7591 + * result up in its catalogue (`tp-post-title` -> `tp_post_title`). For two widgets the type
7592 + * and the catalogue key were named independently, so that lookup never matches and the
7593 + * widget stays off: Elementor then cannot resolve it and the page is saved without it
7594 + * (the Mailchimp subscribe form in the Newsletter Popup and Coming Soon pages). The pairs
7595 + * are the ones where the addon's own key => widget-type map (plus-library.php) disagrees
7596 + * with that rule.
7597 + *
7598 + * @var array<string,string> Elementor widget type => the dashed form of the addon's key.
7599 + */
7600 + private static $widget_enable_aliases = array(
7601 + 'tp-mailchimp-subscribe' => 'tp-mailchimp',
7602 + 'tp-gravityt-form' => 'tp-gravity-form',
7603 + );
7604 +
7605 + /**
7606 + * The name to hand the addon's enabler for a given Elementor widget type.
7607 + *
7608 + * @param string $type Elementor widget type.
7609 + * @return string
7610 + */
7611 + private static function wdkit_widget_enable_name( $type ) {
7612 + $type = (string) $type;
7613 +
7614 + return isset( self::$widget_enable_aliases[ $type ] ) ? self::$widget_enable_aliases[ $type ] : $type;
7615 + }
7616 +
7617 + /**
7618 + * Which of $types the addon has not actually recorded as enabled.
7619 + *
7620 + * This deliberately reads the persisted enable list rather than the live widget registry,
7621 + * because within the enable request itself the registry is always stale: TPAE registers on
7622 + * `elementor/widgets/register` (priority 100) from whatever `check_elements` held at that
7623 + * moment, so a widget enabled later in the same request cannot appear until the NEXT one.
7624 + * Measured against the real endpoint: first call reports the just-enabled widgets missing,
7625 + * an identical second call reports none. Verifying against the registry here would
7626 + * therefore fail every healthy import and trigger a pointless retry every time; the
7627 + * persisted list is what the next request will build the registry from, so it is the
7628 + * honest answer to "did the enable take".
7629 + *
7630 + * The registry check still matters — it just belongs in the request that saves, which is a
7631 + * later request. See wdkit_unregistered_widget_types().
7632 + *
7633 + * @since 2.7.0
7634 + *
7635 + * @param array $types Widget type slugs as the client sends them (`tp-post-title`).
7636 + * @return array The subset not recorded as enabled, in the same slug form.
7637 + */
7638 + private static function wdkit_not_enabled_widget_types( array $types ) {
7639 + $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7640 +
7641 + if ( empty( $types ) ) {
7642 + return array();
7643 + }
7644 +
7645 + $options = get_option( 'theplus_options' );
7646 + $enabled = ( is_array( $options ) && ! empty( $options['check_elements'] ) && is_array( $options['check_elements'] ) )
7647 + ? $options['check_elements']
7648 + : null;
7649 +
7650 + // Unverifiable — the addon's settings row does not exist yet, which is itself a state
7651 + // where the enable cannot have worked (tpae_enable_selected_widgets() writes nothing
7652 + // when that option is empty, and still reports success). Report everything as not
7653 + // enabled so the caller retries rather than trusting a success it cannot confirm.
7654 + if ( null === $enabled ) {
7655 + return $types;
7656 + }
7657 +
7658 + // The caller sends every widget a template uses, core Elementor ones included, and the
7659 + // addon quietly ignores anything outside its own catalogue — so `heading`, `image` and
7660 + // `divider` are never going to appear in its enabled list no matter how many times we
7661 + // ask. Scoping to the catalogue is what keeps this from reporting a permanent failure
7662 + // on every healthy import, which would make the retry fire every time.
7663 + $managed = null;
7664 +
7665 + // has_filter() first: it is only true once the addon has constructed its hooks
7666 + // singleton, so get_instance() below is guaranteed to hand back the existing object
7667 + // rather than build one — asking a question must not register another plugin's hooks.
7668 + if ( has_filter( 'tpae_enable_selected_widgets' )
7669 + && class_exists( 'Tpae_Hooks' )
7670 + && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
7671 + $hooks = Tpae_Hooks::get_instance();
7672 +
7673 + if ( isset( $hooks->all_widgets ) && is_array( $hooks->all_widgets ) ) {
7674 + $managed = $hooks->all_widgets;
7675 + }
7676 + }
7677 +
7678 + // No catalogue to scope against. The settings row exists, so the enable had somewhere
7679 + // to write; treat that as verified rather than inventing a failure we cannot support.
7680 + // The save-time guard still catches anything that really did not register.
7681 + if ( null === $managed ) {
7682 + return array();
7683 + }
7684 +
7685 + $missing = array();
7686 +
7687 + foreach ( $types as $type ) {
7688 + // Same normalisation the addon applies before storing (`tp-post-title` -> `tp_post_title`).
7689 + $slug = str_replace( '-', '_', self::wdkit_widget_enable_name( $type ) );
7690 +
7691 + if ( in_array( $slug, $managed, true ) && ! in_array( $slug, $enabled, true ) ) {
7692 + $missing[] = $type;
7693 + }
7694 + }
7695 +
7696 + return $missing;
7697 + }
7698 +
7699 + /**
7700 + * Every widgetType named anywhere in a payload, whatever shape it arrives in.
7701 + *
7702 + * Scans the serialized form rather than walking the tree so it cannot drift from the
7703 + * nesting rules Elementor actually uses (containers, inner sections, nested repeaters).
7704 + *
7705 + * @since 2.7.0
7706 + *
7707 + * @param mixed $payload Array, object or JSON string.
7708 + * @return array Unique widget type slugs.
7709 + */
7710 + /**
7711 + * Drop the redundant "Page" from an imported page's title.
7712 + *
7713 + * Catalog titles are written for browsing a list of kits ("About Us Page | Taj Bakery"),
7714 + * where the word does useful work. On the site itself every entry in Pages is a page, so it
7715 + * is noise on every row - and it rides along into the fallback slug too.
7716 + *
7717 + * Only stripped when it is a standalone word sitting immediately before the kit separator
7718 + * or at the end of the title, so a kit or page whose name legitimately contains the word
7719 + * ("Page Builder | …", "Landing Pages Bundle") keeps it. Pages only: a theme-builder
7720 + * template called "Blog Single Page" is describing what it is, not repeating itself, and
7721 + * those live in a different list entirely.
7722 + *
7723 + * @since 2.7.1
7724 + *
7725 + * @param string $title Catalog title.
7726 + * @param string $post_type Destination post type.
7727 + * @return string Title to save.
7728 + */
7729 + private static function wdkit_clean_page_title( $title, $post_type ) {
7730 +
7731 + if ( 'page' !== $post_type || '' === (string) $title ) {
7732 + return $title;
7733 + }
7734 +
7735 + $clean = preg_replace( '/\s*\bPages?\b(?=\s*(?:\||$))/i', '', (string) $title );
7736 + $clean = trim( preg_replace( '/\s+/', ' ', (string) $clean ) );
7737 +
7738 + // A title that was only ever the word itself ("Page", "Page | Kit") would come back
7739 + // empty or orphaned from its separator; leave those exactly as the catalog wrote them.
7740 + if ( '' === $clean || 0 === strpos( $clean, '|' ) ) {
7741 + return $title;
7742 + }
7743 +
7744 + /* Drop the kit's name: "About Us | Zion Technology" -> "About Us".
7745 + *
7746 + * Catalog titles are "<page> | <kit>", and the kit half is the demo brand. Keeping it
7747 + * named every imported page after someone else's business — it showed in the browser
7748 + * tab and in the theme's page heading ("About Us | Zion Technology" on a freight
7749 + * company's site). Both other ways a page can be created already cut here: the
7750 + * browser's import_new_temp() does `title.split("|")[0]`, and
7751 + * Wdkit_Page_Importer::store() does `explode( '|', … )[0]`. This path — the one-request
7752 + * bundle insert, which is the one the runner actually uses — was the only one that
7753 + * did not. */
7754 + $parts = explode( '|', $clean );
7755 + $name = trim( $parts[0] );
7756 +
7757 + return '' !== $name ? $name : $clean;
7758 + }
7759 +
7760 + /**
7761 + * Point a header's logo at the home page when the template shipped it unlinked.
7762 + *
7763 + * Kits are inconsistent here: some ship the logo image with `link_to: custom` and the
7764 + * authoring site's home URL (which the nav remap then rewrites to this site's), and some
7765 + * ship it with no link control at all. Elementor's image widget defaults to `link_to: none`,
7766 + * so the second kind renders a bare <img> and the logo is not clickable - which is not a
7767 + * choice any of these templates meant to make.
7768 + *
7769 + * Deliberately narrow, because "which image is the logo" is a guess everywhere else:
7770 + *
7771 + * - headers only, identified by the theme-builder section meta rather than the title, which
7772 + * is translated and kit-specific;
7773 + * - the core `image` widget only;
7774 + * - and only when the widget has no link at all. A template that set one - to home or
7775 + * anywhere else - is left exactly as its author wrote it.
7776 + *
7777 + * @since 2.7.1
7778 + *
7779 + * @param mixed $elements Elementor `elements` data.
7780 + * @param mixed $custom_meta The template's custom_meta (meta_key => array(value)).
7781 + * @return mixed Elements, with the logo linked where it was not.
7782 + */
7783 + private static function wdkit_link_header_logo( $elements, $custom_meta ) {
7784 +
7785 + $meta = is_object( $custom_meta ) ? get_object_vars( $custom_meta ) : $custom_meta;
7786 +
7787 + if ( ! is_array( $meta ) || empty( $meta['nxt-hooks-layout-sections'] ) ) {
7788 + return $elements;
7789 + }
7790 +
7791 + $section = $meta['nxt-hooks-layout-sections'];
7792 + $section = is_array( $section ) ? reset( $section ) : $section;
7793 +
7794 + if ( 'header' !== $section ) {
7795 + return $elements;
7796 + }
7797 +
7798 + $home = home_url( '/' );
7799 +
7800 + $walk = function ( $node ) use ( &$walk, $home ) {
7801 + if ( ! is_array( $node ) ) {
7802 + return $node;
7803 + }
7804 +
7805 + if ( isset( $node['elType'], $node['widgetType'] )
7806 + && 'widget' === $node['elType'] && 'image' === $node['widgetType'] ) {
7807 +
7808 + $settings = isset( $node['settings'] ) && is_array( $node['settings'] ) ? $node['settings'] : array();
7809 + $link_to = isset( $settings['link_to'] ) ? (string) $settings['link_to'] : '';
7810 + $link_url = isset( $settings['link']['url'] ) ? (string) $settings['link']['url'] : '';
7811 +
7812 + if ( '' === $link_url && ( '' === $link_to || 'none' === $link_to ) ) {
7813 + $settings['link_to'] = 'custom';
7814 + $settings['link'] = array(
7815 + 'url' => $home,
7816 + 'is_external' => '',
7817 + 'nofollow' => '',
7818 + 'custom_attributes' => '',
7819 + );
7820 +
7821 + $node['settings'] = $settings;
7822 + }
7823 + }
7824 +
7825 + if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
7826 + foreach ( $node['elements'] as $i => $child ) {
7827 + $node['elements'][ $i ] = $walk( $child );
7828 + }
7829 + }
7830 +
7831 + return $node;
7832 + };
7833 +
7834 + if ( ! is_array( $elements ) ) {
7835 + return $elements;
7836 + }
7837 +
7838 + foreach ( $elements as $i => $node ) {
7839 + $elements[ $i ] = $walk( $node );
7840 + }
7841 +
7842 + return $elements;
7843 + }
7844 +
7845 + private static function wdkit_widget_types_in( $payload ) {
7846 + $json = is_string( $payload ) ? $payload : (string) wp_json_encode( $payload );
7847 +
7848 + if ( ! is_string( $json ) || ! preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $json, $matches ) ) {
7849 + return array();
7850 + }
7851 +
7852 + return array_values( array_unique( $matches[1] ) );
7853 + }
7854 +
7855 + /**
7856 + * Which of $types Elementor cannot resolve right now.
7857 + *
7858 + * This is the check that makes an unregistered widget visible. Document::save() walks
7859 + * every node through Elements_Manager::create_element_instance(), which returns null for a
7860 + * widgetType the widgets manager does not know, and get_elements_raw_data() then
7861 + * `continue`s past it — the container survives, the widget is dropped, and the save still
7862 + * reports success. TPAE documents the same behaviour from the other side (see
7863 + * theplus-include-widgets.php: "without a registered type Elementor deletes their saved
7864 + * nodes on the next save"). So an unregistered type is silent data loss, not an error, and
7865 + * nothing downstream will ever tell us it happened.
7866 + *
7867 + * @since 2.7.0
7868 + *
7869 + * @param array $types Widget type slugs.
7870 + * @return array The subset that has no registered type.
7871 + */
7872 + private static function wdkit_unregistered_widget_types( array $types ) {
7873 + $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7874 +
7875 + if ( empty( $types ) ) {
7876 + return array();
7877 + }
7878 +
7879 + // Nothing can be resolved without the widgets manager, so everything is missing —
7880 + // which is the honest answer, and the one that stops a stripped save going out quietly.
7881 + if ( ! did_action( 'elementor/loaded' ) || ! isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
7882 + return $types;
7883 + }
7884 +
7885 + $known = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
7886 + $known = is_array( $known ) ? array_keys( $known ) : array();
7887 +
7888 + return array_values( array_diff( $types, $known ) );
7889 + }
7890 +
7891 + /**
7892 + * Make sure the media importer class is loaded before it is used.
7893 + *
7894 + * The class was only required deep inside the two media walks, so any call reached before
7895 + * one of those ran hit a fatal - "Class \"Wdkit_Import_Images\" not found". The per-item
7896 + * deferred-list clear at the top of wdkit_bundle_insert_item() is exactly such a call: it
7897 + * runs before any walk, by design, so that per-item isolation does not depend on which
7898 + * branch happens to execute.
7899 + *
7900 + * @since 2.7.1
7901 + *
7902 + * @return void
7903 + */
7904 + private static function wdkit_require_import_images() {
7905 + if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
7906 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
7907 + }
7908 + }
7909 +
7910 + /**
7911 + * The four placeholder shapes a wireframe import swaps images for.
7912 + *
7913 + * Same files the browser uses, so a wireframe site looks identical whichever route
7914 + * built it.
7915 + *
7916 + * @since 2.7.1
7917 + *
7918 + * @return array<string,string> ratio-name => absolute url.
7919 + */
7920 + private static function wdkit_wireframe_placeholders() {
7921 +
7922 + $base = WDKIT_SERVER_API_URL . 'images/v2/plugin/template/';
7923 +
7924 + return array(
7925 + 'hero' => $base . 'hero-placeholder.png',
7926 + 'long' => $base . 'long-placeholder.png',
7927 + 'square' => $base . 'square-placeholder.png',
7928 + 'vertical' => $base . 'vertical-placeholder.png',
7929 + );
7930 + }
7931 +
7932 + /**
7933 + * Swap a bundle item's images for wireframe placeholders, server-side.
7934 + *
7935 + * "Import as Wireframe" was only ever honoured on routes that hand the template content
7936 + * to the browser - the AI path, and the legacy per-template path. A dummy import takes
7937 + * the one-request route instead (see import_site_bundle() in import_loader.js), where the
7938 + * server fetches and builds the whole kit and the content never reaches the browser at
7939 + * all, so the client-side swap could not run and the toggle silently did nothing.
7940 + *
7941 + * It looked intermittent because the fast route only wins when it can: on a site whose
7942 + * plugins were just installed it bails and the legacy path takes over, which DOES swap.
7943 + * So wireframe worked on a fresh site and stopped working on a warm one - reproduced by
7944 + * importing a kit with AI first, then re-importing it as dummy with wireframe on.
7945 + *
7946 + * Shape is chosen from the WordPress size suffix when the URL carries one
7947 + * (`-700x700` -> square); everything else gets the hero 4:3, matching what the browser
7948 + * falls back to when it cannot measure a source image. SVGs are deliberately untouched:
7949 + * the browser's swap skips them too, so icons and logos survive on both routes.
7950 + *
7951 + * @since 2.7.1
7952 + *
7953 + * @param array $item Bundle item, whose `content` holds the template payload.
7954 + * @return array The item with its raster image URLs pointed at placeholders.
7955 + */
7956 + private static function wdkit_wireframe_bundle_item( $item ) {
7957 +
7958 + if ( empty( $item['content'] ) ) {
7959 + return $item;
7960 + }
7961 +
7962 + $is_string = is_string( $item['content'] );
7963 + $raw = $is_string ? $item['content'] : wp_json_encode( $item['content'] );
7964 +
7965 + if ( ! is_string( $raw ) || '' === $raw ) {
7966 + return $item;
7967 + }
7968 +
7969 + $shapes = self::wdkit_wireframe_placeholders();
7970 +
7971 + // Both spellings, because a url sits raw in markup and slash-escaped inside the
7972 + // builder's JSON - the same pairing wdkit_replace_url_all_encodings() handles.
7973 + $flat = str_replace( '\\/', '/', $raw );
7974 +
7975 + if ( ! preg_match_all( '#https?://[^"\'\s<>()\\\\]+\.(?:jpe?g|png|gif|webp|avif)#i', $flat, $matches ) ) {
7976 + return $item;
7977 + }
7978 +
7979 + $map = array();
7980 +
7981 + foreach ( array_unique( $matches[0] ) as $url ) {
7982 + // Already a placeholder (a re-import of an already-wireframed kit) - leave it.
7983 + if ( preg_match( '/(hero|long|square|vertical)-placeholder/', $url ) ) {
7984 + continue;
7985 + }
7986 +
7987 + $shape = 'hero';
7988 +
7989 + if ( preg_match( '/-(\d+)x(\d+)\.[A-Za-z0-9]+$/', $url, $dim ) ) {
7990 + $w = (int) $dim[1];
7991 + $h = (int) $dim[2];
7992 +
7993 + if ( $w > 0 && $h > 0 ) {
7994 + $ratio = $w / $h;
7995 +
7996 + if ( $ratio >= 1.6 ) {
7997 + $shape = 'long';
7998 + } elseif ( $ratio <= 0.75 ) {
7999 + $shape = 'vertical';
8000 + } elseif ( $ratio >= 0.9 && $ratio <= 1.1 ) {
8001 + $shape = 'square';
8002 + }
8003 + }
8004 + }
8005 +
8006 + $map[ $url ] = $shapes[ $shape ];
8007 + }
8008 +
8009 + if ( empty( $map ) ) {
8010 + return $item;
8011 + }
8012 +
8013 + foreach ( $map as $from => $to ) {
8014 + $raw = str_replace(
8015 + array( $from, str_replace( '/', '\\/', $from ) ),
8016 + array( $to, str_replace( '/', '\\/', $to ) ),
8017 + $raw
8018 + );
8019 + }
8020 +
8021 + $item['content'] = $is_string ? $raw : json_decode( $raw, true );
8022 +
8023 + return $item;
8024 + }
8025 +
8026 + private function wdkit_bundle_insert_item( $item, $builder, $defer_media, $custom_meta ) {
8027 +
8028 + // Start every item with an empty deferred list, unconditionally.
8029 + //
8030 + // Wdkit_Import_Images::$deferred_urls is static, and only the two `if ( $defer_media )`
8031 + // unions below drain it. Anything recorded on a path that does not reach one of them
8032 + // stays in the array and is handed to the NEXT item in the batch, which then schedules
8033 + // a cron event that rewrites a different page's media. Video makes this reachable in
8034 + // normal use: it defers regardless of $defer_media (see wdkit_Import_media()), so a
8035 + // non-deferred item carrying one leaves a URL behind. Clearing here means per-item
8036 + // isolation does not depend on which branch happens to run.
8037 + self::wdkit_require_import_images();
8038 + Wdkit_Import_Images::get_and_clear_deferred_urls();
8039 +
8040 + $raw_content = isset( $item['content'] ) ? $item['content'] : '';
8041 +
8042 + // The cloud ships each template's file byte-for-byte as the per-template route does,
8043 + // so this decodes to the same object import_page_section_content() works with.
8044 + $post_content = is_string( $raw_content )
8045 + ? json_decode( $raw_content )
8046 + : json_decode( wp_json_encode( $raw_content ) );
8047 +
8048 + if ( ! is_object( $post_content ) ) {
8049 + return array(
8050 + 'success' => false,
8051 + 'message' => esc_html__( 'Template content could not be read.', 'wdesignkit' ),
8052 + );
8053 + }
8054 +
8055 + $editor = ! empty( $item['editor'] ) ? sanitize_text_field( $item['editor'] ) : $builder;
8056 + $file_type = ! empty( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
8057 +
8058 + // import_page_section_content() requires file_type to name the editor and falls
8059 + // through to "Something went wrong" when it does not. Silently dropping a page is
8060 + // worse in a whole-site import, so fall back to the editor the bundle resolved for
8061 + // this template rather than bailing.
8062 + if ( '' === $file_type ) {
8063 + $file_type = ( 'gutenberg' === $editor ) ? 'wp_block' : 'elementor';
8064 + }
8065 +
8066 + $editor = ( 'wp_block' === $file_type ) ? 'gutenberg' : 'elementor';
8067 +
8068 + $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8069 + if ( '' === $post_title ) {
8070 + $post_title = isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '';
8071 + }
8072 +
8073 + // Before the slug is derived below, so the fallback slug loses the word too.
8074 + $post_title = self::wdkit_clean_page_title(
8075 + $post_title,
8076 + ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page'
8077 + );
8078 +
8079 + $post_slug = isset( $post_content->slug ) ? sanitize_title( $post_content->slug ) : '';
8080 +
8081 + // Falling straight back to the title gives every page a slug like
8082 + // "about-us-page-zion-technology" - the catalog's own display title, kit name and
8083 + // all, since that title is written for browsing a list of kits, not for a URL. The
8084 + // page's own path on the demo site (already fetched for the nav-link remap above) is
8085 + // a real slug someone chose for this exact page, so prefer that - except for the
8086 + // pages whose whole demo path IS the kit's folder (the homepage, and section-only
8087 + // entries like Header/Footer with no page of their own), where the segment left after
8088 + // stripping the kit folder is empty and there is nothing better to use than the title.
8089 + if ( '' === $post_slug && ! empty( $item['post_url'] ) ) {
8090 + $path_segments = array_values( array_filter( explode( '/', (string) wp_parse_url( (string) $item['post_url'], PHP_URL_PATH ) ) ) );
8091 +
8092 + if ( count( $path_segments ) > 1 ) {
8093 + $post_slug = sanitize_title( end( $path_segments ) );
8094 + }
8095 + }
8096 +
8097 + if ( '' === $post_slug ) {
8098 + $post_slug = sanitize_title( $post_title );
8099 + }
8100 +
8101 + $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
8102 +
8103 + if ( empty( $content ) ) {
8104 + return array(
8105 + 'success' => false,
8106 + 'message' => esc_html__( 'Content is Empty.', 'wdesignkit' ),
8107 + );
8108 + }
8109 +
8110 + // Whitelist the destination post type against the plugin's own list instead of
8111 + // trusting whatever the payload names.
8112 + $enqueue_instance = new Wdkit_Enqueue();
8113 + $allowed_types = $enqueue_instance->wdkit_get_post_type_list();
8114 + $post_type = ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page';
8115 +
8116 + if ( ! array_key_exists( $post_type, $allowed_types ) ) {
8117 + $post_type = 'page';
8118 + }
8119 +
8120 + $widget_list = ! empty( $post_content->widget_list )
8121 + ? json_decode( wp_json_encode( $post_content->widget_list ), true )
8122 + : array();
8123 +
8124 + $image_urls = ( ! empty( $item['image_urls'] ) && is_array( $item['image_urls'] ) )
8125 + ? array_values( array_filter( array_map( 'esc_url_raw', $item['image_urls'] ) ) )
8126 + : array();
8127 +
8128 + // Same gate as the pages route - this half of the bundle payload is client-supplied too.
8129 + $image_urls = self::wdkit_filter_fetchable_urls( $image_urls );
8130 +
8131 + // Per-template millisecond costs, returned to the client so a staging run can be read
8132 + // straight off the network tab instead of from a log file.
8133 + $timing = array();
8134 + $t_item = microtime( true );
8135 + $inserted_id = 0;
8136 +
8137 + // Only the Elementor branch can lose widgets this way; Gutenberg blocks are stored
8138 + // as serialized markup and are not resolved through a type registry on save.
8139 + $unregistered = array();
8140 +
8141 + if ( 'gutenberg' === $editor ) {
8142 +
8143 + $blocks = parse_blocks( stripslashes( $content ) );
8144 +
8145 + $t_media = microtime( true );
8146 + $blocks = $this->wdkit_media_import( $blocks, 'gutenberg', $defer_media );
8147 + $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8148 +
8149 + // The cloud's image_urls (from the per-template site_images DB field) is not a
8150 + // reliable manifest - most templates never have it populated, so the background
8151 + // sideload pass would otherwise never get scheduled and these images would stay
8152 + // pointed at the source CDN forever. wdkit_Import_media() already recorded every
8153 + // URL it deferred while walking $blocks above; union that in here instead of
8154 + // depending on the cloud field alone.
8155 + if ( $defer_media ) {
8156 + $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8157 + $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8158 + }
8159 +
8160 + if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
8161 + $t_proc = microtime( true );
8162 + $processor = new WDKIT_Nexter_Block_Processor();
8163 + $blocks = $processor->run( $blocks );
8164 + $timing['block_processor'] = (int) round( ( microtime( true ) - $t_proc ) * 1000 );
8165 + }
8166 +
8167 + $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
8168 +
8169 + $t_insert = microtime( true );
8170 + $inserted_id = wp_insert_post(
8171 + array(
8172 + 'post_status' => 'publish',
8173 + 'post_type' => $post_type,
8174 + 'post_title' => $post_title,
8175 + 'post_name' => $post_slug,
8176 + 'post_content' => $content,
8177 + )
8178 + );
8179 +
8180 + $timing['post_insert'] = (int) round( ( microtime( true ) - $t_insert ) * 1000 );
8181 +
8182 + if ( is_wp_error( $inserted_id ) ) {
8183 + return array(
8184 + 'success' => false,
8185 + 'message' => $inserted_id->get_error_message(),
8186 + );
8187 + }
8188 +
8189 + $t_css = microtime( true );
8190 + self::wdkit_rebuild_block_css( $inserted_id );
8191 + $timing['block_css'] = (int) round( ( microtime( true ) - $t_css ) * 1000 );
8192 +
8193 + } else {
8194 +
8195 + if ( ! did_action( 'elementor/loaded' ) ) {
8196 + return array(
8197 + 'success' => false,
8198 + 'message' => esc_html__( 'Relevant Page Builder not installed or activated', 'wdesignkit' ),
8199 + );
8200 + }
8201 +
8202 + $content = $this->wdkit_content_remover( $content );
8203 +
8204 + $post_attributes = array(
8205 + 'post_title' => $post_title,
8206 + 'post_type' => $post_type,
8207 + 'post_status' => 'publish',
8208 + 'post_name' => $post_slug,
8209 + );
8210 +
8211 + // documents->create() is what makes this a real Elementor document — it owns
8212 + // _elementor_data, _elementor_edit_mode, _elementor_version and the template
8213 + // type. Writing that meta by hand is exactly what leaves a page opening as raw
8214 + // JSON in the Text Editor, so the canonical path never does.
8215 + $t_create = microtime( true );
8216 + if ( 'elementor_library' === $post_type ) {
8217 + $el_type = ! empty( $post_content->el_type ) ? sanitize_text_field( $post_content->el_type ) : 'page';
8218 + $new_document = \Elementor\Plugin::$instance->documents->create( $el_type, $post_attributes );
8219 + } else {
8220 + $new_document = \Elementor\Plugin::$instance->documents->create( $post_type, $post_attributes );
8221 + }
8222 + $timing['document_create'] = (int) round( ( microtime( true ) - $t_create ) * 1000 );
8223 +
8224 + if ( is_wp_error( $new_document ) ) {
8225 + return array(
8226 + 'success' => false,
8227 + 'message' => $new_document->get_error_message(),
8228 + );
8229 + }
8230 +
8231 + $settings = ! empty( $post_content->settings )
8232 + ? json_decode( wp_json_encode( $post_content->settings ), true )
8233 + : array();
8234 +
8235 + $elements = wp_json_encode( $content );
8236 +
8237 + $t_media = microtime( true );
8238 + $elements = $this->wdkit_media_import( $elements, 'elementor', $defer_media );
8239 + $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8240 +
8241 + // Same union the Gutenberg branch above does, and for the same reason: the cloud's
8242 + // image_urls is a manifest of the template's *images*, so anything the walk itself
8243 + // discovered - a video especially, which nothing else lists - is only ever going to
8244 + // reach the background pass if it is added here. Without this, wdkit_media_import()
8245 + // recorded those URLs and the schedule then ignored them, leaving the reference
8246 + // pointing at the authoring site for good.
8247 + if ( $defer_media ) {
8248 + $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8249 + $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8250 + }
8251 +
8252 + // A header whose logo the template left unlinked gets one to the home page, before
8253 + // the save rather than as a second write.
8254 + $elements = self::wdkit_link_header_logo( $elements, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
8255 +
8256 + // Read the registry as late as possible — right before the save that consumes it.
8257 + // Anything named here is about to be dropped on the floor by Document::save()
8258 + // (see wdkit_unregistered_widget_types()), and this is the only moment the
8259 + // information still exists: afterwards the row simply looks like a page that was
8260 + // authored without those widgets.
8261 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8262 +
8263 + if ( ! empty( $unregistered ) ) {
8264 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8265 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8266 + }
8267 +
8268 + $t_save = microtime( true );
8269 + $new_document->save(
8270 + array(
8271 + 'elements' => $elements,
8272 + 'settings' => ! empty( $settings ) ? $settings : array(),
8273 + )
8274 + );
8275 + $timing['document_save'] = (int) round( ( microtime( true ) - $t_save ) * 1000 );
8276 +
8277 + $inserted_id = $new_document->get_main_id();
8278 + }
8279 +
8280 + if ( empty( $inserted_id ) ) {
8281 + return array(
8282 + 'success' => false,
8283 + 'message' => esc_html__( 'Something went wrong', 'wdesignkit' ),
8284 + );
8285 + }
8286 +
8287 + // nxt-* meta: theme-builder display conditions and similar. allowed_classes => false
8288 + // because the value comes from the imported kit body, where a serialized object could
8289 + // fire a POP gadget chain in any loaded plugin or theme (CWE-502) — see the identical
8290 + // restore in import_page_section_content().
8291 + if ( $custom_meta && ! empty( $post_content->custom_meta ) ) {
8292 + $meta_list = json_decode( wp_json_encode( $post_content->custom_meta ), true );
8293 +
8294 + if ( is_array( $meta_list ) ) {
8295 + foreach ( $meta_list as $meta_key => $meta_val ) {
8296 + if ( ! isset( $meta_val[0] ) ) {
8297 + continue;
8298 + }
8299 +
8300 + $value = $meta_val[0];
8301 +
8302 + if ( is_string( $value ) && is_serialized( $value ) ) {
8303 + $value = unserialize( $value, array( 'allowed_classes' => false ) );
8304 + }
8305 +
8306 + if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
8307 + add_post_meta( $inserted_id, $meta_key, $value );
8308 + }
8309 + }
8310 + }
8311 + }
8312 +
8313 + clean_post_cache( $inserted_id );
8314 +
8315 + // Diagnostic: how many widgets arrived for this template versus how many actually
8316 + // landed in the row. A template that comes in with content and stores none is a
8317 + // silent data loss - the page still imports, reports success, and renders empty.
8318 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
8319 + $in_widgets = preg_match_all( '/"widgetType":/', (string) wp_json_encode( $post_content ) );
8320 + $stored_raw = get_post_meta( $inserted_id, '_elementor_data', true );
8321 + $stored_widgets = preg_match_all( '/"widgetType":/', is_string( $stored_raw ) ? $stored_raw : (string) wp_json_encode( $stored_raw ) );
8322 +
8323 + error_log(
8324 + sprintf(
8325 + 'WDKIT insert: cloud=%s post=%d editor=%s title=%s widgets_in=%d widgets_stored=%d%s',
8326 + isset( $item['id'] ) ? $item['id'] : '?',
8327 + (int) $inserted_id,
8328 + $editor,
8329 + $post_title,
8330 + (int) $in_widgets,
8331 + (int) $stored_widgets,
8332 + ( $in_widgets > 0 && 0 === (int) $stored_widgets ) ? ' <-- LOST' : ''
8333 + )
8334 + );
8335 + }
8336 +
8337 + $timing['custom_meta'] = isset( $timing['custom_meta'] ) ? $timing['custom_meta'] : 0;
8338 + $timing['item_total'] = (int) round( ( microtime( true ) - $t_item ) * 1000 );
8339 +
8340 + // Deliberately not behind WP_DEBUG. The page imported, reported success and renders —
8341 + // it is simply missing widgets nobody asked it to drop, so a log line the site owner
8342 + // never enabled is the difference between a caught regression and a silent one.
8343 + if ( ! empty( $unregistered ) ) {
8344 + error_log(
8345 + sprintf(
8346 + 'WDKIT: %s (post %d) was saved without %d widget type(s) Elementor could not resolve: %s',
8347 + $post_title,
8348 + (int) $inserted_id,
8349 + count( $unregistered ),
8350 + implode( ', ', $unregistered )
8351 + )
8352 + );
8353 + }
8354 +
8355 + return array(
8356 + 'success' => true,
8357 + 'timing_ms' => $timing,
8358 + // Empty on every healthy import. Non-empty means this template was stored with
8359 + // content missing — wkit_import_site_bundle() turns it into a reported error.
8360 + 'unregistered' => ! empty( $unregistered ) ? $unregistered : array(),
8361 + 'id' => (int) $inserted_id,
8362 + // The template's id on the source site. wdkit_nxt_thembuilder_update() needs it to
8363 + // remap nxt_builder display conditions onto the ids this import just created.
8364 + 'old_page_id' => isset( $post_content->page_id ) ? $post_content->page_id : '',
8365 + 'title' => get_the_title( $inserted_id ),
8366 + 'edit_link' => get_edit_post_link( $inserted_id, 'internal' ),
8367 + 'view' => get_permalink( $inserted_id ),
8368 + 'editor' => $editor,
8369 + 'post_type' => $post_type,
8370 + 'widget_list' => is_array( $widget_list ) ? $widget_list : array(),
8371 + 'image_urls' => $image_urls,
8372 + 'post_url' => isset( $item['post_url'] ) ? sanitize_text_field( (string) $item['post_url'] ) : '',
8373 + 'is_front_page' => ! empty( $item['is_front_page'] ),
8374 + 'is_shop_page' => ! empty( $item['is_shop_page'] ),
8375 + );
8376 + }
8377 +
8378 + /**
8379 + * Create — or extend — the primary menu from the bundle's pre-ordered nav list.
8380 + *
8381 + * Two things here that a naive rebuild gets wrong on a site that is not empty: pages
8382 + * already in the menu are skipped, so re-importing a kit does not stack duplicate items;
8383 + * and only theme menu locations the site has left EMPTY get filled, so an existing
8384 + * menu assignment is never replaced by the imported one.
8385 + *
8386 + * @since 2.7.0
8387 + *
8388 + * @param array $nav_menu Bundle nav entries: template_id, title, position.
8389 + * @param array $imported Results from wdkit_bundle_insert_item().
8390 + * @param array $template_map Cloud template id => inserted post id.
8391 + * @return int Menu term id, or 0 when no menu was touched.
8392 + */
8393 + private function wdkit_bundle_build_menu( $nav_menu, $imported, $template_map ) {
8394 +
8395 + // Older bundles (and any kit whose pages are all untitled) carry no nav list — fall
8396 + // back to the pages this request created, in insertion order.
8397 + if ( empty( $nav_menu ) ) {
8398 + foreach ( $imported as $entry ) {
8399 + if ( 'page' === $entry['group'] && 'page' === $entry['post_type'] ) {
8400 + $nav_menu[] = array(
8401 + 'post_id' => $entry['id'],
8402 + 'title' => $entry['title'],
8403 + );
8404 + }
8405 + }
8406 + }
8407 +
8408 + if ( empty( $nav_menu ) ) {
8409 + return 0;
8410 + }
8411 +
8412 + // Deliberately untranslated: this string is the menu's identity across re-imports,
8413 + // so it must not change with the admin locale.
8414 + $menu_name = 'Primary Menu';
8415 + $menu_obj = wp_get_nav_menu_object( $menu_name );
8416 + $menu_id = $menu_obj ? (int) $menu_obj->term_id : wp_create_nav_menu( $menu_name );
8417 +
8418 + if ( is_wp_error( $menu_id ) || empty( $menu_id ) ) {
8419 + return 0;
8420 + }
8421 +
8422 + $existing = wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'any' ) );
8423 + $existing = is_array( $existing ) ? $existing : array();
8424 +
8425 + $already = array();
8426 + foreach ( $existing as $menu_item ) {
8427 + if ( ! empty( $menu_item->object_id ) ) {
8428 + $already[ (int) $menu_item->object_id ] = true;
8429 + }
8430 + }
8431 +
8432 + $position = count( $existing );
8433 +
8434 + foreach ( $nav_menu as $nav_item ) {
8435 + $post_id = 0;
8436 +
8437 + if ( ! empty( $nav_item['post_id'] ) ) {
8438 + $post_id = (int) $nav_item['post_id'];
8439 + } elseif ( ! empty( $nav_item['template_id'] ) && isset( $template_map[ (string) $nav_item['template_id'] ] ) ) {
8440 + $post_id = (int) $template_map[ (string) $nav_item['template_id'] ];
8441 + }
8442 +
8443 + if ( ! $post_id || isset( $already[ $post_id ] ) ) {
8444 + continue;
8445 + }
8446 +
8447 + ++$position;
8448 +
8449 + wp_update_nav_menu_item(
8450 + $menu_id,
8451 + 0,
8452 + array(
8453 + 'menu-item-title' => ! empty( $nav_item['title'] )
8454 + ? sanitize_text_field( (string) $nav_item['title'] )
8455 + : get_the_title( $post_id ),
8456 + 'menu-item-object-id' => $post_id,
8457 + 'menu-item-object' => get_post_type( $post_id ),
8458 + 'menu-item-type' => 'post_type',
8459 + 'menu-item-status' => 'publish',
8460 + 'menu-item-position' => $position,
8461 + )
8462 + );
8463 +
8464 + $already[ $post_id ] = true;
8465 + }
8466 +
8467 + // Only ever fill a location the active theme actually registers and the site has
8468 + // left empty. The old hardcoded primary/main-menu/header-menu write clobbered live
8469 + // menu assignments on any site that already had them.
8470 + $registered = function_exists( 'get_registered_nav_menus' ) ? get_registered_nav_menus() : array();
8471 + $locations = get_theme_mod( 'nav_menu_locations', array() );
8472 + $locations = is_array( $locations ) ? $locations : array();
8473 + $changed = false;
8474 +
8475 + foreach ( array_keys( $registered ) as $location ) {
8476 + if ( empty( $locations[ $location ] ) ) {
8477 + $locations[ $location ] = $menu_id;
8478 + $changed = true;
8479 + }
8480 + }
8481 +
8482 + if ( $changed ) {
8483 + set_theme_mod( 'nav_menu_locations', $locations );
8484 + }
8485 +
8486 + return (int) $menu_id;
8487 + }
8488 +
2998 8489 public function wdkit_enable_template_widgets() {
2999 8490 $widget_list = ! empty( $_POST['widget_list'] ) ? json_decode( wp_unslash( $_POST['widget_list'] ), true ) : array();
3000 8491 $extensions_list = ! empty( $_POST['extensions_list'] ) ? json_decode( wp_unslash( $_POST['extensions_list'] ), true ) : array();
3001 8492
8493 + $res = $this->wdkit_enable_widgets_data( $widget_list, $extensions_list );
8494 +
8495 + wp_send_json( $res );
8496 + wp_die();
8497 + }
8498 +
8499 + /**
8500 + * Enable the widgets and extensions a kit's content actually uses.
8501 + *
8502 + * Extracted from wdkit_enable_template_widgets() so the PHP import runner can reach it
8503 + * without `$_POST`. The lists are derived from imported kit content by the caller — they
8504 + * are never accepted from a remote payload.
8505 + *
8506 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
8507 + *
8508 + * @since 2.6.5
8509 + *
8510 + * @param array $widget_list Widget names used by the kit.
8511 + * @param array $extensions_list Extension names used by the kit.
8512 + * @return array Response array, exactly as the AJAX action used to emit.
8513 + */
8514 + public function wdkit_enable_widgets_data( $widget_list = array(), $extensions_list = array() ) {
8515 + $widget_list = is_array( $widget_list ) ? $widget_list : array();
8516 + $extensions_list = is_array( $extensions_list ) ? $extensions_list : array();
8517 +
3002 8518 if ( empty( $widget_list ) && empty( $extensions_list ) ) {
3003 - $res = array(
8519 + return array(
3004 8520 'massage' => __( 'Widget array not found', 'wdesignkit' ),
3005 8521 'description' => __( 'Widget array not found', 'wdesignkit' ),
3006 8522 'success' => false,
3007 8523 );
3008 - wp_send_json( $res );
3009 - wp_die();
3010 8524 }
3011 8525
8526 + // The widget list is what the caller needs verified; the extensions ride along but
8527 + // have no registry to check against.
8528 + $requested = is_array( $widget_list ) ? $widget_list : array();
8529 +
8530 + // What the addon's enabler is given: widget types renamed to the key it knows them by.
8531 + // $requested keeps the real types, which is what verification and Elementor use.
8532 + $widget_list = array_values( array_unique( array_map( array( __CLASS__, 'wdkit_widget_enable_name' ), $widget_list ) ) );
8533 +
3012 8534 if ( ! has_filter( 'tpae_enable_selected_widgets' ) ) {
3013 - $res = array(
3014 - 'massage' => __( 'Relevant Plugin not Activated', 'wdesignkit' ),
3015 - 'description' => __( 'Relevant Plugin not Installed / Activated', 'wdesignkit' ),
3016 - 'success' => false,
8535 + if ( class_exists( 'Tpae_Hooks' ) && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
8536 + Tpae_Hooks::get_instance();
8537 + } elseif ( defined( 'L_THEPLUS_PATH' ) && file_exists( L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8538 + require_once L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8539 + } elseif ( defined( 'THEPLUS_PATH' ) && file_exists( THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8540 + require_once THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8541 + }
8542 + }
8543 +
8544 + // Ensure theplus_options exists so tpae_enable_selected_widgets does not bail silently
8545 + // on a fresh install where the option row has not been created yet.
8546 + $theplus_options = get_option( 'theplus_options', false );
8547 + if ( empty( $theplus_options ) || ! is_array( $theplus_options ) ) {
8548 + $theplus_options = array(
8549 + 'check_elements' => array(),
8550 + 'extras_elements' => array(),
3017 8551 );
3018 - wp_send_json( $res );
3019 - wp_die();
8552 + update_option( 'theplus_options', $theplus_options );
3020 8553 }
3021 8554
3022 - $w_list = array(
3023 - 'widgets' => $widget_list,
3024 - 'extensions' => $extensions_list,
3025 - );
8555 + if ( has_filter( 'tpae_enable_selected_widgets' ) ) {
8556 + $w_list = array(
8557 + 'widgets' => $widget_list,
8558 + 'extensions' => $extensions_list,
8559 + );
3026 8560
3027 - $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
8561 + $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
8562 + } else {
8563 + // Fallback: If filter is not loaded in this process (e.g. right after activation or CLI),
8564 + // directly merge into theplus_options so subsequent requests and Elementor boot find them.
8565 + $to_add = array();
8566 + foreach ( $widget_list as $w ) {
8567 + $to_add[] = str_replace( '-', '_', $w );
8568 + }
8569 + $ext_to_add = array();
8570 + foreach ( $extensions_list as $e ) {
8571 + $ext_to_add[] = str_replace( '-', '_', $e );
8572 + }
3028 8573
8574 + $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 ) ) );
8575 + $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 ) ) );
8576 + update_option( 'theplus_options', $theplus_options );
8577 +
8578 + $result = array( 'success' => true );
8579 + }
8580 +
8581 + // Register newly enabled widgets into Elementor\'s in-memory manager so Document::save()
8582 + // in the current PHP execution does not silently drop them.
8583 + if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
8584 + try {
8585 + $widgets_manager = \Elementor\Plugin::$instance->widgets_manager;
8586 + if ( class_exists( '\TheplusAddons\L_Theplus_Widgets_Include' ) && method_exists( '\TheplusAddons\L_Theplus_Widgets_Include', 'get_instance' ) ) {
8587 + \TheplusAddons\L_Theplus_Widgets_Include::get_instance()->add_widgets( $widgets_manager );
8588 + }
8589 + // The Pro add-on loads each widget file with a plain `require` (the Lite one uses
8590 + // require_once), so Pro's add_widgets() must run exactly once per request: a second
8591 + // pass redeclares the widget classes, a fatal ("Cannot declare class ...") that
8592 + // try/catch cannot intercept and that aborts the import at "Installing Plugins & Theme".
8593 + // Calling it by hand is also what trips it the first time: register() makes Elementor
8594 + // lazily run init_widgets(), which fires `elementor/widgets/register`, and Pro's own
8595 + // hook then loads the same files again. So let Elementor initialise the manager itself
8596 + // (Pro registers through that hook, against the options saved above) and never call
8597 + // Pro's add_widgets() directly.
8598 + if ( ! did_action( 'elementor/widgets/register' ) && method_exists( $widgets_manager, 'get_widget_types' ) ) {
8599 + $widgets_manager->get_widget_types();
8600 + }
8601 + } catch ( \Throwable $e ) {
8602 + // Best-effort in-memory registration.
8603 + }
8604 + }
8605 +
8606 + // What the caller actually needs to know. A 200 with success:true only says the filter
8607 + // ran — tpae_enable_selected_widgets() reports success even when it wrote nothing
8608 + // (it bails silently if the addon's own settings row is still empty). So answer from
8609 + // the state it was supposed to write.
8610 + $not_enabled = self::wdkit_not_enabled_widget_types( $requested );
8611 +
3029 8612 if ( ! empty( $result['success'] ) ) {
3030 8613 $res = array(
3031 8614 'massage' => __( 'Enabled widgets successfully', 'wdesignkit' ),
3032 8615 'description' => __( 'Used widgets have been enabled successfully', 'wdesignkit' ),
@@ -3043,10 +8626,14 @@
3043 8626 'success' => false,
3044 8627 );
3045 8628 }
3046 8629
3047 - wp_send_json( $res );
3048 - wp_die();
8630 + // Authoritative result of the enable, and the only field the caller should retry on.
8631 + // Both callers need it: the browser importer retries on it, and the runner's
8632 + // enable_kit_widgets() decides from it whether the content stage may start.
8633 + $res['not_enabled'] = $not_enabled;
8634 +
8635 + return $res;
3049 8636 }
3050 8637
3051 8638 /**
3052 8639 * Import single template and section from plugin only
@@ -3096,8 +8683,12 @@
3096 8683 'editor' => $args['editor'],
3097 8684 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
3098 8685 );
3099 8686
8687 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
8688 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
8689 + }
8690 +
3100 8691 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3101 8692
3102 8693 if ( is_wp_error( $response ) ) {
3103 8694 wp_send_json( array(
@@ -3176,8 +8767,19 @@
3176 8767 }
3177 8768
3178 8769 $document = \Elementor\Plugin::$instance->documents->get($template_id);
3179 8770
8771 + // This saves content posted straight from the browser, which carries local image
8772 + // URLs but still the source template's attachment IDs. Without repairing them the
8773 + // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
8774 + // container background images especially — resolve to nothing and render empty.
8775 + $content = self::wdkit_repair_attachment_ids( $content );
8776 +
8777 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $content ) );
8778 + if ( ! empty( $unregistered ) ) {
8779 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8780 + }
8781 +
3180 8782 $document->save([
3181 8783 'elements' => $content
3182 8784 ]);
3183 8785 }
@@ -3182,8 +8784,113 @@
3182 8784 ]);
3183 8785 }
3184 8786
3185 8787 /**
8788 + * Update the content of an already-created page.
8789 + *
8790 + * Used by the async ("Site Ready first") import path: pages are created up front with
8791 + * their un-rewritten template content, then this writes the AI-rewritten content into
8792 + * each page in the background. Elementor saves via the document API (same as
8793 + * wkit_update_elementor_template); Gutenberg writes post_content directly.
8794 + *
8795 + * @since 2.6.2
8796 + */
8797 + protected function wdkit_update_page_content() {
8798 + $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
8799 + $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
8800 +
8801 + if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
8802 + return array(
8803 + 'success' => false,
8804 + 'message' => esc_html__( 'Invalid page or insufficient permission', 'wdesignkit' ),
8805 + );
8806 + }
8807 +
8808 + if ( 'gutenberg' === $builder ) {
8809 + // Do NOT run kses here: Gutenberg block delimiters are HTML comments
8810 + // (<!-- wp:... -->) which kses strips. Mirror the create path, which stores
8811 + // the block markup slashed and unfiltered (endpoint is manage_options-gated
8812 + // and the content is plugin-generated).
8813 + $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
8814 +
8815 + // This content comes straight from the browser and still carries the template
8816 + // site's media URLs and attachment IDs, so it has to go through the same pipeline
8817 + // the create path uses. Without this the save simply undid the import: the files
8818 + // were fetched, then overwritten by a copy still pointing at the source site.
8819 + //
8820 + // Re-running is cheap. Every URL already handled resolves from the source-hash
8821 + // lookup, and media that is already local resolves straight from its URL, so no
8822 + // image is fetched or stored twice.
8823 + $content = $this->wdkit_relink_gutenberg_content( $content );
8824 +
8825 + // See wdkit_write_post_content(): this endpoint replaces the whole post_content
8826 + // with markup the import itself produced, so kses here strips legitimate block
8827 + // markup rather than untrusted input. Kept inline instead of routed through that
8828 + // helper because this call needs wp_slash() and the WP_Error return.
8829 + $had_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
8830 +
8831 + if ( $had_kses ) {
8832 + kses_remove_filters();
8833 + }
8834 +
8835 + $result = wp_update_post(
8836 + array(
8837 + 'ID' => $post_id,
8838 + 'post_content' => wp_slash( $content ),
8839 + ),
8840 + true
8841 + );
8842 +
8843 + if ( $had_kses ) {
8844 + kses_init_filters();
8845 + }
8846 +
8847 + if ( is_wp_error( $result ) ) {
8848 + return array(
8849 + 'success' => false,
8850 + 'message' => $result->get_error_message(),
8851 + );
8852 + }
8853 +
8854 + self::wdkit_rebuild_block_css( $post_id );
8855 + } else {
8856 + $elements = isset( $_POST['content'] ) ? json_decode( wp_unslash( $_POST['content'] ), true ) : array();
8857 +
8858 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
8859 + return array(
8860 + 'success' => false,
8861 + 'message' => esc_html__( 'Elementor not available', 'wdesignkit' ),
8862 + );
8863 + }
8864 +
8865 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
8866 + if ( ! $document ) {
8867 + return array(
8868 + 'success' => false,
8869 + 'message' => esc_html__( 'Elementor document not found', 'wdesignkit' ),
8870 + );
8871 + }
8872 +
8873 + // Same as wkit_update_elementor_template(): browser-posted content keeps the
8874 + // source template's attachment IDs, so repair them or this save undoes the
8875 + // create-time fix and background images stop rendering.
8876 + $elements = self::wdkit_repair_attachment_ids( $elements );
8877 +
8878 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8879 + if ( ! empty( $unregistered ) ) {
8880 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8881 + }
8882 +
8883 + $document->save( array( 'elements' => $elements ) );
8884 + }
8885 +
8886 + return array(
8887 + 'success' => true,
8888 + 'message' => esc_html__( 'Page content updated', 'wdesignkit' ),
8889 + );
8890 + }
8891 +
8892 + /**
3186 8893 * Import single template and section from plugin only
3187 8894 *
3188 8895 * @param array $args store data.
3189 8896 * @param array $template_id store data.
@@ -3191,8 +8898,13 @@
3191 8898 * @param array $temp_data store data.
3192 8899 * */
3193 8900 protected function import_page_section_content() {
3194 8901
8902 + // Elementor sideloads every image referenced by the page from inside this request.
8903 + // A single oversized source image decodes to more than the whole memory limit, so
8904 + // guard before any of that starts.
8905 + $this->wdkit_guard_oversized_images();
8906 +
3195 8907 // Sideloading images for image-heavy pages (wdkit_media_import → Imagick
3196 8908 // thumbnail generation per image) can exceed the default 30s execution
3197 8909 // limit and fatal the request mid-import. Give this single page import
3198 8910 // more headroom; harmless no-op where set_time_limit() is disabled.
@@ -3217,8 +8929,11 @@
3217 8929 }
3218 8930
3219 8931 if ( isset( $_POST['thumb_image'] ) ) {
3220 8932 $thumb_image = ! empty( $_POST['thumb_image'] ) ? esc_url_raw( $_POST['thumb_image'] ) : '';
8933 + // Capped once here covers both download_url( $thumb_image ) calls below (Elementor
8934 + // and Gutenberg branches share this variable).
8935 + $thumb_image = Wdkit_Image_Guard::cap_pexels_source( $thumb_image );
3221 8936 }
3222 8937
3223 8938 if ( isset( $_POST['template_id'] ) ) {
3224 8939 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
@@ -3225,8 +8940,14 @@
3225 8940 }
3226 8941
3227 8942 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3228 8943
8944 + // AI kit imports send this so the page saves with its source CDN image URLs still in
8945 + // place - the JS side then registers it for wkit_schedule_deferred_media_sync() once
8946 + // every page in the kit has been created, and the actual media_sideload_image() work
8947 + // happens later, off this request. See wdkit_media_import()'s $defer_media branch.
8948 + $defer_media = ! empty( $_POST['defer_media'] );
8949 +
3229 8950 if ( isset( $_POST['data'] ) ) {
3230 8951 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3231 8952 }
3232 8953
@@ -3246,9 +8967,27 @@
3246 8967 }
3247 8968
3248 8969 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3249 8970 $post_content = $data;
8971 + // Restore The Plus Addons' globals before the page is built, so the widgets'
8972 + // tp_global_preset references resolve as soon as it renders. Done here rather
8973 + // than in the save-template UI's confirmation dialog so that every import path
8974 + // - the library, the abilities, the theme builder - gets it.
8975 + if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
8976 + $this->wdkit_merge_tp_globals(
8977 + json_decode( wp_json_encode( $post_content->tp_globals ), true ),
8978 + isset( $post_content->tp_global_refs )
8979 + ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
8980 + : array()
8981 + );
8982 + }
8983 +
3250 8984 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8985 +
8986 + // Same title cleanup the bundle path applies, so a kit imported through either
8987 + // route lists identically in Pages.
8988 + $post_title = self::wdkit_clean_page_title( $post_title, $post_type );
8989 +
3251 8990 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3252 8991 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3253 8992 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
3254 8993 $temp_con = '';
@@ -3264,14 +9003,24 @@
3264 9003 wp_die();
3265 9004 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3266 9005
3267 9006 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
9007 +
3268 9008 $blocks = parse_blocks( stripslashes( $content ) );
9009 + $blocks = $this->wdkit_media_import( $blocks, $editor, $defer_media );
3269 9010
3270 - $blocks = $this->wdkit_media_import( $blocks, $editor );
9011 + // wdkit_media_import() above just skipped every raster image in this section's
9012 + // own content (the "Blog Detail" dummy template's own decorative images, not a
9013 + // user-picked stock photo) — this is this method's only chance to learn which
9014 + // URLs those were, so the deferred-media response field below is built from it
9015 + // rather than left to the bundle-only image_urls the client already tracks.
9016 + $deferred_image_urls = $defer_media
9017 + ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
9018 + : array();
3271 9019
3272 9020 $processor = new WDKIT_Nexter_Block_Processor();
3273 9021 $blocks = $processor->run( $blocks );
9022 +
3274 9023 $content = serialize_blocks( $blocks );
3275 9024
3276 9025 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3277 9026
@@ -3284,8 +9033,12 @@
3284 9033 'post_content' => $content,
3285 9034 )
3286 9035 );
3287 9036
9037 + if ( ! is_wp_error( $inserted_post ) && $inserted_post ) {
9038 + self::wdkit_rebuild_block_css( $inserted_post );
9039 + }
9040 +
3288 9041 if ( is_wp_error( $inserted_post ) ) {
3289 9042 wp_send_json(
3290 9043 array(
3291 9044 'import_failed' => $inserted_post->get_error_message(),
@@ -3294,9 +9047,19 @@
3294 9047 );
3295 9048 wp_die();
3296 9049 }
3297 9050
3298 - if ( ! empty( $thumb_image ) ) {
9051 + // A picked stock image is already in the media library by the time the posts
9052 + // are created, so thumb_image is usually a url on this site rather than a
9053 + // remote one. That can never go down the download path - the external-url
9054 + // guard rejects loopback addresses by design, which silently dropped the
9055 + // featured image - so recognise the existing attachment and point at it.
9056 + // Anything genuinely remote still takes the untouched branch below.
9057 + $local_thumb_id = ! empty( $thumb_image ) ? self::wdkit_local_attachment_from_url( $thumb_image ) : 0;
9058 +
9059 + if ( $local_thumb_id ) {
9060 + set_post_thumbnail( $inserted_post, $local_thumb_id );
9061 + } elseif ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3299 9062 // $featured_image_url = esc_url_raw( $thumb_image );
3300 9063 $tmp = download_url( $thumb_image );
3301 9064 if ( is_wp_error( $tmp ) ) {
3302 9065 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3332,9 +9095,9 @@
3332 9095 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3333 9096 if ( ! empty( $custom_meta ) ) {
3334 9097 foreach ( $custom_meta as $meta_key => $meta_val ) {
3335 9098 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3336 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9099 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3337 9100 }
3338 9101
3339 9102 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
3340 9103 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -3366,8 +9129,15 @@
3366 9129 Tpgb_Library()->remove_backend_dir_files();
3367 9130 }
3368 9131
3369 9132 clean_post_cache( $inserted_post );
9133 +
9134 + // This whole method imports exactly one section per call — unlike
9135 + // wdkit_import_template()/wdkit_import_kit_template(), it never fired this hook
9136 + // at all, so single-section imports (Header/Footer/CTA/etc., a primary import
9137 + // path per the Template Type sidebar) were invisible to tracking entirely.
9138 + do_action( 'wdkit_template_imported', 'single', sanitize_key( $editor ), 1 );
9139 +
3370 9140 wp_send_json(
3371 9141 array(
3372 9142 $temp_id => $temp_detail,
3373 9143 'description' => 'Yay! Your Section has been Successfully Imported.',
@@ -3372,8 +9142,12 @@
3372 9142 $temp_id => $temp_detail,
3373 9143 'description' => 'Yay! Your Section has been Successfully Imported.',
3374 9144 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3375 9145 'inserted_id' => $inserted_post,
9146 + // Raster images this call itself deferred - the caller unions this into
9147 + // whatever image_urls its own template-fetch response already carries
9148 + // before scheduling the background sideload pass.
9149 + 'image_urls' => $deferred_image_urls,
3376 9150 'success' => true,
3377 9151 )
3378 9152 );
3379 9153 wp_die();
@@ -3423,9 +9197,9 @@
3423 9197 }
3424 9198
3425 9199 $inserted_id = $new_document->get_main_id();
3426 9200
3427 - if ( ! empty( $thumb_image ) ) {
9201 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3428 9202 // $featured_image_url = esc_url_raw( $thumb_image );
3429 9203 $tmp = download_url( $thumb_image );
3430 9204 if ( is_wp_error( $tmp ) ) {
3431 9205 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3459,10 +9233,20 @@
3459 9233
3460 9234 $settings = ( isset( $post_content->settings ) && ! empty( $post_content->settings ) ) ? json_decode( wp_json_encode( $post_content->settings ), true ) : array();
3461 9235
3462 9236 $content = wp_json_encode( $content );
3463 - $content = $this->wdkit_media_import( $content, $file_type );
9237 + $content = $this->wdkit_media_import( $content, $file_type, $defer_media );
3464 9238
9239 + // Same header-logo guarantee the bundle path applies, so a kit imported
9240 + // through either route ends up with the same clickable logo.
9241 + $content = self::wdkit_link_header_logo( $content, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
9242 +
9243 + $widgets_in = self::wdkit_widget_types_in( $content );
9244 + $unregistered = self::wdkit_unregistered_widget_types( $widgets_in );
9245 + if ( ! empty( $unregistered ) ) {
9246 + $this->wdkit_enable_widgets_data( $unregistered );
9247 + }
9248 +
3465 9249 $new_document->save(
3466 9250 array(
3467 9251 'elements' => $content,
3468 9252 'settings' => ! empty( $settings ) ? $settings : array(),
@@ -3479,9 +9263,9 @@
3479 9263 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3480 9264 if ( ! empty( $custom_meta ) ) {
3481 9265 foreach ( $custom_meta as $meta_key => $meta_val ) {
3482 9266 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3483 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9267 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3484 9268 }
3485 9269 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
3486 9270 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
3487 9271 }
@@ -3505,8 +9289,12 @@
3505 9289 }
3506 9290
3507 9291 \Elementor\Plugin::$instance->files_manager->clear_cache();
3508 9292
9293 + // See the matching note in the Gutenberg branch above — this method never
9294 + // fired the tracking hook for either editor.
9295 + do_action( 'wdkit_template_imported', 'single', 'elementor', 1 );
9296 +
3509 9297 wp_send_json(
3510 9298 array(
3511 9299 $temp_id => $temp_detail,
3512 9300 'content' => $temp_con,
@@ -3512,8 +9300,13 @@
3512 9300 'content' => $temp_con,
3513 9301 'description' => 'Yay! Your Section has been Successfully Imported.',
3514 9302 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3515 9303 'inserted_id' => $inserted_id,
9304 + // Same contract as the Gutenberg branch: the media this call
9305 + // chose to defer, for the caller to schedule.
9306 + 'image_urls' => $defer_media
9307 + ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
9308 + : array(),
3516 9309 'success' => true,
3517 9310 )
3518 9311 );
3519 9312 wp_die();
@@ -3543,9 +9336,14 @@
3543 9336 * Replace unicode glitch
3544 9337 *
3545 9338 * @since 2.0.0
3546 9339 */
3547 - private function replace_unicode_glitch( $content ) {
9340 + /**
9341 + * Widened from private to public in 2.6.5 so the PHP page importer can apply the exact
9342 + * same unicode normalisation the AJAX page import applies. Pure string transform, no
9343 + * state, no capability implications.
9344 + */
9345 + public function replace_unicode_glitch( $content ) {
3548 9346
3549 9347 // Fix escaped unicode like \u003c → <
3550 9348 $content = preg_replace_callback(
3551 9349 '/\\\\u([0-9a-fA-F]{4})/',
@@ -3574,8 +9372,31 @@
3574 9372 */
3575 9373 protected function update_plugin_setting() {
3576 9374 $temp_id = isset( $_POST['plugin_type'] ) ? sanitize_text_field( $_POST['plugin_type'] ) : '';
3577 9375
9376 + $response = $this->wdkit_apply_plugin_settings_data( $temp_id );
9377 +
9378 + wp_send_json( $response );
9379 + wp_die();
9380 + }
9381 +
9382 + /**
9383 + * Apply the plugin-side settings an imported kit needs.
9384 + *
9385 + * Extracted from update_plugin_setting() so the PHP import runner can reach it without
9386 + * `$_POST`. The option list is a closed set written by this method alone — there is no
9387 + * path here for a caller to name an option, which is the point.
9388 + *
9389 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9390 + *
9391 + * @since 2.6.5
9392 + *
9393 + * @param string $temp_id Plugin identifier; only 'elementor' is recognised.
9394 + * @return array Response array, exactly as the AJAX action used to emit.
9395 + */
9396 + public function wdkit_apply_plugin_settings_data( $temp_id ) {
9397 + $temp_id = is_string( $temp_id ) ? $temp_id : '';
9398 +
3578 9399 if ( $temp_id == 'elementor' ) {
3579 9400 $unfiltered_files = get_option( 'elementor_unfiltered_files_upload', false );
3580 9401 $load_fa4 = get_option( 'elementor_load_fa4_shim', false );
3581 9402 $Inline_font_icons = get_option( 'elementor_experiment-e_font_icon_svg', false );
@@ -3617,10 +9438,9 @@
3617 9438 'success' => false,
3618 9439 );
3619 9440 }
3620 9441
3621 - wp_send_json( $response );
3622 - wp_die();
9442 + return $response;
3623 9443 }
3624 9444
3625 9445 /**
3626 9446 * generate different color logo
@@ -3642,8 +9462,12 @@
3642 9462 }
3643 9463
3644 9464 $image_url = esc_url_raw( $_POST['image_url'] );
3645 9465
9466 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
9467 + wp_send_json_error( 'Image could not be downloaded.' );
9468 + }
9469 +
3646 9470 $tmp_file = download_url( $image_url );
3647 9471 if ( is_wp_error( $tmp_file ) ) {
3648 9472 wp_send_json_error( 'Image could not be downloaded.' );
3649 9473 }
@@ -3679,9 +9503,21 @@
3679 9503
3680 9504 $upload_dir = wp_upload_dir();
3681 9505 $result_urls = array();
3682 9506
9507 + $colour_index = 0;
3683 9508 foreach ( $img_colors as $name => $rgb ) {
9509 + // $name is a key from the posted colours payload and went straight into the output
9510 + // filename, so traversal sequences in it steered imagepng() outside the upload
9511 + // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
9512 + // path segment; a key made only of dots/separators sanitizes to empty, so fall back
9513 + // to a positional index rather than writing to a bare "colored--<time>.png".
9514 + ++$colour_index;
9515 + $safe_name = sanitize_file_name( (string) $name );
9516 + if ( '' === $safe_name ) {
9517 + $safe_name = 'colour-' . $colour_index;
9518 + }
9519 +
3684 9520 $new = imagecreatetruecolor( $width, $height );
3685 9521 imagesavealpha( $new, true );
3686 9522 imagealphablending( $new, false );
3687 9523
@@ -3707,9 +9543,9 @@
3707 9543 imagesetpixel( $new, $x, $y, $color );
3708 9544 }
3709 9545 }
3710 9546
3711 - $filename = 'colored-' . $name . '-' . time() . '.png';
9547 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
3712 9548 $filepath = $upload_dir['path'] . '/' . $filename;
3713 9549
3714 9550 imagepng( $new, $filepath );
3715 9551 imagedestroy( $new );
@@ -3740,8 +9576,29 @@
3740 9576 *
3741 9577 * @since 2.0.0
3742 9578 */
3743 9579 protected function update_theme_setting() {
9580 + $response = $this->wdkit_apply_theme_settings_data();
9581 +
9582 + wp_send_json( $response );
9583 + wp_die();
9584 + }
9585 +
9586 + /**
9587 + * Apply the Nexter theme container settings an imported kit expects.
9588 + *
9589 + * Extracted from update_theme_setting() so the PHP import runner can reach it without
9590 + * `$_POST`. Note that this method takes no input at all and never has: it writes a
9591 + * fixed set of `nxt-theme-options` sub-keys, which is why it is inherently idempotent
9592 + * and why there is nothing here a caller could steer.
9593 + *
9594 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9595 + *
9596 + * @since 2.6.5
9597 + *
9598 + * @return array Response array, exactly as the AJAX action used to emit.
9599 + */
9600 + public function wdkit_apply_theme_settings_data() {
3744 9601 $theme_db = get_option( 'nxt-theme-options', false );
3745 9602
3746 9603 $container_type = 'container-fluid';
3747 9604 $fluid_spacing = array(
@@ -3862,10 +9719,9 @@
3862 9719 'description' => esc_html__( 'Theme Setting updated', 'wdesignkit' ),
3863 9720 'success' => true,
3864 9721 );
3865 9722
3866 - wp_send_json( $response );
3867 - wp_die();
9723 + return $response;
3868 9724 }
3869 9725
3870 9726 /**
3871 9727 * change site setting for import kit
@@ -3872,8 +9728,22 @@
3872 9728 *
3873 9729 * @since 2.0.0
3874 9730 */
3875 9731 protected function update_site_setting() {
9732 +
9733 + // This is the import's "Finalizing Settings" request, and it is not the cheap
9734 + // bookkeeping call its name suggests: it generates the site logo/icon and then runs
9735 + // wdkit_sweep_attachment_ids() across every page that was just created, which resolves
9736 + // attachment ids and can pull WordPress into Imagick subsize generation. Every other
9737 + // heavy handler here already extends the limit (see the bundle import, the page-section
9738 + // import, the deferred-media cron); this one did not, so on a kit with a large image it
9739 + // fataled at PHP's default 30s. The client has no recovery path for that - the step
9740 + // simply stays on "Finalizing Settings" and polls forever, which is exactly the hang
9741 + // reported against Elementor kits. Harmless no-op where set_time_limit() is disabled.
9742 + if ( function_exists( 'set_time_limit' ) ) {
9743 + @set_time_limit( 300 );
9744 + }
9745 +
3876 9746 $temp_id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
3877 9747 $shop_id = isset( $_POST['shop_id'] ) ? sanitize_text_field( wp_unslash( $_POST['shop_id'] ) ) : '';
3878 9748 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
3879 9749 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
@@ -3878,14 +9748,95 @@
3878 9748 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
3879 9749 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
3880 9750 $site_tagline = isset( $_POST['site_tagline'] ) ? sanitize_text_field( wp_unslash( $_POST['site_tagline'] ) ) : '';
3881 9751
3882 - $this->wdkit_nxt_thembuilder_update();
9752 + $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9753 + $page_information = json_decode( $page_information, true );
3883 9754
3884 - if ( ! empty( $shop_id ) ) {
3885 - update_option( 'woocommerce_shop_page_id', $shop_id );
9755 + $response = $this->wdkit_apply_site_settings_data(
9756 + array(
9757 + 'id' => $temp_id,
9758 + 'shop_id' => $shop_id,
9759 + 'temp_type' => $temp_type,
9760 + 'site_name' => $site_name,
9761 + 'site_tagline' => $site_tagline,
9762 + 'page_information' => $page_information,
9763 + )
9764 + );
9765 +
9766 + wp_send_json( $response );
9767 + wp_die();
9768 + }
9769 +
9770 + /**
9771 + * Keep an imported Shop page from being the WooCommerce shop page.
9772 + *
9773 + * WooCommerce loads whichever page is set as the shop page as a PRODUCT ARCHIVE, and on an
9774 + * archive Elementor does not render that page's design: the page's own document is absent
9775 + * from the output and only its raw text shows, unstyled. The kit's Shop page is built as an
9776 + * ordinary Elementor page (filters, product grid, breadcrumb), so it is left as one, and
9777 + * is reached by the kit's own links.
9778 + *
9779 + * An earlier import (or an earlier version of this importer) may already have pointed the
9780 + * option at a Shop page of ours. That page is drafted by the next reset, so the stale id is
9781 + * cleared too. A shop page the site owner chose, which is published and not ours, is left
9782 + * exactly as it is.
9783 + *
9784 + * @return void
9785 + */
9786 + private function wdkit_release_imported_shop_page() {
9787 + $current = (int) get_option( 'woocommerce_shop_page_id' );
9788 +
9789 + if ( $current <= 0 ) {
9790 + return;
3886 9791 }
3887 9792
9793 + $page = get_post( $current );
9794 + $ours = $page && class_exists( 'Wdkit_Import_Session' )
9795 + && get_post_meta( $current, Wdkit_Import_Session::POST_OWNER_META, true );
9796 +
9797 + if ( ! $page || 'publish' !== $page->post_status || $ours ) {
9798 + update_option( 'woocommerce_shop_page_id', 0 );
9799 + }
9800 + }
9801 +
9802 + /**
9803 + * Apply a kit's site settings: front page, shop page, name and tagline.
9804 + *
9805 + * Extracted from update_site_setting() so the PHP import runner can reach it without
9806 + * `$_POST`. The set of options written is closed and hard-coded here —
9807 + * `show_on_front`, `page_on_front`, `blogname`, `blogdescription`, plus clearing a
9808 + * `woocommerce_shop_page_id` that points at an imported page (never setting it). There is deliberately no way for a caller to name an option.
9809 + *
9810 + * Theme-builder condition rewriting still runs through
9811 + * wdkit_nxt_thembuilder_update(), which now takes its page map as an argument.
9812 + *
9813 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9814 + *
9815 + * @since 2.6.5
9816 + *
9817 + * @param array $args {id, shop_id, temp_type, site_name, site_tagline, page_information,
9818 + * defer_attachment_downloads}.
9819 + * @return array Response array, exactly as the AJAX action used to emit.
9820 + */
9821 + public function wdkit_apply_site_settings_data( $args ) {
9822 + $args = is_array( $args ) ? $args : array();
9823 +
9824 + $temp_id = isset( $args['id'] ) ? sanitize_text_field( (string) $args['id'] ) : '';
9825 + $shop_id = isset( $args['shop_id'] ) ? sanitize_text_field( (string) $args['shop_id'] ) : '';
9826 + $temp_type = isset( $args['temp_type'] ) ? sanitize_text_field( (string) $args['temp_type'] ) : 'page';
9827 + $site_name = isset( $args['site_name'] ) ? sanitize_text_field( (string) $args['site_name'] ) : '';
9828 + $site_tagline = isset( $args['site_tagline'] ) ? sanitize_text_field( (string) $args['site_tagline'] ) : '';
9829 +
9830 + $this->wdkit_nxt_thembuilder_update(
9831 + isset( $args['page_information'] ) ? $args['page_information'] : null,
9832 + ! empty( $args['defer_attachment_downloads'] )
9833 + );
9834 +
9835 + // $shop_id is accepted for compatibility but no longer applied; see
9836 + // wdkit_release_imported_shop_page().
9837 + $this->wdkit_release_imported_shop_page();
9838 +
3888 9839 if ( $temp_id ) {
3889 9840 update_option( 'show_on_front', $temp_type );
3890 9841 update_option( 'page_on_front', $temp_id );
3891 9842
@@ -3894,8 +9845,28 @@
3894 9845 }
3895 9846
3896 9847 if ( ! empty( $site_tagline ) ) {
3897 9848 update_option( 'blogdescription', $site_tagline );
9849 +
9850 + /* Remembered so a LATER import can tell a tagline this importer wrote from
9851 + * one the site's owner wrote. Only the former is ours to clear. */
9852 + update_option( 'wdkit_applied_tagline', $site_tagline );
9853 + } else {
9854 + /* Skipped the tagline question - which is not the same as "leave whatever is
9855 + * there". Importing a second kit onto a site that already carries the FIRST
9856 + * import's tagline left the new business sitting under the old one's slogan
9857 + * (a SaaS kit headed "Golden Crust Melts Inside"), because nothing ever
9858 + * cleared it.
9859 + *
9860 + * Cleared only when the current tagline is character-for-character the one a
9861 + * previous import wrote. A tagline the site's own owner typed is left
9862 + * completely alone - a kit import has no business wiping it. */
9863 + $applied = (string) get_option( 'wdkit_applied_tagline', '' );
9864 +
9865 + if ( '' !== $applied && $applied === (string) get_option( 'blogdescription', '' ) ) {
9866 + update_option( 'blogdescription', '' );
9867 + delete_option( 'wdkit_applied_tagline' );
9868 + }
3898 9869 }
3899 9870
3900 9871 $response = array(
3901 9872 'message' => esc_html__( 'Site link updated', 'wdesignkit' ),
@@ -3910,10 +9881,9 @@
3910 9881 'success' => false,
3911 9882 );
3912 9883 }
3913 9884
3914 - wp_send_json( $response );
3915 - wp_die();
9885 + return $response;
3916 9886 }
3917 9887
3918 9888 /**
3919 9889 * update theme builder
@@ -3918,15 +9888,41 @@
3918 9888 /**
3919 9889 * update theme builder
3920 9890 *
3921 9891 * @since 2.0.4
9892 + *
9893 + * @param array|null $page_information Page map; falls back to $_POST when null.
9894 + * @param bool $local_only Passed straight through to
9895 + * wdkit_sweep_attachment_ids() — see its docblock.
3922 9896 */
3923 - public function wdkit_nxt_thembuilder_update() {
9897 + public function wdkit_nxt_thembuilder_update( $page_information = null, $local_only = false ) {
3924 9898
3925 - $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
3926 - $page_information = json_decode( $page_information, true );
9899 + /* Falls back to `$_POST` when called with no argument, so every existing caller —
9900 + * and the AJAX path — behaves exactly as before. The PHP runner passes the map in. */
9901 + if ( null === $page_information ) {
9902 + $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9903 + $page_information = json_decode( $page_information, true );
9904 + }
3927 9905
9906 + $t_finalize = microtime( true );
9907 +
3928 9908 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
9909 +
9910 + // Every page and attachment now exists, so resolve any image ID the per-page
9911 + // pass could not (siblings import concurrently and share icons).
9912 + $t_sweep = microtime( true );
9913 + $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ), $local_only );
9914 +
9915 + // This walks every page's content looking for unresolved attachment ids, so it
9916 + // scales with kit size and was not covered by the per-template numbers.
9917 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
9918 + error_log( sprintf(
9919 + 'WDKIT finalize: sweep_attachment_ids %dms over %d pages',
9920 + (int) round( ( microtime( true ) - $t_sweep ) * 1000 ),
9921 + count( $page_information )
9922 + ) );
9923 + }
9924 +
3929 9925 // Step 1: banavo mapping [ old_id => new_id ]
3930 9926 $id_mapping = array();
3931 9927 foreach ( $page_information as $page_info ) {
3932 9928 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -3999,32 +9995,136 @@
3999 9995 *
4000 9996 * @since 2.2.2
4001 9997 */
4002 9998 public function wdkit_check_user_credit() {
4003 - $array_data = array(
4004 - 'token' => isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '',
4005 - );
9999 + $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
4006 10000
10001 + /* The caller sends the token from its own localStorage, which is per browser. The
10002 + * SITE's cloud session is not - it lives in a transient written at login and is what
10003 + * every import actually authenticates with. Reading only the browser copy meant a
10004 + * connected site told a second browser, an incognito window or another WP admin that
10005 + * it needed to log in, while an import from that same site would have worked
10006 + * (ClickUp 14ynqxywrg4). */
10007 + if ( '' === $token && function_exists( 'wdkit_kit_import_resolve_token' ) ) {
10008 + $token = (string) wdkit_kit_import_resolve_token();
10009 + }
10010 +
10011 + if ( '' === $token ) {
10012 + wp_send_json(
10013 + array(
10014 + 'success' => false,
10015 + 'reason' => 'no_session',
10016 + 'logged_in' => false,
10017 + 'message' => esc_html__( 'Not signed into WDesignKit on this site.', 'wdesignkit' ),
10018 + )
10019 + );
10020 + wp_die();
10021 + }
10022 +
10023 + $array_data = array( 'token' => $token );
10024 +
4007 10025 $response = $this->wkit_api_call( $array_data, 'ai/credits/get' );
4008 10026 $success = ! empty( $response['success'] ) ? $response['success'] : false;
4009 10027
4010 10028 if ( empty( $success ) ) {
4011 - $response = array(
4012 - 'success' => false,
4013 - 'message' => esc_html__( 'Data Not Found', 'wdesignkit' ),
4014 - 'description' => esc_html__( 'Data not found', 'wdesignkit' ),
10029 + /* Why it failed, not just that it did. The caller used to receive one flat
10030 + * "Data Not Found" for every failure and read it as "no credits left", so a
10031 + * cloud request that merely timed out was reported to the user as "your site
10032 + * limit for today has been reached" - a limit that had not been reached. A
10033 + * transport failure says so through wkit_api_call()'s own message. */
10034 + $massage = isset( $response['massage'] ) ? (string) $response['massage'] : '';
10035 + $unreachable = ( '' !== $massage && false !== stripos( $massage, 'API request error' ) );
10036 +
10037 + wp_send_json(
10038 + array(
10039 + 'success' => false,
10040 + 'reason' => $unreachable ? 'unreachable' : 'rejected',
10041 +
10042 + /* A token existed and was sent, so whatever went wrong here is not the
10043 + * user being signed out. */
10044 + 'logged_in' => true,
10045 + 'message' => $unreachable
10046 + ? esc_html__( 'Could not reach WDesignKit.', 'wdesignkit' )
10047 + : esc_html__( 'Data Not Found', 'wdesignkit' ),
10048 + 'description' => $massage,
10049 + )
4015 10050 );
4016 -
4017 - wp_send_json( $response );
4018 10051 wp_die();
4019 10052 }
4020 10053
4021 10054 $response = json_decode( wp_json_encode( $response['data'] ), true );
4022 10055
10056 + if ( is_array( $response ) ) {
10057 + /* The cloud answered for this token, so the site is signed in - said plainly so
10058 + * the caller does not have to infer it from its own localStorage. */
10059 + $response['logged_in'] = true;
10060 + }
10061 +
10062 + $this->wdkit_cache_cloud_usage( $response );
10063 +
4023 10064 wp_send_json( $response );
4024 10065 wp_die();
4025 10066 }
4026 10067
10068 + /**
10069 + * Caches the storage / credit figures this response carried.
10070 + *
10071 + * This handler is the ONLY place those numbers ever exist on the site: the cloud endpoint
10072 + * authenticates with a user token that only a logged-in dashboard request carries, so the
10073 + * analytics heartbeat — which runs on cron with no user at all — can never fetch them itself.
10074 + * Caching them here is what lets Posimyth_Tracker_WDK report them, and it reports the cache's
10075 + * age alongside so a stale reading is recognisable as one.
10076 + *
10077 + * Field names are probed rather than assumed: the cloud has renamed these before, and the
10078 + * licence ability already carries six spellings of its own key field for the same reason. An
10079 + * unrecognised shape simply caches nothing rather than storing a wrong number.
10080 + *
10081 + * Only the figures are kept. No token, no account id, no email — the analytics consent copy
10082 + * promises non-sensitive data only, and this is read by the payload builder.
10083 + *
10084 + * @since 2.6.4
10085 + *
10086 + * @param mixed $data Decoded `data` object from the credits endpoint.
10087 + * @return void
10088 + */
10089 + private function wdkit_cache_cloud_usage( $data ) {
10090 + if ( ! is_array( $data ) ) {
10091 + return;
10092 + }
10093 +
10094 + $pick = static function ( $source, array $fields ) {
10095 + foreach ( $fields as $field ) {
10096 + if ( isset( $source[ $field ] ) && is_numeric( $source[ $field ] ) ) {
10097 + return (float) $source[ $field ];
10098 + }
10099 + }
10100 + return null;
10101 + };
10102 +
10103 + $usage = array(
10104 + 'storage_used' => $pick( $data, array( 'used_storage', 'storage_used', 'used_space' ) ),
10105 + 'storage_total' => $pick( $data, array( 'total_storage', 'storage_total', 'storage', 'total_space' ) ),
10106 + 'credit_used' => $pick( $data, array( 'used_credit', 'credit_used', 'used_credits' ) ),
10107 + 'credit_total' => $pick( $data, array( 'total_credit', 'credit_total', 'credits', 'real_credit' ) ),
10108 + );
10109 +
10110 + $usage = array_filter(
10111 + $usage,
10112 + static function ( $value ) {
10113 + return null !== $value;
10114 + }
10115 + );
10116 +
10117 + if ( empty( $usage ) ) {
10118 + return;
10119 + }
10120 +
10121 + $usage['cached_at'] = gmdate( 'Y-m-d H:i:s' );
10122 +
10123 + // Not autoloaded: read once a week by the heartbeat, never on a front-end request.
10124 + update_option( 'wdkit_cloud_usage', $usage, false );
10125 + }
10126 +
4027 10127 public function wdkit_nxt_thembuilder_reset() {
4028 10128 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
4029 10129 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
4030 10130
@@ -4357,10 +10457,35 @@
4357 10457 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
4358 10458 );
4359 10459 }
4360 10460
4361 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
10461 + // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
10462 + // three segments arrive from $_POST with only wp_unslash() applied — which strips
10463 + // nothing path-relevant — so "../" in any of them walked out of the builder directory
10464 + // and this handler returned the decoded contents of any .json file the web server user
10465 + // could read (CWE-22, ClickUp 86d41zaun).
10466 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
4362 10467
10468 + if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
10469 + return array(
10470 + 'success' => false,
10471 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10472 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10473 + );
10474 + }
10475 +
10476 + $json_path = $safe_path['base'];
10477 +
10478 + // Re-check the resolved file: the component guard above cannot see a symlink. Returns
10479 + // false for a path that does not exist, which is the same answer we want anyway.
10480 + if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
10481 + return array(
10482 + 'success' => false,
10483 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10484 + 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
10485 + );
10486 + }
10487 +
4363 10488 $json_data = wp_json_file_decode( "$json_path.json" );
4364 10489 if ( ! empty( $json_data ) ) {
4365 10490 $result = (object) array(
4366 10491 'success' => true,
@@ -4386,9 +10511,9 @@
4386 10511 *
4387 10512 * @since 1.0.0
4388 10513 */
4389 10514 protected function wdkit_download_widget() {
4390 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10515 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4391 10516 $data = json_decode( stripslashes( $data ) );
4392 10517
4393 10518 $array_data = array(
4394 10519 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4456,14 +10581,34 @@
4456 10581 if ( ! is_array( $json_data ) ) {
4457 10582 $json_data = json_decode( $json_data, true );
4458 10583 }
4459 10584
4460 - $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['name'] ) : '';
4461 - $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['type'] ) : '';
4462 - $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
10585 + // Sanitize as filenames before use in the widget path (CWE-22): sanitize_file_name()
10586 + // on name/id and sanitize_key() + allowlist on the builder strip path separators and
10587 + // dots so a crafted cloud response cannot escape WDKIT_BUILDER_PATH.
10588 + $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['name'] ) : '';
10589 + $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_key( $json_data['widget_data']['widgetdata']['type'] ) : '';
10590 + $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
4463 10591
4464 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4465 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
10592 + $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
10593 + if ( '' === $title || '' === $w_uniq || ! in_array( $builder, $allowed_builders, true ) ) {
10594 + $responce = (object) array(
10595 + 'success' => false,
10596 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
10597 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10598 + );
10599 +
10600 + wp_send_json( $responce );
10601 + wp_die();
10602 + }
10603 +
10604 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
10605 + // already sanitized, which collapsed spaces to hyphens and left the underscore pass
10606 + // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
10607 + // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
10608 + // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
10609 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10610 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4466 10611 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
4467 10612
4468 10613 if ( ! is_dir( $builder_type_path ) ) {
4469 10614 wp_mkdir_p( $builder_type_path );
@@ -4473,15 +10618,27 @@
4473 10618 wp_mkdir_p( $builder_type_path . $folder_name );
4474 10619 }
4475 10620
4476 10621 if ( ! empty( $img_url ) ) {
4477 - $img_body = wp_remote_get( $img_url );
4478 - $img_ext = pathinfo( $img_url )['extension'];
10622 + // SSRF guard (CWE-918): validate the resolved host before fetching.
10623 + $img_body = wdesignkit_safe_remote_get( $img_url );
10624 + if ( ! is_wp_error( $img_body ) ) {
10625 + // The remote extension was written verbatim here, so a cloud response naming a
10626 + // ".php" image put executable PHP in the builder directory (CWE-434,
10627 + // ClickUp 86d41cczd). An empty return means the bytes are not an image.
10628 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
4479 10629
4480 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
4481 - $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10630 + if ( '' !== $img_ext ) {
10631 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
10632 + $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10633 + }
10634 + }
4482 10635 }
4483 10636
10637 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
10638 + wdesignkit_invalidate_widget_registry( $builder );
10639 + }
10640 +
4484 10641 // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
4485 10642 $result = (object) array(
4486 10643 'success' => true,
4487 10644 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
@@ -4499,9 +10656,9 @@
4499 10656 *
4500 10657 * @since 1.0.0
4501 10658 */
4502 10659 protected function wdkit_add_widget() {
4503 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10660 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4504 10661 $data = base64_decode( $data );
4505 10662 $data = json_decode( $data );
4506 10663
4507 10664 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -4510,9 +10667,11 @@
4510 10667 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
4511 10668
4512 10669 if ( ! empty( $w_image ) ) {
4513 10670 $w_image = str_replace( '\\', '', $w_image );
4514 - $w_image = wp_remote_get( $w_image )['body'];
10671 + // SSRF guard (CWE-918): validate the resolved host before fetching.
10672 + $fetched = wdesignkit_safe_remote_get( $w_image );
10673 + $w_image = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
4515 10674 }
4516 10675
4517 10676 $array_data = array(
4518 10677 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4552,22 +10711,51 @@
4552 10711 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
4553 10712
4554 10713 if ( ! empty( $img_url ) && 'error' !== $res ) {
4555 10714
4556 - $img_body = wp_remote_get( $img_url );
4557 - $img_ext = pathinfo( $img_url )['extension'];
4558 - include_once ABSPATH . 'wp-admin/includes/file.php';
4559 - \WP_Filesystem();
4560 - global $wp_filesystem;
4561 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4562 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
4563 - $file_path = WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name";
10715 + // SSRF guard (CWE-918): validate the resolved host before fetching.
10716 + $img_body = wdesignkit_safe_remote_get( $img_url );
10717 + if ( ! is_wp_error( $img_body ) ) {
10718 + // Verified against the payload rather than trusted from the URL (CWE-434,
10719 + // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
10720 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
10721 + include_once ABSPATH . 'wp-admin/includes/file.php';
10722 + \WP_Filesystem();
10723 + global $wp_filesystem;
10724 + // Canonical helpers, so the JSON read and the image write here address the same
10725 + // base name every other writer uses (ClickUp 86d41cck5). They also apply
10726 + // sanitize_file_name(), which $title and $w_uniq had not been through.
10727 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10728 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4564 10729
4565 - $u_r_l = wp_json_file_decode( "$file_path.json" );
4566 - $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10730 + // $builder reaches here with only sanitize_text_field() applied and no
10731 + // allowlist, so it was a live traversal segment in this path (CWE-22,
10732 + // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
10733 + // one had neither the builder allowlist nor a containment check.
10734 + $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
10735 + if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
10736 + wp_send_json(
10737 + (object) array(
10738 + 'success' => false,
10739 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
10740 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10741 + )
10742 + );
10743 + wp_die();
10744 + }
4567 10745
4568 - $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
4569 - $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
10746 + $builder = $safe_path['builder'];
10747 + $file_path = $safe_path['base'];
10748 +
10749 + $u_r_l = wp_json_file_decode( "$file_path.json" );
10750 +
10751 + if ( '' !== $img_ext ) {
10752 + $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10753 + $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
10754 + }
10755 +
10756 + $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
10757 + }
4570 10758 }
4571 10759
4572 10760 wp_send_json( $response );
4573 10761 wp_die();
@@ -4667,9 +10855,9 @@
4667 10855 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
4668 10856 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
4669 10857 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
4670 10858 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
4671 - 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
10859 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
4672 10860 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
4673 10861 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
4674 10862 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
4675 10863 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
@@ -4779,21 +10967,21 @@
4779 10967 if ( ! empty( $response['data'] ) ) {
4780 10968 $response = json_decode( wp_json_encode( $response['data'] ), true );
4781 10969
4782 10970 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
4783 - $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'] );
10971 + $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
4784 10972 }
4785 10973
4786 10974 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
4787 - $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'] );
10975 + $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
4788 10976 }
4789 10977
4790 10978 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
4791 - $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'] );
10979 + $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
4792 10980 }
4793 10981
4794 10982 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
4795 - $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'] );
10983 + $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
4796 10984
4797 10985 // Store WDesignKit license status locally for quick access
4798 10986 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
4799 10987 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
@@ -4800,9 +10988,9 @@
4800 10988 }
4801 10989 }
4802 10990
4803 10991 if ( ! empty( $response['data']['wdkit_licence_extra'] ) && is_serialized( $response['data']['wdkit_licence_extra'] ) ) {
4804 - $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'] );
10992 + $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
4805 10993 }
4806 10994 }
4807 10995
4808 10996 wp_send_json( $response );
@@ -4845,12 +11033,16 @@
4845 11033 */
4846 11034 protected function wdkit_sync_licence_key() {
4847 11035 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
4848 11036 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
11037 + // Needed to identify which extra-credit key to sync (wdkit_extra / wdkit_ai_extra
11038 + // are arrays matched by the api key's last digits on the server).
11039 + $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
4849 11040
4850 11041 $args = array(
4851 11042 'token' => $token,
4852 11043 'licencename' => $licencename,
11044 + 'apikey' => $apikey,
4853 11045 );
4854 11046
4855 11047 $response = $this->wkit_api_call( $args, 'licence_sync' );
4856 11048
@@ -4983,9 +11175,16 @@
4983 11175 $token = $this->wdkit_login_user_token( $email );
4984 11176 $args = array( 'token' => $token );
4985 11177
4986 11178 if ( 'session' !== $logout_type ) {
4987 - delete_transient( 'wdkit_auth_' . $email );
11179 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
11180 +
11181 + /* Logged out means the site no longer acts for this account - see
11182 + * Wdkit_Import_Remote::forget_registration(). */
11183 + if ( class_exists( 'Wdkit_Import_Remote' ) ) {
11184 + Wdkit_Import_Remote::forget_registration();
11185 + }
11186 +
4988 11187 // Clear stored license data on logout so banner shows again
4989 11188 delete_option( 'wdkit_licence_data' );
4990 11189 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
4991 11190 }
@@ -5005,9 +11204,9 @@
5005 11204 */
5006 11205 protected function wdkit_login_user_token( $email = '' ) {
5007 11206
5008 11207 if ( ! empty( $email ) ) {
5009 - $user_key = strstr( $email, '@', true );
11208 + $user_key = wdesignkit_cloud_session_key( $email );
5010 11209 $get_login = get_transient( 'wdkit_auth_' . $user_key );
5011 11210
5012 11211 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
5013 11212 return $get_login['token'];
@@ -5025,9 +11224,9 @@
5025 11224 * @param string $data send all post data.
5026 11225 * @param string $type store text data.
5027 11226 * @param string $condition store text data.
5028 11227 */
5029 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
11228 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
5030 11229
5031 11230 if ( 'none' === $condition ) {
5032 11231 return $data[ $type ];
5033 11232 } elseif ( 'cr_widget' === $condition ) {
@@ -5032,8 +11231,10 @@
5032 11231 return $data[ $type ];
5033 11232 } elseif ( 'cr_widget' === $condition ) {
5034 11233 return $data[ $type ];
5035 11234 }
11235 +
11236 + return null;
5036 11237 }
5037 11238
5038 11239
5039 11240 /**
@@ -5187,8 +11388,570 @@
5187 11388 );
5188 11389
5189 11390 wp_send_json( $response );
5190 11391 wp_die();
11392 + }
11393 +
11394 + /**
11395 + * The Gutenberg global-options record a site starts from.
11396 + *
11397 + * Nexter has no defaults of its own until something writes `tpgb_global_options`, and
11398 + * this is what WDesignKit seeds it with: five base colours, the base type scale, the
11399 + * gradients, spacing and box-shadow entries, and an empty container width.
11400 + *
11401 + * Extracted from wdkit_get_global_val() so the PHP import runner can seed the same
11402 + * record. The runner appends the kit's palette to whatever the site already has, and on
11403 + * a site where nothing had written the option yet it was appending to NOTHING — a
11404 + * headless import came out with 13 colours instead of 18, 12 type entries instead of 19
11405 + * and no gradients, spacing or box shadows at all, because the baseline it should have
11406 + * built on did not exist. The wizard never saw it: the globals screen calls
11407 + * wdkit_get_global_val() first, which seeds the option as a side effect.
11408 + *
11409 + * @since 2.7.2
11410 + *
11411 + * @return array
11412 + */
11413 + public static function wdkit_default_gutenberg_globals() {
11414 + return array(
11415 + 'active' => 'preset1',
11416 + 'darkMode' => 'none',
11417 + 'presets' => array(
11418 + 'preset1' => array(
11419 + 'name' => 'Preset 1',
11420 + 'key' => 'preset1',
11421 + 'colors' => array(
11422 + array(
11423 + 'label' => 'Primary',
11424 + 'value' => '#8072FC',
11425 + ),
11426 + array(
11427 + 'label' => 'Secondary',
11428 + 'value' => '#6FC784',
11429 + ),
11430 + array(
11431 + 'label' => 'Tertiary',
11432 + 'value' => '#FF5A6E',
11433 + ),
11434 + array(
11435 + 'label' => 'Accent',
11436 + 'value' => '#F3F3F3',
11437 + ),
11438 + array(
11439 + 'label' => 'Background',
11440 + 'value' => '#888888',
11441 + ),
11442 + ),
11443 + 'gradient' => array(
11444 + array(
11445 + 'label' => 'Primary',
11446 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11447 + ),
11448 +
11449 + array(
11450 + 'label' => 'Secondary',
11451 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11452 + ),
11453 +
11454 + array(
11455 + 'label' => 'Tertiary',
11456 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11457 + ),
11458 +
11459 + array(
11460 + 'label' => 'Accent',
11461 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11462 + ),
11463 +
11464 + array(
11465 + 'label' => 'Background',
11466 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11467 + ),
11468 + ),
11469 + 'spacing' => array(
11470 + array(
11471 + 'label' => 'Large',
11472 + 'value' => array(
11473 + 'md' => 70,
11474 + 'unit' => 'px',
11475 + ),
11476 + ),
11477 + array(
11478 + 'label' => 'Medium',
11479 + 'value' => array(
11480 + 'md' => 40,
11481 + 'unit' => 'px',
11482 + ),
11483 + ),
11484 + array(
11485 + 'label' => 'Small',
11486 + 'value' => array(
11487 + 'md' => 20,
11488 + 'unit' => 'px',
11489 + ),
11490 +
11491 + ),
11492 + ),
11493 + 'typography' => array(
11494 + array(
11495 + 'label' => 'Display Text',
11496 + 'value' => array(
11497 + 'openTypography' => 1,
11498 + 'size' => array(
11499 + 'md' => 65,
11500 + 'unit' => 'px',
11501 + ),
11502 + 'height' => array(
11503 + 'md' => 75,
11504 + 'unit' => 'px',
11505 + ),
11506 + 'fontFamily' => array(
11507 + 'family' => 'Roboto',
11508 + 'type' => 'sans-serif',
11509 + 'fontWeight' => 700,
11510 + ),
11511 + 'spacing' => array(
11512 + 'md' => 0,
11513 + 'unit' => 'px',
11514 + ),
11515 + ),
11516 + ),
11517 + array(
11518 + 'label' => 'Headline',
11519 + 'value' => array(
11520 + 'openTypography' => 1,
11521 + 'size' => array(
11522 + 'md' => 45,
11523 + 'unit' => 'px',
11524 + ),
11525 + 'height' => array(
11526 + 'md' => 60,
11527 + 'unit' => 'px',
11528 + ),
11529 + 'fontFamily' => array(
11530 + 'family' => 'Roboto',
11531 + 'type' => 'sans-serif',
11532 + 'fontWeight' => 700,
11533 + ),
11534 + 'spacing' => array(
11535 + 'md' => 0,
11536 + 'unit' => 'px',
11537 + ),
11538 + ),
11539 + ),
11540 + array(
11541 + 'label' => 'Sub Headline',
11542 + 'value' => array(
11543 + 'openTypography' => 1,
11544 + 'size' => array(
11545 + 'md' => 38,
11546 + 'unit' => 'px',
11547 + ),
11548 + 'height' => array(
11549 + 'md' => 45,
11550 + 'unit' => 'px',
11551 + ),
11552 + 'fontFamily' => array(
11553 + 'family' => 'Roboto',
11554 + 'type' => 'sans-serif',
11555 + 'fontWeight' => 500,
11556 + ),
11557 + 'spacing' => array(
11558 + 'md' => 0,
11559 + 'unit' => 'px',
11560 + ),
11561 + ),
11562 + ),
11563 + array(
11564 + 'label' => 'Title 1',
11565 + 'value' => array(
11566 + 'openTypography' => 1,
11567 + 'size' => array(
11568 + 'md' => 30,
11569 + 'unit' => 'px',
11570 + ),
11571 + 'height' => array(
11572 + 'md' => 40,
11573 + 'unit' => 'px',
11574 + ),
11575 + 'fontFamily' => array(
11576 + 'family' => 'Roboto',
11577 + 'type' => 'sans-serif',
11578 + 'fontWeight' => 500,
11579 + ),
11580 + 'spacing' => array(
11581 + 'md' => 0,
11582 + 'unit' => 'px',
11583 + ),
11584 + ),
11585 + ),
11586 + array(
11587 + 'label' => 'Title 2',
11588 + 'value' => array(
11589 + 'openTypography' => 1,
11590 + 'size' => array(
11591 + 'md' => 25,
11592 + 'unit' => 'px',
11593 + ),
11594 + 'height' => array(
11595 + 'md' => 30,
11596 + 'unit' => 'px',
11597 + ),
11598 + 'fontFamily' => array(
11599 + 'family' => 'Roboto',
11600 + 'type' => 'sans-serif',
11601 + 'fontWeight' => 400,
11602 + ),
11603 + 'spacing' => array(
11604 + 'md' => 0,
11605 + 'unit' => 'px',
11606 + ),
11607 + ),
11608 + ),
11609 + array(
11610 + 'label' => 'Body',
11611 + 'value' => array(
11612 + 'openTypography' => 1,
11613 + 'size' => array(
11614 + 'md' => 17,
11615 + 'unit' => 'px',
11616 + ),
11617 + 'height' => array(
11618 + 'md' => 22,
11619 + 'unit' => 'px',
11620 + ),
11621 + 'fontFamily' => array(
11622 + 'family' => 'Roboto',
11623 + 'type' => 'sans-serif',
11624 + 'fontWeight' => 400,
11625 + ),
11626 + 'spacing' => array(
11627 + 'md' => 0,
11628 + 'unit' => 'px',
11629 + ),
11630 + ),
11631 + ),
11632 + array(
11633 + 'label' => 'Captions',
11634 + 'value' => array(
11635 + 'openTypography' => 1,
11636 + 'size' => array(
11637 + 'md' => 13,
11638 + 'unit' => 'px',
11639 + ),
11640 + 'height' => array(
11641 + 'md' => 16,
11642 + 'unit' => 'px',
11643 + ),
11644 + 'fontFamily' => array(
11645 + 'family' => 'Roboto',
11646 + 'type' => 'sans-serif',
11647 + 'fontWeight' => 400,
11648 + ),
11649 + 'spacing' => array(
11650 + 'md' => 0,
11651 + 'unit' => 'px',
11652 + ),
11653 + ),
11654 + ),
11655 + ),
11656 + 'boxshadow' => array(
11657 + array(
11658 + 'label' => 'Normal Shadow',
11659 + 'value' => array(
11660 + 'openShadow' => 1,
11661 + 'inset' => 0,
11662 + 'horizontal' => 2,
11663 + 'vertical' => 6,
11664 + 'blur' => 10,
11665 + 'spread' => 0,
11666 + 'color' => 'rgba(0,0,0,0.15)',
11667 + ),
11668 + ),
11669 + array(
11670 + 'label' => 'Hover Shadow',
11671 + 'value' => array(
11672 + 'openShadow' => 1,
11673 + 'inset' => 0,
11674 + 'horizontal' => 2,
11675 + 'vertical' => 5,
11676 + 'blur' => 14,
11677 + 'spread' => 3,
11678 + 'color' => 'rgba(0,0,0,0.2)',
11679 + ),
11680 + ),
11681 + ),
11682 + ),
11683 + 'preset2' => array(
11684 + 'name' => 'Preset 2',
11685 + 'key' => 'preset2',
11686 + 'colors' => array(
11687 + array(
11688 + 'label' => 'Primary',
11689 + 'value' => '#8072FC',
11690 + ),
11691 + array(
11692 + 'label' => 'Secondary',
11693 + 'value' => '#6FC784',
11694 + ),
11695 + array(
11696 + 'label' => 'Tertiary',
11697 + 'value' => '#FF5A6E',
11698 + ),
11699 + array(
11700 + 'label' => 'Accent',
11701 + 'value' => '#F3F3F3',
11702 + ),
11703 + array(
11704 + 'label' => 'Background',
11705 + 'value' => '#888888',
11706 + ),
11707 + ),
11708 + 'gradient' => array(
11709 + array(
11710 + 'label' => 'Primary',
11711 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11712 + ),
11713 +
11714 + array(
11715 + 'label' => 'Secondary',
11716 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11717 + ),
11718 +
11719 + array(
11720 + 'label' => 'Tertiary',
11721 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11722 + ),
11723 +
11724 + array(
11725 + 'label' => 'Accent',
11726 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11727 + ),
11728 +
11729 + array(
11730 + 'label' => 'Background',
11731 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11732 + ),
11733 + ),
11734 + 'spacing' => array(
11735 + array(
11736 + 'label' => 'Large',
11737 + 'value' => array(
11738 + 'md' => 70,
11739 + 'unit' => 'px',
11740 + ),
11741 + ),
11742 + array(
11743 + 'label' => 'Medium',
11744 + 'value' => array(
11745 + 'md' => 40,
11746 + 'unit' => 'px',
11747 + ),
11748 + ),
11749 + array(
11750 + 'label' => 'Small',
11751 + 'value' => array(
11752 + 'md' => 20,
11753 + 'unit' => 'px',
11754 + ),
11755 +
11756 + ),
11757 + ),
11758 + 'typography' => array(
11759 + array(
11760 + 'label' => 'Display Text',
11761 + 'value' => array(
11762 + 'openTypography' => 1,
11763 + 'size' => array(
11764 + 'md' => 65,
11765 + 'unit' => 'px',
11766 + ),
11767 + 'height' => array(
11768 + 'md' => 75,
11769 + 'unit' => 'px',
11770 + ),
11771 + 'fontFamily' => array(
11772 + 'family' => 'Roboto',
11773 + 'type' => 'sans-serif',
11774 + 'fontWeight' => 700,
11775 + ),
11776 + 'spacing' => array(
11777 + 'md' => 0,
11778 + 'unit' => 'px',
11779 + ),
11780 + ),
11781 + ),
11782 + array(
11783 + 'label' => 'Headline',
11784 + 'value' => array(
11785 + 'openTypography' => 1,
11786 + 'size' => array(
11787 + 'md' => 45,
11788 + 'unit' => 'px',
11789 + ),
11790 + 'height' => array(
11791 + 'md' => 60,
11792 + 'unit' => 'px',
11793 + ),
11794 + 'fontFamily' => array(
11795 + 'family' => 'Roboto',
11796 + 'type' => 'sans-serif',
11797 + 'fontWeight' => 700,
11798 + ),
11799 + 'spacing' => array(
11800 + 'md' => 0,
11801 + 'unit' => 'px',
11802 + ),
11803 + ),
11804 + ),
11805 + array(
11806 + 'label' => 'Sub Headline',
11807 + 'value' => array(
11808 + 'openTypography' => 1,
11809 + 'size' => array(
11810 + 'md' => 38,
11811 + 'unit' => 'px',
11812 + ),
11813 + 'height' => array(
11814 + 'md' => 45,
11815 + 'unit' => 'px',
11816 + ),
11817 + 'fontFamily' => array(
11818 + 'family' => 'Roboto',
11819 + 'type' => 'sans-serif',
11820 + 'fontWeight' => 500,
11821 + ),
11822 + 'spacing' => array(
11823 + 'md' => 0,
11824 + 'unit' => 'px',
11825 + ),
11826 + ),
11827 + ),
11828 + array(
11829 + 'label' => 'Title 1',
11830 + 'value' => array(
11831 + 'openTypography' => 1,
11832 + 'size' => array(
11833 + 'md' => 30,
11834 + 'unit' => 'px',
11835 + ),
11836 + 'height' => array(
11837 + 'md' => 40,
11838 + 'unit' => 'px',
11839 + ),
11840 + 'fontFamily' => array(
11841 + 'family' => 'Roboto',
11842 + 'type' => 'sans-serif',
11843 + 'fontWeight' => 500,
11844 + ),
11845 + 'spacing' => array(
11846 + 'md' => 0,
11847 + 'unit' => 'px',
11848 + ),
11849 + ),
11850 + ),
11851 + array(
11852 + 'label' => 'Title 2',
11853 + 'value' => array(
11854 + 'openTypography' => 1,
11855 + 'size' => array(
11856 + 'md' => 25,
11857 + 'unit' => 'px',
11858 + ),
11859 + 'height' => array(
11860 + 'md' => 30,
11861 + 'unit' => 'px',
11862 + ),
11863 + 'fontFamily' => array(
11864 + 'family' => 'Roboto',
11865 + 'type' => 'sans-serif',
11866 + 'fontWeight' => 400,
11867 + ),
11868 + 'spacing' => array(
11869 + 'md' => 0,
11870 + 'unit' => 'px',
11871 + ),
11872 + ),
11873 + ),
11874 + array(
11875 + 'label' => 'Body',
11876 + 'value' => array(
11877 + 'openTypography' => 1,
11878 + 'size' => array(
11879 + 'md' => 17,
11880 + 'unit' => 'px',
11881 + ),
11882 + 'height' => array(
11883 + 'md' => 22,
11884 + 'unit' => 'px',
11885 + ),
11886 + 'fontFamily' => array(
11887 + 'family' => 'Roboto',
11888 + 'type' => 'sans-serif',
11889 + 'fontWeight' => 400,
11890 + ),
11891 + 'spacing' => array(
11892 + 'md' => 0,
11893 + 'unit' => 'px',
11894 + ),
11895 + ),
11896 + ),
11897 + array(
11898 + 'label' => 'Captions',
11899 + 'value' => array(
11900 + 'openTypography' => 1,
11901 + 'size' => array(
11902 + 'md' => 13,
11903 + 'unit' => 'px',
11904 + ),
11905 + 'height' => array(
11906 + 'md' => 16,
11907 + 'unit' => 'px',
11908 + ),
11909 + 'fontFamily' => array(
11910 + 'family' => 'Roboto',
11911 + 'type' => 'sans-serif',
11912 + 'fontWeight' => 400,
11913 + ),
11914 + 'spacing' => array(
11915 + 'md' => 0,
11916 + 'unit' => 'px',
11917 + ),
11918 + ),
11919 + ),
11920 + ),
11921 + 'boxshadow' => array(
11922 + array(
11923 + 'label' => 'Normal Shadow',
11924 + 'value' => array(
11925 + 'openShadow' => 1,
11926 + 'inset' => 0,
11927 + 'horizontal' => 2,
11928 + 'vertical' => 6,
11929 + 'blur' => 10,
11930 + 'spread' => 0,
11931 + 'color' => 'rgba(0,0,0,0.15)',
11932 + ),
11933 + ),
11934 + array(
11935 + 'label' => 'Hover Shadow',
11936 + 'value' => array(
11937 + 'openShadow' => 1,
11938 + 'inset' => 0,
11939 + 'horizontal' => 2,
11940 + 'vertical' => 5,
11941 + 'blur' => 14,
11942 + 'spread' => 3,
11943 + 'color' => 'rgba(0,0,0,0.2)',
11944 + ),
11945 + ),
11946 + ),
11947 + ),
11948 + ),
11949 + 'globalContainer' => array(
11950 + 'md' => '',
11951 + 'unit' => 'px',
11952 + ),
11953 + );
5191 11954 }
5192 11955 }
5193 11956
5194 11957 Wdkit_Api_Call::get_instance();