PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
← All changes | includes/admin/class-api.php +6639 -689 2.6.3 → 2.7.0 View file →
@@ -107,8 +107,14 @@
107 107 * Define the core functionality of the plugin.
108 108 */
109 109 public function __construct() {
110 110 add_action( 'wp_ajax_get_wdesignkit', array( $this, 'wdkit_api_call' ) );
111 +
112 + // Background half of the deferred-media-sideload flow (see
113 + // wkit_schedule_deferred_media_sync()/import_page_section_content()): each kit page
114 + // is created instantly with its source CDN image URLs still in place, and this cron
115 + // event does the slow media_sideload_image() work afterwards, off the import request.
116 + add_action( 'wdkit_async_sideload_page_images', array( $this, 'wdkit_async_sideload_page_images' ), 10, 3 );
111 117 }
112 118
113 119 /**
114 120 * Error JSON message
@@ -275,8 +281,14 @@
275 281 break;
276 282 case 'save_wp_images':
277 283 $data = $this->wdkit_save_wp_images();
278 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;
279 291 case 'get_global_val':
280 292 $data = $this->wdkit_get_global_val();
281 293 break;
282 294 case 'update_global_val':
@@ -305,8 +317,11 @@
305 317 break;
306 318 case 'install_plugins_depends':
307 319 $data = $this->wdkit_install_plugins_depends();
308 320 break;
321 + case 'install_plugins_depends_batch':
322 + $data = $this->wdkit_install_plugins_depends_batch();
323 + break;
309 324 case 'generate_site_logo':
310 325 $data = $this->wkit_generate_site_logo();
311 326 break;
312 327 case 'generate_ai_content':
@@ -371,8 +386,11 @@
371 386 break;
372 387 case 'import_page_section':
373 388 $data = $this->import_page_section_content();
374 389 break;
390 + case 'wdkit_import_stage':
391 + $data = $this->wdkit_import_stage();
392 + break;
375 393 case 'wkit_update_elementor_template':
376 394 $data = $this->wkit_update_elementor_template();
377 395 break;
378 396 case 'wdkit_update_page_content':
@@ -389,8 +407,17 @@
389 407 break;
390 408 case 'import_kit_template':
391 409 $data = $this->wdkit_import_kit_template();
392 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;
393 420 case 'enable_template_widgets':
394 421 $data = $this->wdkit_enable_template_widgets();
395 422 break;
396 423 case 'scan_nexter_widgets':
@@ -837,9 +864,9 @@
837 864 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
838 865
839 866 /**Condtion user for user logout & expire token*/
840 867 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
841 - delete_transient( 'wdkit_auth_' . $email );
868 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
842 869 // Clear stored license data when token expires so banner shows again
843 870 delete_option( 'wdkit_licence_data' );
844 871 }
845 872
@@ -884,8 +911,16 @@
884 911 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
885 912 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
886 913 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
887 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 +
888 923 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
889 924
890 925 $local_list = $this->wdkit_get_local_widgets();
891 926
@@ -971,13 +1006,31 @@
971 1006 );
972 1007 }
973 1008 }
974 1009
975 - $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
976 - if ( empty( $get_db_widget ) ) {
977 - add_option( 'wkit_deactivate_widgets', $db_widget, '', 'yes' );
978 - } else {
979 - 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 + }
980 1033 }
981 1034
982 1035 return $final;
983 1036 }
@@ -1114,8 +1167,24 @@
1114 1167 }
1115 1168
1116 1169 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1117 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 +
1118 1187 wp_send_json( $response );
1119 1188 wp_die();
1120 1189 }
1121 1190
@@ -1182,14 +1251,53 @@
1182 1251 * @since 2.3.3
1183 1252 */
1184 1253 protected function wdkit_save_wp_images() {
1185 1254
1255 + $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1256 +
1257 + $response = self::wdkit_sideload_image_data( $image_url );
1258 +
1259 + wp_send_json( $response );
1260 + wp_die();
1261 + }
1262 +
1263 + /**
1264 + * Sideload one image into the media library and describe the outcome.
1265 + *
1266 + * Extracted verbatim from wdkit_save_wp_images() so the same code can be reached
1267 + * without `$_POST` — Wdkit_Import_Media::sideload() calls this, which is why the PHP
1268 + * runner does not need its own copy of the guard or the importer hash stamps.
1269 + *
1270 + * The AJAX action above is now a thin adapter over this and its response is byte-for-byte
1271 + * what it was before.
1272 + *
1273 + * @since 2.6.5
1274 + *
1275 + * @param string $image_url External image URL.
1276 + * @return array{message:string,description:string,success:bool,url?:string}
1277 + */
1278 + public static function wdkit_sideload_image_data( $image_url ) {
1279 +
1186 1280 // media_sideload_image() generates every registered thumbnail size, which decodes
1187 1281 // the full source bitmap. Same guard as the page import.
1188 - $this->wdkit_guard_oversized_images();
1282 + self::wdkit_guard_oversized_images();
1189 1283
1190 - $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1191 -
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.
1192 1300 if ( empty( $image_url ) ) {
1193 1301 $response = array(
1194 1302 'message' => __( 'No Image Provided', 'wdesignkit' ),
1195 1303 'description' => __( 'No Image URL provided for save.', 'wdesignkit' ),
@@ -1196,10 +1304,43 @@
1196 1304 'success' => false,
1197 1305 );
1198 1306 } else {
1199 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 +
1200 1341 $attachment_id = media_sideload_image( $image_url, 0, null, 'id' );
1201 -
1342 +
1202 1343 if ( is_wp_error( $attachment_id ) ) {
1203 1344 $response = array(
1204 1345 'message' => __( 'Upload Failed', 'wdesignkit' ),
1205 1346 'description' => $attachment_id->get_error_message(),
@@ -1224,29 +1365,1576 @@
1224 1365 'message' => __( 'Image Saved', 'wdesignkit' ),
1225 1366 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1226 1367 'success' => true,
1227 1368 'url' => $saved_url,
1369 + 'id' => $attachment_id,
1228 1370 );
1229 1371 }
1230 1372
1231 1373 }
1232 1374
1233 - wp_send_json( $response );
1234 - wp_die();
1375 + return $response;
1235 1376 }
1236 1377
1237 1378 /**
1379 + * The two bundle/media primitives the PHP import runner needs, reachable without a
1380 + * request.
1238 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 `&`, sometimes `&`, and - where a value is escaped twice on the way
1656 + * into post content - as `&`. 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 `&` inside `&`
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 + *
1239 2683 * Get Elementor Global color and Typography.
1240 2684 *
1241 2685 * @since 1.1.16
1242 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 +
1243 2914 protected function wdkit_get_global_val() {
1244 2915
1245 2916 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
1246 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 +
1247 2927 if ( 'elementor' === $builder ) {
1248 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 + }
1249 2937 if ( empty( $kit_id ) ) {
1250 2938 $response = array(
1251 2939 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1252 2940 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1321,10 +3009,40 @@
1321 3009 'viewport_md' => 768,
1322 3010 'viewport_lg' => 1025,
1323 3011 );
1324 3012
1325 - $kit_meta = $static_meta;
1326 - 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 + }
1327 3045 }
1328 3046
1329 3047 $system_colors = ! empty( $kit_meta['system_colors'] ) ? $kit_meta['system_colors'] : array();
1330 3048 $custom_colors = ! empty( $kit_meta['custom_colors'] ) ? $kit_meta['custom_colors'] : array();
@@ -1352,549 +3070,10 @@
1352 3070 $plus_settings = ! empty( $plus_settings ) ? json_decode( $plus_settings, true ) : json_decode( '[]' );
1353 3071
1354 3072 if ( empty( $plus_settings ) ) {
1355 3073
1356 - $static_meta = array(
1357 - 'active' => 'preset1',
1358 - 'darkMode' => 'none',
1359 - 'presets' => array(
1360 - 'preset1' => array(
1361 - 'name' => 'Preset 1',
1362 - 'key' => 'preset1',
1363 - 'colors' => array(
1364 - array(
1365 - 'label' => 'Primary',
1366 - 'value' => '#8072FC',
1367 - ),
1368 - array(
1369 - 'label' => 'Secondary',
1370 - 'value' => '#6FC784',
1371 - ),
1372 - array(
1373 - 'label' => 'Tertiary',
1374 - 'value' => '#FF5A6E',
1375 - ),
1376 - array(
1377 - 'label' => 'Accent',
1378 - 'value' => '#F3F3F3',
1379 - ),
1380 - array(
1381 - 'label' => 'Background',
1382 - 'value' => '#888888',
1383 - ),
1384 - ),
1385 - 'gradient' => array(
1386 - array(
1387 - 'label' => 'Primary',
1388 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1389 - ),
3074 + $static_meta = self::wdkit_default_gutenberg_globals();
1390 3075
1391 - array(
1392 - 'label' => 'Secondary',
1393 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1394 - ),
1395 -
1396 - array(
1397 - 'label' => 'Tertiary',
1398 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1399 - ),
1400 -
1401 - array(
1402 - 'label' => 'Accent',
1403 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1404 - ),
1405 -
1406 - array(
1407 - 'label' => 'Background',
1408 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1409 - ),
1410 - ),
1411 - 'spacing' => array(
1412 - array(
1413 - 'label' => 'Large',
1414 - 'value' => array(
1415 - 'md' => 70,
1416 - 'unit' => 'px',
1417 - ),
1418 - ),
1419 - array(
1420 - 'label' => 'Medium',
1421 - 'value' => array(
1422 - 'md' => 40,
1423 - 'unit' => 'px',
1424 - ),
1425 - ),
1426 - array(
1427 - 'label' => 'Small',
1428 - 'value' => array(
1429 - 'md' => 20,
1430 - 'unit' => 'px',
1431 - ),
1432 -
1433 - ),
1434 - ),
1435 - 'typography' => array(
1436 - array(
1437 - 'label' => 'Display Text',
1438 - 'value' => array(
1439 - 'openTypography' => 1,
1440 - 'size' => array(
1441 - 'md' => 65,
1442 - 'unit' => 'px',
1443 - ),
1444 - 'height' => array(
1445 - 'md' => 75,
1446 - 'unit' => 'px',
1447 - ),
1448 - 'fontFamily' => array(
1449 - 'family' => 'Roboto',
1450 - 'type' => 'sans-serif',
1451 - 'fontWeight' => 700,
1452 - ),
1453 - 'spacing' => array(
1454 - 'md' => 0,
1455 - 'unit' => 'px',
1456 - ),
1457 - ),
1458 - ),
1459 - array(
1460 - 'label' => 'Headline',
1461 - 'value' => array(
1462 - 'openTypography' => 1,
1463 - 'size' => array(
1464 - 'md' => 45,
1465 - 'unit' => 'px',
1466 - ),
1467 - 'height' => array(
1468 - 'md' => 60,
1469 - 'unit' => 'px',
1470 - ),
1471 - 'fontFamily' => array(
1472 - 'family' => 'Roboto',
1473 - 'type' => 'sans-serif',
1474 - 'fontWeight' => 700,
1475 - ),
1476 - 'spacing' => array(
1477 - 'md' => 0,
1478 - 'unit' => 'px',
1479 - ),
1480 - ),
1481 - ),
1482 - array(
1483 - 'label' => 'Sub Headline',
1484 - 'value' => array(
1485 - 'openTypography' => 1,
1486 - 'size' => array(
1487 - 'md' => 38,
1488 - 'unit' => 'px',
1489 - ),
1490 - 'height' => array(
1491 - 'md' => 45,
1492 - 'unit' => 'px',
1493 - ),
1494 - 'fontFamily' => array(
1495 - 'family' => 'Roboto',
1496 - 'type' => 'sans-serif',
1497 - 'fontWeight' => 500,
1498 - ),
1499 - 'spacing' => array(
1500 - 'md' => 0,
1501 - 'unit' => 'px',
1502 - ),
1503 - ),
1504 - ),
1505 - array(
1506 - 'label' => 'Title 1',
1507 - 'value' => array(
1508 - 'openTypography' => 1,
1509 - 'size' => array(
1510 - 'md' => 30,
1511 - 'unit' => 'px',
1512 - ),
1513 - 'height' => array(
1514 - 'md' => 40,
1515 - 'unit' => 'px',
1516 - ),
1517 - 'fontFamily' => array(
1518 - 'family' => 'Roboto',
1519 - 'type' => 'sans-serif',
1520 - 'fontWeight' => 500,
1521 - ),
1522 - 'spacing' => array(
1523 - 'md' => 0,
1524 - 'unit' => 'px',
1525 - ),
1526 - ),
1527 - ),
1528 - array(
1529 - 'label' => 'Title 2',
1530 - 'value' => array(
1531 - 'openTypography' => 1,
1532 - 'size' => array(
1533 - 'md' => 25,
1534 - 'unit' => 'px',
1535 - ),
1536 - 'height' => array(
1537 - 'md' => 30,
1538 - 'unit' => 'px',
1539 - ),
1540 - 'fontFamily' => array(
1541 - 'family' => 'Roboto',
1542 - 'type' => 'sans-serif',
1543 - 'fontWeight' => 400,
1544 - ),
1545 - 'spacing' => array(
1546 - 'md' => 0,
1547 - 'unit' => 'px',
1548 - ),
1549 - ),
1550 - ),
1551 - array(
1552 - 'label' => 'Body',
1553 - 'value' => array(
1554 - 'openTypography' => 1,
1555 - 'size' => array(
1556 - 'md' => 17,
1557 - 'unit' => 'px',
1558 - ),
1559 - 'height' => array(
1560 - 'md' => 22,
1561 - 'unit' => 'px',
1562 - ),
1563 - 'fontFamily' => array(
1564 - 'family' => 'Roboto',
1565 - 'type' => 'sans-serif',
1566 - 'fontWeight' => 400,
1567 - ),
1568 - 'spacing' => array(
1569 - 'md' => 0,
1570 - 'unit' => 'px',
1571 - ),
1572 - ),
1573 - ),
1574 - array(
1575 - 'label' => 'Captions',
1576 - 'value' => array(
1577 - 'openTypography' => 1,
1578 - 'size' => array(
1579 - 'md' => 13,
1580 - 'unit' => 'px',
1581 - ),
1582 - 'height' => array(
1583 - 'md' => 16,
1584 - 'unit' => 'px',
1585 - ),
1586 - 'fontFamily' => array(
1587 - 'family' => 'Roboto',
1588 - 'type' => 'sans-serif',
1589 - 'fontWeight' => 400,
1590 - ),
1591 - 'spacing' => array(
1592 - 'md' => 0,
1593 - 'unit' => 'px',
1594 - ),
1595 - ),
1596 - ),
1597 - ),
1598 - 'boxshadow' => array(
1599 - array(
1600 - 'label' => 'Normal Shadow',
1601 - 'value' => array(
1602 - 'openShadow' => 1,
1603 - 'inset' => 0,
1604 - 'horizontal' => 2,
1605 - 'vertical' => 6,
1606 - 'blur' => 10,
1607 - 'spread' => 0,
1608 - 'color' => 'rgba(0,0,0,0.15)',
1609 - ),
1610 - ),
1611 - array(
1612 - 'label' => 'Hover Shadow',
1613 - 'value' => array(
1614 - 'openShadow' => 1,
1615 - 'inset' => 0,
1616 - 'horizontal' => 2,
1617 - 'vertical' => 5,
1618 - 'blur' => 14,
1619 - 'spread' => 3,
1620 - 'color' => 'rgba(0,0,0,0.2)',
1621 - ),
1622 - ),
1623 - ),
1624 - ),
1625 - 'preset2' => array(
1626 - 'name' => 'Preset 2',
1627 - 'key' => 'preset2',
1628 - 'colors' => array(
1629 - array(
1630 - 'label' => 'Primary',
1631 - 'value' => '#8072FC',
1632 - ),
1633 - array(
1634 - 'label' => 'Secondary',
1635 - 'value' => '#6FC784',
1636 - ),
1637 - array(
1638 - 'label' => 'Tertiary',
1639 - 'value' => '#FF5A6E',
1640 - ),
1641 - array(
1642 - 'label' => 'Accent',
1643 - 'value' => '#F3F3F3',
1644 - ),
1645 - array(
1646 - 'label' => 'Background',
1647 - 'value' => '#888888',
1648 - ),
1649 - ),
1650 - 'gradient' => array(
1651 - array(
1652 - 'label' => 'Primary',
1653 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1654 - ),
1655 -
1656 - array(
1657 - 'label' => 'Secondary',
1658 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1659 - ),
1660 -
1661 - array(
1662 - 'label' => 'Tertiary',
1663 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1664 - ),
1665 -
1666 - array(
1667 - 'label' => 'Accent',
1668 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1669 - ),
1670 -
1671 - array(
1672 - 'label' => 'Background',
1673 - 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
1674 - ),
1675 - ),
1676 - 'spacing' => array(
1677 - array(
1678 - 'label' => 'Large',
1679 - 'value' => array(
1680 - 'md' => 70,
1681 - 'unit' => 'px',
1682 - ),
1683 - ),
1684 - array(
1685 - 'label' => 'Medium',
1686 - 'value' => array(
1687 - 'md' => 40,
1688 - 'unit' => 'px',
1689 - ),
1690 - ),
1691 - array(
1692 - 'label' => 'Small',
1693 - 'value' => array(
1694 - 'md' => 20,
1695 - 'unit' => 'px',
1696 - ),
1697 -
1698 - ),
1699 - ),
1700 - 'typography' => array(
1701 - array(
1702 - 'label' => 'Display Text',
1703 - 'value' => array(
1704 - 'openTypography' => 1,
1705 - 'size' => array(
1706 - 'md' => 65,
1707 - 'unit' => 'px',
1708 - ),
1709 - 'height' => array(
1710 - 'md' => 75,
1711 - 'unit' => 'px',
1712 - ),
1713 - 'fontFamily' => array(
1714 - 'family' => 'Roboto',
1715 - 'type' => 'sans-serif',
1716 - 'fontWeight' => 700,
1717 - ),
1718 - 'spacing' => array(
1719 - 'md' => 0,
1720 - 'unit' => 'px',
1721 - ),
1722 - ),
1723 - ),
1724 - array(
1725 - 'label' => 'Headline',
1726 - 'value' => array(
1727 - 'openTypography' => 1,
1728 - 'size' => array(
1729 - 'md' => 45,
1730 - 'unit' => 'px',
1731 - ),
1732 - 'height' => array(
1733 - 'md' => 60,
1734 - 'unit' => 'px',
1735 - ),
1736 - 'fontFamily' => array(
1737 - 'family' => 'Roboto',
1738 - 'type' => 'sans-serif',
1739 - 'fontWeight' => 700,
1740 - ),
1741 - 'spacing' => array(
1742 - 'md' => 0,
1743 - 'unit' => 'px',
1744 - ),
1745 - ),
1746 - ),
1747 - array(
1748 - 'label' => 'Sub Headline',
1749 - 'value' => array(
1750 - 'openTypography' => 1,
1751 - 'size' => array(
1752 - 'md' => 38,
1753 - 'unit' => 'px',
1754 - ),
1755 - 'height' => array(
1756 - 'md' => 45,
1757 - 'unit' => 'px',
1758 - ),
1759 - 'fontFamily' => array(
1760 - 'family' => 'Roboto',
1761 - 'type' => 'sans-serif',
1762 - 'fontWeight' => 500,
1763 - ),
1764 - 'spacing' => array(
1765 - 'md' => 0,
1766 - 'unit' => 'px',
1767 - ),
1768 - ),
1769 - ),
1770 - array(
1771 - 'label' => 'Title 1',
1772 - 'value' => array(
1773 - 'openTypography' => 1,
1774 - 'size' => array(
1775 - 'md' => 30,
1776 - 'unit' => 'px',
1777 - ),
1778 - 'height' => array(
1779 - 'md' => 40,
1780 - 'unit' => 'px',
1781 - ),
1782 - 'fontFamily' => array(
1783 - 'family' => 'Roboto',
1784 - 'type' => 'sans-serif',
1785 - 'fontWeight' => 500,
1786 - ),
1787 - 'spacing' => array(
1788 - 'md' => 0,
1789 - 'unit' => 'px',
1790 - ),
1791 - ),
1792 - ),
1793 - array(
1794 - 'label' => 'Title 2',
1795 - 'value' => array(
1796 - 'openTypography' => 1,
1797 - 'size' => array(
1798 - 'md' => 25,
1799 - 'unit' => 'px',
1800 - ),
1801 - 'height' => array(
1802 - 'md' => 30,
1803 - 'unit' => 'px',
1804 - ),
1805 - 'fontFamily' => array(
1806 - 'family' => 'Roboto',
1807 - 'type' => 'sans-serif',
1808 - 'fontWeight' => 400,
1809 - ),
1810 - 'spacing' => array(
1811 - 'md' => 0,
1812 - 'unit' => 'px',
1813 - ),
1814 - ),
1815 - ),
1816 - array(
1817 - 'label' => 'Body',
1818 - 'value' => array(
1819 - 'openTypography' => 1,
1820 - 'size' => array(
1821 - 'md' => 17,
1822 - 'unit' => 'px',
1823 - ),
1824 - 'height' => array(
1825 - 'md' => 22,
1826 - 'unit' => 'px',
1827 - ),
1828 - 'fontFamily' => array(
1829 - 'family' => 'Roboto',
1830 - 'type' => 'sans-serif',
1831 - 'fontWeight' => 400,
1832 - ),
1833 - 'spacing' => array(
1834 - 'md' => 0,
1835 - 'unit' => 'px',
1836 - ),
1837 - ),
1838 - ),
1839 - array(
1840 - 'label' => 'Captions',
1841 - 'value' => array(
1842 - 'openTypography' => 1,
1843 - 'size' => array(
1844 - 'md' => 13,
1845 - 'unit' => 'px',
1846 - ),
1847 - 'height' => array(
1848 - 'md' => 16,
1849 - 'unit' => 'px',
1850 - ),
1851 - 'fontFamily' => array(
1852 - 'family' => 'Roboto',
1853 - 'type' => 'sans-serif',
1854 - 'fontWeight' => 400,
1855 - ),
1856 - 'spacing' => array(
1857 - 'md' => 0,
1858 - 'unit' => 'px',
1859 - ),
1860 - ),
1861 - ),
1862 - ),
1863 - 'boxshadow' => array(
1864 - array(
1865 - 'label' => 'Normal Shadow',
1866 - 'value' => array(
1867 - 'openShadow' => 1,
1868 - 'inset' => 0,
1869 - 'horizontal' => 2,
1870 - 'vertical' => 6,
1871 - 'blur' => 10,
1872 - 'spread' => 0,
1873 - 'color' => 'rgba(0,0,0,0.15)',
1874 - ),
1875 - ),
1876 - array(
1877 - 'label' => 'Hover Shadow',
1878 - 'value' => array(
1879 - 'openShadow' => 1,
1880 - 'inset' => 0,
1881 - 'horizontal' => 2,
1882 - 'vertical' => 5,
1883 - 'blur' => 14,
1884 - 'spread' => 3,
1885 - 'color' => 'rgba(0,0,0,0.2)',
1886 - ),
1887 - ),
1888 - ),
1889 - ),
1890 - ),
1891 - 'globalContainer' => array(
1892 - 'md' => '',
1893 - 'unit' => 'px',
1894 - ),
1895 - );
1896 -
1897 3076 update_option( 'tpgb_global_options', json_encode( $static_meta ) );
1898 3077 $plus_settings = $static_meta;
1899 3078 }
1900 3079
@@ -1935,8 +3114,14 @@
1935 3114 */
1936 3115 protected function wdkit_get_site_setting() {
1937 3116
1938 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 +
1939 3124 if ( empty( $kit_id ) ) {
1940 3125 $response = array(
1941 3126 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1942 3127 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1980,8 +3165,33 @@
1980 3165
1981 3166 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
1982 3167 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
1983 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 +
1984 3194 if ( 'elementor' == $builder ) {
1985 3195 $kit_id = get_option( 'elementor_active_kit' );
1986 3196 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
1987 3197 \Elementor\Core\Kits\Manager::create_default_kit();
@@ -1994,10 +3204,9 @@
1994 3204 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
1995 3205 'success' => false,
1996 3206 );
1997 3207
1998 - wp_send_json( $response );
1999 - wp_die();
3208 + return $response;
2000 3209 }
2001 3210
2002 3211 // A freshly created kit has no `_elementor_page_settings` meta yet,
2003 3212 // so an empty result here is a valid starting point, not an error.
@@ -2044,10 +3253,9 @@
2044 3253 'success' => true,
2045 3254 );
2046 3255 }
2047 3256
2048 - wp_send_json( $response );
2049 - wp_die();
3257 + return $response;
2050 3258 }
2051 3259
2052 3260 /**
2053 3261 *
@@ -2058,12 +3266,46 @@
2058 3266 protected function wdkit_update_global_val() {
2059 3267
2060 3268 $builder = ! empty( $_POST['builder'] ) ? sanitize_text_field( $_POST['builder'] ) : '';
2061 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 +
2062 3304 if ( 'elementor' == $builder ) {
2063 3305
2064 - $g_color = ! empty( $_POST['g_color'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_color'] ) ), true ) : array();
2065 - $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();
2066 3308
2067 3309 // Get colors from Elementor Site Kit
2068 3310 $kit_id = get_option( 'elementor_active_kit' );
2069 3311 if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
@@ -2080,10 +3322,9 @@
2080 3322 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
2081 3323 'success' => false,
2082 3324 );
2083 3325
2084 - wp_send_json( $response );
2085 - wp_die();
3326 + return $response;
2086 3327 }
2087 3328
2088 3329 // A freshly created kit has no `_elementor_page_settings` meta yet,
2089 3330 // so an empty result here is a valid starting point, not an error.
@@ -2108,9 +3349,9 @@
2108 3349 'success' => true,
2109 3350 );
2110 3351
2111 3352 } elseif ( 'gutenberg' == $builder ) {
2112 - $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();
2113 3354 $new_preset_id = ! empty( $new_preset['key'] ) ? $new_preset['key'] : '';
2114 3355 $plus_settings = get_option( 'tpgb_global_options' );
2115 3356 $site_preset = json_decode( $plus_settings, true );
2116 3357
@@ -2132,10 +3373,9 @@
2132 3373 'success' => true,
2133 3374 );
2134 3375 }
2135 3376
2136 - wp_send_json( $response );
2137 - wp_die();
3377 + return $response;
2138 3378 }
2139 3379
2140 3380 /**
2141 3381 * Regenerate Elementor's cached CSS files after the active kit's
@@ -2413,8 +3653,32 @@
2413 3653 * @version 1.0.9
2414 3654 */
2415 3655 protected function wdkit_install_plugins_depends() {
2416 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();
2417 3681 $type = ! empty( $plugins['type'] ) ? $plugins['type'] : 'plugin';
2418 3682 $p_id = ! empty( $plugins['p_id'] ) ? $plugins['p_id'] : 'plugin';
2419 3683
2420 3684 $responce = '';
@@ -2472,12 +3736,160 @@
2472 3736 );
2473 3737 }
2474 3738 }
2475 3739
2476 - 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 + );
2477 3845 wp_die();
2478 3846 }
2479 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 +
2480 3892 protected function wdkit_install_theme_depends( $name = 'nexter' ) {
2481 3893
2482 3894 if ( ! current_user_can( 'install_themes' ) ) {
2483 3895 $response = $this->tpae_set_response( false, 'Invalid nonce.', 'The security check failed. Please refresh the page and try again.' );
@@ -2532,18 +3944,14 @@
2532 3944 $theme_info = unserialize( $response['body'], array( 'allowed_classes' => array( 'stdClass' ) ) );
2533 3945 $theme_name = $theme_info->name;
2534 3946 $theme_zip_url = $theme_info->download_link;
2535 3947
2536 - global $wp_filesystem;
2537 - // Install the theme. SSRF guard (CWE-918): validate the resolved host before
2538 - // fetching the ZIP referenced by the external theme_info response. Themes can be
2539 - // large, so raise the response-size cap well above the wrapper's image default.
2540 - $theme = wdesignkit_safe_remote_get( $theme_zip_url, array( 'timeout' => 60, 'limit_response_size' => 256 * MB_IN_BYTES ) );
2541 -
2542 - if ( is_wp_error( $theme ) ) {
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 ) ) {
2543 3951 return array(
2544 3952 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
2545 - 'description' => $theme->get_error_message(),
3953 + 'description' => esc_html__( 'The theme package URL is not allowed.', 'wdesignkit' ),
2546 3954 'status' => 'inactive',
2547 3955 'success' => false,
2548 3956 );
2549 3957 }
@@ -2551,22 +3959,32 @@
2551 3959 if ( ! function_exists( 'WP_Filesystem' ) ) {
2552 3960 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
2553 3961 }
2554 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 +
2555 3966 WP_Filesystem();
2556 3967
2557 3968 $active_theme = wp_get_theme();
2558 3969 $theme_name = $active_theme->get( 'Name' );
2559 3970
2560 - $wp_filesystem->put_contents( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip', $theme['body'] );
2561 - $zip = new ZipArchive();
2562 - if ( $zip->open( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' ) === true ) {
2563 - $zip->extractTo( WP_CONTENT_DIR . '/themes/' );
2564 - $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 + );
2565 3985 }
2566 3986
2567 - $wp_filesystem->delete( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' );
2568 -
2569 3987 $activate_result = switch_theme( $name );
2570 3988
2571 3989 if ( ! is_wp_error( $activate_result ) ) {
2572 3990 $response = array(
@@ -2646,8 +4064,37 @@
2646 4064 protected function wdkit_import_taxonomy() {
2647 4065 $category = isset( $_POST['category'] ) ? json_decode( wp_unslash( $_POST['category'] ) ) : array();
2648 4066 $tags = isset( $_POST['tags'] ) ? json_decode( wp_unslash( $_POST['tags'] ) ) : array();
2649 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 +
2650 4097 $response = array(
2651 4098 'success' => false,
2652 4099 'categories' => array(),
2653 4100 'tags' => array(),
@@ -2712,10 +4159,14 @@
2712 4159
2713 4160 $response['success'] = true;
2714 4161 }
2715 4162
2716 - wp_send_json( $response );
2717 - 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;
2718 4169 }
2719 4170
2720 4171 /**
2721 4172 * Get Download Template Content
@@ -2742,8 +4193,17 @@
2742 4193 $args['token'] = $this->wdkit_login_user_token( $args['email'] );
2743 4194
2744 4195 unset( $args['email'] );
2745 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 +
2746 4206 $response = WDesignKit_Data_Query::get_data( $api_type, $args );
2747 4207
2748 4208 if ( is_wp_error( $response ) ) {
2749 4209 wp_send_json( array(
@@ -2764,9 +4224,9 @@
2764 4224
2765 4225 if ( ! empty( $meta_data ) ) {
2766 4226 foreach ( $meta_data as $meta_key => $meta_val ) {
2767 4227 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
2768 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4228 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
2769 4229 }
2770 4230
2771 4231 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
2772 4232 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
@@ -2777,8 +4237,34 @@
2777 4237 }
2778 4238 }
2779 4239 }
2780 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 +
2781 4267 wp_send_json( $response );
2782 4268 wp_die();
2783 4269 }
2784 4270
@@ -2842,17 +4328,392 @@
2842 4328 *
2843 4329 * @param string $url URL from a media control.
2844 4330 * @return bool True when the URL points at another site's uploads.
2845 4331 */
2846 - private static function wdkit_is_foreign_media_url( $url ) {
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 ) {
2847 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 +
2848 4590 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
2849 4591 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
2850 4592 }
2851 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 +
2852 4713 $uploads = wp_get_upload_dir();
2853 4714
2854 - return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '' );
4715 + return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '', $require_extension );
2855 4716 }
2856 4717
2857 4718 /**
2858 4719 * Find - or make - the local attachment behind a source-site media URL.
@@ -2878,8 +4739,37 @@
2878 4739 if ( isset( $cache[ $url ] ) ) {
2879 4740 return $cache[ $url ];
2880 4741 }
2881 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 +
2882 4772 if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
2883 4773 return array();
2884 4774 }
2885 4775
@@ -2891,23 +4781,55 @@
2891 4781
2892 4782 // A download may happen, so keep the oversized-image guard in force.
2893 4783 self::wdkit_guard_oversized_images();
2894 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 +
2895 4802 $imported = $images->import(
2896 4803 array(
2897 - // Elementor only checks its hash table when an id is present.
2898 - 'id' => $source_id ? $source_id : 1,
4804 + 'id' => $probe_id,
2899 4805 'url' => $url,
2900 4806 )
2901 4807 );
2902 4808
2903 - $local = ( ! empty( $imported['id'] ) && ! empty( $imported['url'] ) )
2904 - ? array(
2905 - 'id' => (int) $imported['id'],
2906 - 'url' => $imported['url'],
2907 - )
2908 - : array();
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();
2909 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 +
2910 4832 // Remember hits only: a sibling request importing concurrently may simply not have
2911 4833 // finished yet, and caching that miss would strand every later control on this page.
2912 4834 if ( $local ) {
2913 4835 $cache[ $url ] = $local;
@@ -2916,8 +4838,69 @@
2916 4838 return $local;
2917 4839 }
2918 4840
2919 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 + /**
2920 4903 * Repair dangling attachment IDs across every page of a finished import.
2921 4904 *
2922 4905 * The create-time repair in wdkit_media_import() can only see attachments that already
2923 4906 * exist. Pages import concurrently and share images — an icon first imported by one
@@ -2928,12 +4911,23 @@
2928 4911 * whatever the per-page pass had to leave behind.
2929 4912 *
2930 4913 * @since 2.6.2
2931 4914 *
2932 - * @param array $page_ids Imported post IDs.
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.
2933 4927 * @return int Number of pages actually rewritten.
2934 4928 */
2935 - private function wdkit_sweep_attachment_ids( $page_ids ) {
4929 + private function wdkit_sweep_attachment_ids( $page_ids, $local_only = false ) {
2936 4930
2937 4931 if ( empty( $page_ids ) || ! did_action( 'elementor/loaded' ) ) {
2938 4932 return 0;
2939 4933 }
@@ -2938,11 +4932,46 @@
2938 4932 return 0;
2939 4933 }
2940 4934
2941 4935 $fixed = 0;
4936 + $ids = array_unique( array_map( 'intval', $page_ids ) );
2942 4937
2943 - foreach ( array_unique( array_map( 'intval', $page_ids ) ) as $post_id ) {
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();
2944 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 +
2945 4974 if ( ! $post_id ) {
2946 4975 continue;
2947 4976 }
2948 4977
@@ -2957,9 +4986,9 @@
2957 4986 if ( ! is_array( $data ) ) {
2958 4987 continue;
2959 4988 }
2960 4989
2961 - $repaired = self::wdkit_repair_attachment_ids( $data );
4990 + $repaired = self::wdkit_repair_attachment_ids( $data, $local_only );
2962 4991
2963 4992 if ( wp_json_encode( $repaired ) === wp_json_encode( $data ) ) {
2964 4993 continue;
2965 4994 }
@@ -2975,11 +5004,12 @@
2975 5004 ++$fixed;
2976 5005 }
2977 5006 }
2978 5007
2979 - if ( $fixed ) {
2980 - \Elementor\Plugin::$instance->files_manager->clear_cache();
2981 - }
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.
2982 5012
2983 5013 return $fixed;
2984 5014 }
2985 5015
@@ -3000,10 +5030,29 @@
3000 5030 *
3001 5031 * @param mixed $node Elementor data, walked recursively.
3002 5032 * @return mixed Data with resolvable attachment IDs.
3003 5033 */
3004 - private static function wdkit_repair_attachment_ids( $node ) {
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 + }
3005 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 +
3006 5055 if ( ! is_array( $node ) ) {
3007 5056 return $node;
3008 5057 }
3009 5058
@@ -3010,24 +5059,60 @@
3010 5059 // A media control value: has a url, and an id slot to correct.
3011 5060 if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
3012 5061
3013 5062 $current = (int) $node['id'];
3014 - $is_live = $current && 'attachment' === get_post_type( $current );
3015 5063
3016 5064 if ( self::wdkit_is_foreign_media_url( $node['url'] ) ) {
3017 5065 // Still pointing at the source site. Ask Elementor for the local copy: its
3018 5066 // _elementor_source_image_hash lookup returns the attachment the create-time
3019 5067 // import already made, so this normally costs a single query and no download.
3020 - $local = self::wdkit_localise_media_url( $node['url'], $current );
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 );
3021 5085
3022 - if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
3023 - $node['id'] = $local['id'];
3024 - $node['url'] = $local['url'];
5086 + if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
5087 + $node['id'] = $local['id'];
5088 + $node['url'] = $local['url'];
5089 + }
3025 5090 }
3026 - } elseif ( ! $is_live && false !== strpos( $node['url'], '/wp-content/uploads/' ) ) {
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.
3027 5107 $resolved = self::wdkit_attachment_id_from_url( $node['url'] );
3028 5108
3029 - if ( $resolved ) {
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 ) ) {
3030 5115 $node['id'] = $resolved;
3031 5116 }
3032 5117 }
3033 5118 }
@@ -3033,9 +5118,9 @@
3033 5118 }
3034 5119
3035 5120 foreach ( $node as $key => $value ) {
3036 5121 if ( is_array( $value ) ) {
3037 - $node[ $key ] = self::wdkit_repair_attachment_ids( $value );
5122 + $node[ $key ] = self::wdkit_repair_attachment_ids( $value, $local_only );
3038 5123 }
3039 5124 }
3040 5125
3041 5126 return $node;
@@ -3040,9 +5125,9 @@
3040 5125
3041 5126 return $node;
3042 5127 }
3043 5128
3044 - public function wdkit_media_import( $content = array(), $editor = '' ) {
5129 + public function wdkit_media_import( $content = array(), $editor = '', $defer_media = false ) {
3045 5130
3046 5131 if ( empty( $content ) && empty( $editor ) ) {
3047 5132 $args = $this->wdkit_parse_args( $_POST );
3048 5133 $content = ! empty( $args['content'] ) ? json_decode( $args['content'], true ) : array();
@@ -3063,14 +5148,155 @@
3063 5148 }
3064 5149
3065 5150
3066 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 +
3067 5206 $media_import = array( $content );
3068 - $media_import = self::blocks_import_media_copy_content( $media_import );
5207 + $media_import = self::blocks_import_media_copy_content( $media_import, $defer_media );
3069 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();
3070 5252 } elseif ( ! empty( $args['editor'] ) && 'elementor' === $args['editor'] && ! empty( $content ) ) {
3071 5253 $media_import = array( $content );
5254 + // Element IDs must stay unique regardless of the media path, so this always runs.
3072 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 +
3073 5299 $media_import = self::widgets_import_media_copy_content( $media_import );
3074 5300 $content = $media_import[0];
3075 5301
3076 5302 // Last: point any control Elementor left holding a foreign attachment ID at the
@@ -3168,9 +5394,9 @@
3168 5394 * Blocks Recursively data
3169 5395 *
3170 5396 * @param string $data_import gutenber data import.
3171 5397 */
3172 - public static function blocks_import_media_copy_content( $data_import ) {
5398 + public static function blocks_import_media_copy_content( $data_import, $defer_media = false ) {
3173 5399 if ( ! empty( $data_import ) ) {
3174 5400 foreach ( $data_import[0] as $key => $val ) {
3175 5401 if ( array_key_exists( 'blockName', $val ) && ( empty( $val['blockName'] ) || null === $val['blockName'] || empty( $val['blockName'] ) || ' ' === $val['blockName'] ) ) {
3176 5402 unset( $data_import[0][ $key ] );
@@ -3179,12 +5405,13 @@
3179 5405 }
3180 5406
3181 5407 return self::blocks_array_recursively_data(
3182 5408 $data_import,
3183 - function ( $block_data ) {
3184 - $elements = self::blocks_data_instance( $block_data );
5409 + function ( $block_data, $args ) {
5410 + $elements = self::blocks_data_instance( $block_data, $args );
3185 5411 return $elements;
3186 - }
5412 + },
5413 + array( 'defer_media' => $defer_media )
3187 5414 );
3188 5415 }
3189 5416
3190 5417 /**
@@ -3229,9 +5456,9 @@
3229 5456 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
3230 5457
3231 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'] ) ) ) {
3232 5459 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
3233 - $blocks_attr = self::wdkit_import_block_media( $blocks_attr );
5460 + $blocks_attr = self::wdkit_import_block_media( $blocks_attr, ! empty( $args['defer_media'] ) );
3234 5461 if ( isset( $block_data['attributes'] ) ) {
3235 5462 $block_data['attributes'] = $blocks_attr;
3236 5463 } elseif ( isset( $block_data['attrs'] ) ) {
3237 5464 $block_data['attrs'] = $blocks_attr;
@@ -3293,8 +5520,14 @@
3293 5520 }
3294 5521
3295 5522 $serialised = serialize_blocks( $blocks );
3296 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 +
3297 5530 // Last line of defence. This function exists to repoint media, so a result carrying
3298 5531 // fewer block attributes than it started with is a broken round trip, not a rewrite.
3299 5532 // Leaving the media wrong is recoverable; saving gutted content is not.
3300 5533 $before = self::wdkit_block_attr_count( $parsable );
@@ -3309,8 +5542,806 @@
3309 5542 return ! empty( $serialised ) ? $serialised : $content;
3310 5543 }
3311 5544
3312 5545 /**
5546 + * Attachment rows Elementor's importer created during this request.
5547 + *
5548 + * @since 2.7.1
5549 + *
5550 + * @var array<int>
5551 + */
5552 + private static $elementor_new_attachments = array();
5553 +
5554 + /**
5555 + * Watch Elementor's importer so a row it leaves behind can be rolled back.
5556 + *
5557 + * Elementor inserts the attachment row before it knows whether the upload worked.
5558 + * class-import-images.php gates the mime type on `if ( $info )`, where `$info` is
5559 + * wp_check_filetype()'s return - always an array, so always true - and it never looks at
5560 + * wp_upload_bits()'s own error. So when the upload fails, which on a real site means an
5561 + * unwritable or full uploads directory, `$upload['file']` is simply absent: Elementor
5562 + * warns "Undefined array key file", inserts a row with post_mime_type '' and no
5563 + * _wp_attached_file, and carries on. Reproduced here by pointing upload_dir at a path
5564 + * that cannot be created.
5565 + *
5566 + * Nothing renders from such a row - wdkit_localise_media_url() refuses the id - but it
5567 + * stays in the customer's media library, and it is exactly the record the reuse lookup
5568 + * used to adopt on a re-import.
5569 + *
5570 + * Registered lazily and only from the import paths, so an ordinary page load never
5571 + * carries either hook. The capture is Elementor's own new_attachment filter rather than
5572 + * an id diff, so only a row Elementor itself just created is ever a candidate.
5573 + *
5574 + * @since 2.7.1
5575 + *
5576 + * @return void
5577 + */
5578 + private static function wdkit_watch_elementor_attachments() {
5579 +
5580 + static $watching = false;
5581 +
5582 + if ( $watching ) {
5583 + return;
5584 + }
5585 +
5586 + $watching = true;
5587 +
5588 + add_filter(
5589 + 'elementor/template_library/import_images/new_attachment',
5590 + static function ( $id ) {
5591 + self::$elementor_new_attachments[] = (int) $id;
5592 +
5593 + return $id;
5594 + }
5595 + );
5596 +
5597 + // Elementor writes the attachment metadata AFTER the filter above fires, so nothing
5598 + // may be deleted from inside it: update_post_meta() on a removed post would leave an
5599 + // orphan meta row, which is worse than the orphan post. The sweep therefore runs once
5600 + // the request is over, and wdkit_localise_media_url() drains it inline as well so a
5601 + // bad id is gone before anything downstream can read it.
5602 + add_action( 'shutdown', array( __CLASS__, 'wdkit_sweep_stub_attachments' ) );
5603 + }
5604 +
5605 + /**
5606 + * Delete the attachment rows Elementor's importer created without a file behind them.
5607 + *
5608 + * Both markers are required, not either: an empty post_mime_type AND no attached file.
5609 + * A media-offload plugin legitimately has no local file but keeps both of those, and an
5610 + * SVG keeps both too - so neither can be reached by this. A row that has a mime type but
5611 + * whose file has since gone missing is deliberately left alone for the same reason: from
5612 + * here it is indistinguishable from offloaded media, and deleting it would take a working
5613 + * image out of a customer's library.
5614 + *
5615 + * @since 2.7.1
5616 + *
5617 + * @return int Rows removed.
5618 + */
5619 + public static function wdkit_sweep_stub_attachments() {
5620 +
5621 + $removed = 0;
5622 +
5623 + foreach ( array_unique( self::$elementor_new_attachments ) as $id ) {
5624 + $id = (int) $id;
5625 +
5626 + if ( ! $id || 'attachment' !== get_post_type( $id ) ) {
5627 + continue;
5628 + }
5629 +
5630 + $file = get_attached_file( $id );
5631 +
5632 + if ( '' !== (string) get_post_mime_type( $id ) || ! empty( $file ) ) {
5633 + continue;
5634 + }
5635 +
5636 + if ( self::wdkit_is_usable_attachment( $id ) ) {
5637 + continue;
5638 + }
5639 +
5640 + wp_delete_attachment( $id, true );
5641 + ++$removed;
5642 + }
5643 +
5644 + self::$elementor_new_attachments = array();
5645 +
5646 + return $removed;
5647 + }
5648 +
5649 + /**
5650 + * Is this id a usable image attachment on THIS site?
5651 + *
5652 + * get_post() is not the question. A template authored elsewhere carries that site's
5653 + * attachment ids, and on the destination those integers frequently already belong to
5654 + * something else entirely - a page, a nav menu item, a revision. Zion is a live example:
5655 + * its team images arrive as ids 93-96, which here are two pages, an nxt_builder template
5656 + * and a nav_menu_item. get_post() happily returns those, so a "does a post exist" test
5657 + * concludes the id is fine and leaves it in place, and every consumer that resolves media
5658 + * through the id gets nothing back.
5659 + *
5660 + * The collision is generic, not specific to those four numbers: any id range that overlaps
5661 + * existing local content behaves the same way. So the test is what the id has to satisfy to
5662 + * actually work - it must be an attachment, and it must have something to serve.
5663 + *
5664 + * SVGs are accepted on the strength of a real file rather than image dimensions: they are
5665 + * imported synchronously and legitimately carry no width/height/sizes metadata.
5666 + *
5667 + * An empty post_mime_type is disqualifying on its own, whatever else the record has.
5668 + * wp_attachment_is_image() is false without one, so wp_get_attachment_image_src() returns
5669 + * false and Elementor's Control_Media::get_style_value() emits no background-image rule at
5670 + * all - the section renders empty even though the id resolves and the url looks right.
5671 + * Every path in this plugin that creates an attachment sets a real mime type (see
5672 + * Wdkit_Import_Images::import(), which deletes the upload rather than insert an unknown
5673 + * type), so a blank one only ever comes from a record an earlier build left behind.
5674 + *
5675 + * @since 2.7.1
5676 + *
5677 + * @param mixed $id Candidate attachment id.
5678 + * @return bool True when the id resolves to an attachment that can actually be rendered.
5679 + */
5680 + private static function wdkit_is_usable_attachment( $id ) {
5681 +
5682 + $id = (int) $id;
5683 +
5684 + if ( ! $id || 'attachment' !== get_post_type( $id ) ) {
5685 + return false;
5686 + }
5687 +
5688 + if ( '' === (string) get_post_mime_type( $id ) ) {
5689 + return false;
5690 + }
5691 +
5692 + $meta = wp_get_attachment_metadata( $id );
5693 +
5694 + if ( is_array( $meta ) && ( ! empty( $meta['file'] ) || ! empty( $meta['sizes'] ) ) ) {
5695 + return true;
5696 + }
5697 +
5698 + // No image metadata is normal for an SVG - fall back to the file itself.
5699 + $file = get_attached_file( $id );
5700 +
5701 + return ! empty( $file ) && file_exists( $file );
5702 + }
5703 +
5704 + /**
5705 + * The attachment a url on this site already refers to, or 0.
5706 + *
5707 + * Exists for the featured-image path: a picked stock image is copied into the media
5708 + * library before the blog posts are created (see localised_images()), so the thumb_image
5709 + * the client sends is normally a url on this very site by then. Handing that to
5710 + * download_url() cannot work - wdesignkit_validate_external_url() rejects loopback and
5711 + * reserved addresses by design - so the file has to be recognised as already-imported
5712 + * instead of fetched again.
5713 + *
5714 + * Host-matched first so nothing offsite can reach attachment_url_to_postid(), then held
5715 + * to the same wdkit_is_usable_attachment() bar as every other id this class writes: an
5716 + * id that resolves to no usable attachment returns 0 and the caller falls through to its
5717 + * normal external handling.
5718 + *
5719 + * @since 2.7.1
5720 + *
5721 + * @param string $url Candidate url.
5722 + * @return int Usable local attachment id, or 0.
5723 + */
5724 + /**
5725 + * Store post_content that this import already produced, without kses rewriting it.
5726 + *
5727 + * wp_update_post() runs post_content through kses whenever the current user cannot
5728 + * `unfiltered_html`. WP-Cron has no user at all, so the deferred media pass always hits
5729 + * that branch - and kses is not a no-op on block markup. It deletes <input>, <iframe>,
5730 + * <form>, <select> and inline <svg> outright, and strips every attribute off <object>.
5731 + *
5732 + * The visible casualty is the pricing switcher: its markup carries
5733 + * `<input class="switch-toggle" type="checkbox">`, and every rule that paints the control
5734 + * is an adjacent-sibling selector on it - `.switch-toggle + .switch-slider` for the track,
5735 + * `.switch-toggle:checked + .switch-slider` for the on state. Delete the input and the
5736 + * track has no background at all and the Monthly/Yearly panels can never switch, which is
5737 + * why the page had to be opened and saved by hand to come back: an administrator does hold
5738 + * `unfiltered_html`, so the editor's own save writes the markup back intact.
5739 + *
5740 + * Suspending the filters around the write is what core's own importer does, for this exact
5741 + * reason. Nothing user-supplied passes through here - this only rewrites image urls inside
5742 + * content an authenticated administrator imported moments earlier - and the filters are
5743 + * restored immediately, so nothing else in the request loses its sanitising.
5744 + *
5745 + * @since 2.7.1
5746 + *
5747 + * @param int $post_id Post to write.
5748 + * @param string $content Block markup to store.
5749 + * @return int|WP_Error Whatever wp_update_post() returned.
5750 + */
5751 + private static function wdkit_write_post_content( $post_id, $content ) {
5752 +
5753 + $had_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
5754 +
5755 + if ( $had_kses ) {
5756 + kses_remove_filters();
5757 + }
5758 +
5759 + // Nexter Blocks' save_post handler treats a save of anything that is not a page,
5760 + // post or product - a header/footer template, say - as a site-wide change and bumps
5761 + // the stamp every page's block bundle is validated against, sending every page back
5762 + // to its unbundled first-view render (block CSS arriving late, from the footer). Every
5763 + // caller here only rewrites URLs; the blocks, and so the bundles, are unchanged, and
5764 + // the page's own stylesheet is rebuilt with a fresh version by
5765 + // wdkit_rebuild_block_css(). So that one handler sits this write out. Measured: one
5766 + // unchanged re-save of the Taj Bakery header took 10 of 10 built pages to 0.
5767 + $detached = self::wdkit_detach_block_bundle_invalidation();
5768 +
5769 + // wp_update_post() unslashes what it is given, exactly like update_post_meta(),
5770 + // so the content has to arrive slashed or every backslash in the markup is eaten.
5771 + // The synchronous import paths already do this (see the wp_slash() calls around
5772 + // wdkit_insert_post_content()); this writer was the one that did not.
5773 + $result = wp_update_post(
5774 + array(
5775 + 'ID' => $post_id,
5776 + 'post_content' => wp_slash( $content ),
5777 + )
5778 + );
5779 +
5780 + // Restore exactly what was there, so the rest of the request sanitises as it did.
5781 + if ( $had_kses ) {
5782 + kses_init_filters();
5783 + }
5784 +
5785 + foreach ( $detached as $hooked ) {
5786 + add_action( 'save_post', $hooked['callback'], $hooked['priority'], $hooked['args'] );
5787 + }
5788 +
5789 + return $result;
5790 + }
5791 +
5792 + /**
5793 + * Take Nexter Blocks' save_post bundle invalidation off the hook, returning what was removed.
5794 + *
5795 + * Found by method name rather than class, the same way the CSS generator is located, so a
5796 + * renamed class does not silently turn this into a no-op - and a site without Nexter
5797 + * Blocks simply gets an empty list back.
5798 + *
5799 + * @since 2.7.2
5800 + *
5801 + * @return array[] Each { callback, priority, args }, for add_action() to put back.
5802 + */
5803 + private static function wdkit_detach_block_bundle_invalidation() {
5804 + global $wp_filter;
5805 +
5806 + $removed = array();
5807 +
5808 + if ( empty( $wp_filter['save_post'] ) || ! is_object( $wp_filter['save_post'] ) || empty( $wp_filter['save_post']->callbacks ) ) {
5809 + return $removed;
5810 + }
5811 +
5812 + foreach ( $wp_filter['save_post']->callbacks as $priority => $callbacks ) {
5813 + foreach ( $callbacks as $hooked ) {
5814 + $callback = isset( $hooked['function'] ) ? $hooked['function'] : null;
5815 +
5816 + if ( is_array( $callback ) && isset( $callback[1] ) && 'plus_post_save_transient' === $callback[1] ) {
5817 + $removed[] = array(
5818 + 'callback' => $callback,
5819 + 'priority' => (int) $priority,
5820 + 'args' => isset( $hooked['accepted_args'] ) ? (int) $hooked['accepted_args'] : 1,
5821 + );
5822 + }
5823 + }
5824 + }
5825 +
5826 + foreach ( $removed as $hooked ) {
5827 + remove_action( 'save_post', $hooked['callback'], $hooked['priority'] );
5828 + }
5829 +
5830 + return $removed;
5831 + }
5832 +
5833 + private static function wdkit_local_attachment_from_url( $url ) {
5834 +
5835 + if ( ! is_string( $url ) || '' === $url ) {
5836 + return 0;
5837 + }
5838 +
5839 + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
5840 +
5841 + if ( '' === $host ) {
5842 + return 0;
5843 + }
5844 +
5845 + // Both, because a site can be served on a different host than it stores.
5846 + $local_hosts = array_filter(
5847 + array_unique(
5848 + array(
5849 + strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ),
5850 + strtolower( (string) wp_parse_url( site_url(), PHP_URL_HOST ) ),
5851 + )
5852 + )
5853 + );
5854 +
5855 + if ( ! in_array( $host, $local_hosts, true ) ) {
5856 + return 0;
5857 + }
5858 +
5859 + $id = (int) attachment_url_to_postid( $url );
5860 +
5861 + // A sized rendition (…-300x122.png) is not itself an attachment; the full size is.
5862 + if ( ! $id ) {
5863 + $full = preg_replace( '/-\d+x\d+(?=\.[A-Za-z0-9]+$)/', '', $url );
5864 +
5865 + if ( is_string( $full ) && $full !== $url ) {
5866 + $id = (int) attachment_url_to_postid( $full );
5867 + }
5868 + }
5869 +
5870 + return self::wdkit_is_usable_attachment( $id ) ? $id : 0;
5871 + }
5872 +
5873 + /**
5874 + * Block attributes whose image is rebuilt from the attributes on every render, keyed by
5875 + * block name and listing the attribute key the image object sits under.
5876 + *
5877 + * Only these can be helped by wdkit_clear_deferred_attachment_ids(): their renderer does
5878 + *
5879 + * if ( ! empty( $img['id'] ) ) -> wp_get_attachment_image( $img['id'], ... )
5880 + * elseif ( ! empty( $img['url'] ) ) -> <img src="{$img['url']}">
5881 + *
5882 + * so an id that resolves to nothing wins over a url that works, and nothing is emitted at
5883 + * all. Zeroing the id lets the url branch run until the deferred cron supplies the real id.
5884 + *
5885 + * Deliberately an allow-list rather than a sweep, because none of the cheap ways to tell
5886 + * "the renderer rebuilds this from attributes" actually hold here:
5887 + *
5888 + * - WP_Block_Type_Registry::is_dynamic() reports every TPGB block dynamic (they all get a
5889 + * render_callback for css injection), tpgb/tp-image included - and tp-image is served
5890 + * from its stored innerHTML, whose save() derives wp-image-{id} from this very
5891 + * attribute. Zeroing it there would make the block fail editor validation.
5892 + * - "the url also appears in innerHTML" does not mean innerHTML is what gets served:
5893 + * tp-team-listing and tp-testimonials both carry the source site's last-saved markup
5894 + * even though their php renderer ignores it.
5895 + *
5896 + * A block missing from this list simply keeps today's behaviour, so an omission costs a
5897 + * fix, never a regression.
5898 + *
5899 + * @since 2.7.1
5900 + *
5901 + * @return array block name => list of attribute keys holding an image object.
5902 + */
5903 + private static function wdkit_deferred_image_attrs() {
5904 +
5905 + return array(
5906 + 'tpgb/tp-team-listing' => array( 'TImage' ),
5907 + 'tpgb/tp-testimonials' => array( 'avatar' ),
5908 + 'tpgb/tp-infobox' => array( 'imageName' ),
5909 + 'tpgb/tp-heading-title' => array( 'imgName' ),
5910 + 'tpgb/tp-button' => array( 'imageName' ),
5911 + 'tpgb/tp-navigation-builder' => array( 'openImg', 'closeImg', 'menuImg' ),
5912 + 'tpgb/tp-pricing-list' => array( 'imageField' ),
5913 + 'tpgb/tp-social-icons' => array( 'imgField' ),
5914 + 'tpgb/tp-stylist-list' => array( 'iconImg' ),
5915 + 'tpgb/tp-breadcrumbs' => array( 'iconsImg', 'sepIconImg' ),
5916 + 'tpgb/tp-google-map' => array( 'pinIcon' ),
5917 + 'tpgb/tp-hovercard' => array( 'cntImg' ),
5918 + 'tpgb/tp-video' => array( 'BannerImg' ),
5919 + // The header logo. Missing this one cost every kit whose header uses the block
5920 + // with a raster logo its logo entirely: the file is deferred like any other PNG,
5921 + // so the block was left holding the authoring site's attachment id, and its
5922 + // renderer takes the id branch first - wp_get_attachment_image() on an id that
5923 + // is not here returns nothing, and the block substitutes its OWN
5924 + // tpgb-placeholder.jpg rather than falling through to the url that works. So the
5925 + // header came out with a grey placeholder box where the logo should be
5926 + // (reproduced on the PawFusion Gutenberg kit, whose logo is a .png; kits with an
5927 + // .svg logo were unaffected because SVGs never defer and keep a real local id).
5928 + // All three of its image slots read the id first, hover and sticky included.
5929 + 'tpgb/tp-site-logo' => array( 'imageStore', 'hvrImageStore', 'stickyImg' ),
5930 + 'tpgb/tp-progress-bar' => array( 'imageName' ),
5931 + // Pro blocks, same renderer shape.
5932 + 'tpgb/tp-circle-menu' => array( 'imageStore' ),
5933 + 'tpgb/tp-timeline' => array( 'StartImage', 'EndImage' ),
5934 + );
5935 + }
5936 +
5937 + /**
5938 + * Point the blocks in wdkit_deferred_image_attrs() at their remote url for as long as
5939 + * their image is still deferred, by zeroing the foreign attachment id sitting next to it.
5940 + *
5941 + * Runs on the parsed block array the media walk just returned, so there is no markup
5942 + * round trip here at all - no escaping depth to resolve, no re-serialise, no attribute
5943 + * count to compare. Only the integer value of one already-present key changes.
5944 + *
5945 + * Zero specifically, and never '' / null / unset: those all read as "no id" to the
5946 + * renderer too, but wdkit_remap_attachment_ids() gates on
5947 + * isset( $node['id'] ) && '' !== $node['id'], so only an integer 0 both silences the
5948 + * renderer now and still gets picked up and replaced with the real id when the deferred
5949 + * cron localises the file. Dropping the key would also shrink the attribute count that
5950 + * that pass's own guard compares.
5951 + *
5952 + * If the cron never runs at all, the id stays 0 and the url stays remote - which renders
5953 + * the image rather than nothing, so this is also the safer resting state.
5954 + *
5955 + * @since 2.7.1
5956 + *
5957 + * @param array $blocks parsed block array, post media walk.
5958 + * @param array $deferred urls this walk chose to localise later.
5959 + * @return array The same block array, with unusable deferred ids zeroed.
5960 + */
5961 + private static function wdkit_clear_deferred_attachment_ids( $blocks, $deferred ) {
5962 +
5963 + if ( empty( $blocks ) || ! is_array( $blocks ) || empty( $deferred ) || ! is_array( $deferred ) ) {
5964 + return $blocks;
5965 + }
5966 +
5967 + $allow = self::wdkit_deferred_image_attrs();
5968 + $deferred = array_fill_keys( array_map( 'strval', $deferred ), true );
5969 +
5970 + $clear_node = function ( &$node ) use ( $deferred ) {
5971 + if ( ! is_array( $node ) || ! isset( $node['url'], $node['id'] ) ) {
5972 + return;
5973 + }
5974 +
5975 + if ( ! is_string( $node['url'] ) || '' === $node['url'] ) {
5976 + return;
5977 + }
5978 +
5979 + // A dynamic-source image resolves through TPGB Pro's own url helper, and in
5980 + // several renderers that branch sits ahead of the plain url fallback - so an
5981 + // empty id would route somewhere else entirely. Leave those untouched.
5982 + if ( isset( $node['dynamic'] ) ) {
5983 + return;
5984 + }
5985 +
5986 + // Only images this same walk actually deferred. Anything else either imported
5987 + // synchronously already or was never ours to touch - link fields carry a
5988 + // url/id pair too, and a menu item's id is a post id, not an attachment.
5989 + if ( ! isset( $deferred[ (string) $node['url'] ] ) ) {
5990 + return;
5991 + }
5992 +
5993 + // Same list the collector defers and wdkit_remap_attachment_ids() restores: an id
5994 + // cleared for an extension one of those passes will not revisit would never come
5995 + // back. This used to omit avif, which the collector already deferred.
5996 + $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $node['url'], PHP_URL_PATH ), PATHINFO_EXTENSION ) );
5997 +
5998 + if ( ! in_array( $ext, Wdkit_Image_Guard::deferrable_extensions(), true ) ) {
5999 + return;
6000 + }
6001 +
6002 + // An id that already resolves here is never second-guessed.
6003 + if ( 0 === $node['id'] || self::wdkit_is_usable_attachment( $node['id'] ) ) {
6004 + return;
6005 + }
6006 +
6007 + $node['id'] = 0;
6008 + };
6009 +
6010 + $walk_attrs = function ( &$node, $keys ) use ( &$walk_attrs, $clear_node ) {
6011 + foreach ( $node as $key => &$value ) {
6012 + if ( ! is_array( $value ) ) {
6013 + continue;
6014 + }
6015 +
6016 + // The image object itself: clear it and stop. Its own 'sizes' map holds the
6017 + // source site's other renditions, which are separate files that this import
6018 + // may never localise - a 0 written in there could never be restored.
6019 + if ( in_array( (string) $key, $keys, true ) ) {
6020 + $clear_node( $value );
6021 + continue;
6022 + }
6023 +
6024 + if ( 'sizes' === (string) $key ) {
6025 + continue;
6026 + }
6027 +
6028 + $walk_attrs( $value, $keys );
6029 + }
6030 +
6031 + unset( $value );
6032 + };
6033 +
6034 + $walk_blocks = function ( &$list ) use ( &$walk_blocks, &$walk_attrs, $allow ) {
6035 + foreach ( $list as &$block ) {
6036 + if ( ! is_array( $block ) ) {
6037 + continue;
6038 + }
6039 +
6040 + $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
6041 +
6042 + if ( '' !== $name && isset( $allow[ $name ] ) && ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
6043 + $walk_attrs( $block['attrs'], $allow[ $name ] );
6044 + }
6045 +
6046 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
6047 + $walk_blocks( $block['innerBlocks'] );
6048 + }
6049 + }
6050 +
6051 + unset( $block );
6052 + };
6053 +
6054 + $walk_blocks( $blocks );
6055 +
6056 + return $blocks;
6057 + }
6058 +
6059 + /**
6060 + * Repoint attachment ids left behind by the deferred media pass.
6061 + *
6062 + * wdkit_async_sideload_page_images() rewrites every source url in post_content to its local
6063 + * copy, but a Gutenberg media attribute is a pair - `{"url":…,"id":…}` - and only the url
6064 + * half was ever touched. The id keeps pointing at the attachment id from the site the
6065 + * template was authored on, which does not exist here. Blocks that render from the url are
6066 + * unaffected, which is why this went unnoticed; blocks that resolve the attachment id
6067 + * instead (tp-team-listing, tp-testimonials, tp-image, tp-container backgrounds) get
6068 + * nothing back and render empty. The synchronous path never had this problem because
6069 + * wdkit_Import_media() returns both halves and its caller writes both.
6070 + *
6071 + * Declines by returning null rather than guessing. Every caller keeps the url-only content
6072 + * in that case, so the worst outcome is exactly today's behaviour and never worse than it:
6073 + *
6074 + * - not block markup, or the escaping depth cannot be resolved unambiguously
6075 + * - nothing actually needs remapping (no serialise round trip is attempted at all)
6076 + * - the round trip came back with fewer block attributes than it started with
6077 + * - the round trip came back empty
6078 + *
6079 + * Only the id half of a media pair is written, only when the current id resolves to no
6080 + * attachment at all, and only to an id this same pass created ($url_map) - so an id that
6081 + * already resolves is never touched and no unrelated attribute is rewritten.
6082 + *
6083 + * @since 2.7.1
6084 + *
6085 + * @param string $content post_content with urls already localised.
6086 + * @param array $url_map source url => array( 'id' => int, 'url' => string ) from this pass.
6087 + * @return string|null Rewritten content, or null to keep the caller's own content.
6088 + */
6089 + private function wdkit_remap_attachment_ids( $content, $url_map ) {
6090 +
6091 + if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) || empty( $url_map ) ) {
6092 + return null;
6093 + }
6094 +
6095 + // Local url => the attachment id this pass just created for it. This is the primary
6096 + // mapping: these ids were made moments ago in this same request, so no lookup is needed.
6097 + $by_url = array();
6098 +
6099 + foreach ( $url_map as $local ) {
6100 + // Only ids that are genuinely renderable attachments here - see
6101 + // wdkit_is_usable_attachment(). Writing an id that does not resolve would trade one
6102 + // broken reference for another.
6103 + if ( ! empty( $local['url'] ) && ! empty( $local['id'] ) && self::wdkit_is_usable_attachment( $local['id'] ) ) {
6104 + $by_url[ (string) $local['url'] ] = (int) $local['id'];
6105 + }
6106 + }
6107 +
6108 + if ( empty( $by_url ) ) {
6109 + return null;
6110 + }
6111 +
6112 + // Same normalisation the relink path uses: an extra level of escaping makes
6113 + // parse_blocks() read no attributes at all, and serialising that back out would write
6114 + // every block bare. Null means the depth is ambiguous - decline rather than risk it.
6115 + $parsable = self::wdkit_parsable_block_content( $content );
6116 +
6117 + if ( null === $parsable ) {
6118 + return null;
6119 + }
6120 +
6121 + $blocks = parse_blocks( $parsable );
6122 + $changed = 0;
6123 +
6124 + $remap = function ( &$node ) use ( &$remap, $by_url, &$changed ) {
6125 + if ( ! is_array( $node ) ) {
6126 + return;
6127 + }
6128 +
6129 + if ( isset( $node['url'], $node['id'] ) && is_string( $node['url'] ) && '' !== $node['url'] && '' !== $node['id'] ) {
6130 + $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $node['url'], PHP_URL_PATH ), PATHINFO_EXTENSION ) );
6131 +
6132 + // Same list the collector defers and wdkit_blank_deferred_media_ids() clears -
6133 + // the SVG path imports synchronously and already carries a real id. And only
6134 + // when the id resolves to nothing: an id that already works is never
6135 + // second-guessed, whatever the url says.
6136 + if ( in_array( $ext, Wdkit_Image_Guard::deferrable_extensions(), true ) && ! self::wdkit_is_usable_attachment( $node['id'] ) ) {
6137 + $new_id = isset( $by_url[ $node['url'] ] ) ? $by_url[ $node['url'] ] : 0;
6138 +
6139 + // A sized rendition (…-800x600.png) of something this pass localised: the map
6140 + // is keyed by the full-size url, so try that before any database work.
6141 + $full = preg_replace( '/-\d+x\d+(?=\.[A-Za-z0-9]+$)/', '', $node['url'] );
6142 +
6143 + if ( ! $new_id && is_string( $full ) && $full !== $node['url'] && isset( $by_url[ $full ] ) ) {
6144 + $new_id = $by_url[ $full ];
6145 + }
6146 +
6147 + // Defensive last resort for a shape the map does not cover.
6148 + if ( ! $new_id ) {
6149 + $probe = (int) attachment_url_to_postid( $node['url'] );
6150 +
6151 + if ( ! $probe && is_string( $full ) && $full !== $node['url'] ) {
6152 + $probe = (int) attachment_url_to_postid( $full );
6153 + }
6154 +
6155 + // Held to the same bar as the map above.
6156 + if ( $probe && self::wdkit_is_usable_attachment( $probe ) ) {
6157 + $new_id = $probe;
6158 + }
6159 + }
6160 +
6161 + if ( $new_id && (int) $node['id'] !== $new_id ) {
6162 + $node['id'] = $new_id;
6163 + ++$changed;
6164 + }
6165 + }
6166 + }
6167 +
6168 + foreach ( $node as $key => &$child ) {
6169 + if ( is_array( $child ) ) {
6170 + $remap( $child );
6171 + }
6172 + }
6173 +
6174 + unset( $child );
6175 + };
6176 +
6177 + $walk_blocks = function ( &$list ) use ( &$walk_blocks, &$remap ) {
6178 + foreach ( $list as &$block ) {
6179 + if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
6180 + $remap( $block['attrs'] );
6181 + }
6182 +
6183 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
6184 + $walk_blocks( $block['innerBlocks'] );
6185 + }
6186 + }
6187 +
6188 + unset( $block );
6189 + };
6190 +
6191 + $walk_blocks( $blocks );
6192 +
6193 + // Nothing to fix: do not serialise at all. A round trip that changes nothing is still a
6194 + // round trip, and not taking it is strictly safer than taking it.
6195 + if ( ! $changed ) {
6196 + return null;
6197 + }
6198 +
6199 + $serialised = serialize_blocks( $blocks );
6200 +
6201 + // serialize_blocks() re-escapes any literal "--" left in attributes, so undo that here
6202 + // exactly as the relink path does.
6203 + $serialised = $this->replace_unicode_glitch( $serialised );
6204 +
6205 + if ( empty( $serialised ) ) {
6206 + return null;
6207 + }
6208 +
6209 + // Last line of defence, same test the relink path applies: fewer attributes out than in
6210 + // is a broken round trip, not a rewrite. A wrong id is recoverable; gutted content is not.
6211 + if ( self::wdkit_block_attr_count( $serialised ) < self::wdkit_block_attr_count( $parsable ) ) {
6212 + return null;
6213 + }
6214 +
6215 + return $serialised;
6216 + }
6217 +
6218 + /**
6219 + * Repoint the `wp-image-{id}` class that the import stamped into rendered <img> tags.
6220 + *
6221 + * WDKIT_Nexter_Block_Processor::process_image() writes both halves of the tag from the block
6222 + * attributes as they stand when it runs - `src` and `class="… wp-image-{id}"`. With deferred
6223 + * media that is before the image exists locally, so the class carries the id from the site
6224 + * the template was authored on. The deferred pass then repairs the `src` (it is the same url
6225 + * string the replace targets) and the attribute id, but the integer inside the class matches
6226 + * no url and no attribute, so it is left pointing at an attachment that does not exist here.
6227 + *
6228 + * Nothing renders wrong as a result - display comes from `src` - but the class is what links
6229 + * the block back to a Media Library item, so in the editor the image looks detached from any
6230 + * attachment.
6231 + *
6232 + * Deliberately a targeted string rewrite and NOT a parse_blocks()/serialize_blocks() round
6233 + * trip. The change is a run of digits inside one attribute of one tag whose `src` is already
6234 + * known, so nothing here needs to understand block structure - and skipping the round trip
6235 + * means the escaping-depth and attribute-loss failure modes that guard the sibling id remap
6236 + * cannot arise at all.
6237 + *
6238 + * Declines by returning null - not block-ish content, no mapping, a regex that failed, or
6239 + * nothing to change - and the caller then keeps its own content untouched.
6240 + *
6241 + * @since 2.7.1
6242 + *
6243 + * @param string $content post_content with urls and attribute ids already repaired.
6244 + * @param array $url_map source url => array( 'id' => int, 'url' => string ) from this pass.
6245 + * @return string|null Rewritten content, or null to keep the caller's own content.
6246 + */
6247 + private function wdkit_remap_wp_image_classes( $content, $url_map ) {
6248 +
6249 + if ( ! is_string( $content ) || '' === $content || empty( $url_map ) ) {
6250 + return null;
6251 + }
6252 +
6253 + // Nothing stamped a class in this content - no work, and no reason to run a regex over it.
6254 + if ( false === stripos( $content, 'wp-image-' ) || false === stripos( $content, '<img' ) ) {
6255 + return null;
6256 + }
6257 +
6258 + // Local url => the attachment id this pass created for it. Same map the id remap uses, so
6259 + // the class and the attribute cannot end up disagreeing with each other.
6260 + $by_url = array();
6261 +
6262 + foreach ( $url_map as $local ) {
6263 + // Only ids that are genuinely renderable attachments here - see
6264 + // wdkit_is_usable_attachment(). Writing an id that does not resolve would trade one
6265 + // broken reference for another.
6266 + if ( ! empty( $local['url'] ) && ! empty( $local['id'] ) && self::wdkit_is_usable_attachment( $local['id'] ) ) {
6267 + $by_url[ (string) $local['url'] ] = (int) $local['id'];
6268 + }
6269 + }
6270 +
6271 + if ( empty( $by_url ) ) {
6272 + return null;
6273 + }
6274 +
6275 + // Structural fingerprint taken before the rewrite. The only thing this function may
6276 + // change is digits inside a class attribute, so every one of these has to come back
6277 + // identical - anything else means the regex reshaped the markup and the result is refused.
6278 + $img_before = preg_match_all( '/<img\b/i', $content );
6279 + $src_before = preg_match_all( '/\ssrc=/i', $content );
6280 + $changed = 0;
6281 +
6282 + // One pass over the <img> tags, rather than one pass per url: a kit page carries dozens of
6283 + // localised urls and re-scanning the whole document for each of them is needless work.
6284 + $result = preg_replace_callback(
6285 + '/<img\b[^>]*>/i',
6286 + function ( $matches ) use ( $by_url, &$changed ) {
6287 + $tag = $matches[0];
6288 +
6289 + if ( false === stripos( $tag, 'wp-image-' ) ) {
6290 + return $tag;
6291 + }
6292 +
6293 + // Only ever act on a tag whose src is exactly a url this pass localised. A tag
6294 + // pointing anywhere else is left alone, whatever its class says.
6295 + if ( ! preg_match( '/\ssrc=(["\'])(.*?)\1/i', $tag, $src ) ) {
6296 + return $tag;
6297 + }
6298 +
6299 + if ( ! isset( $by_url[ $src[2] ] ) ) {
6300 + return $tag;
6301 + }
6302 +
6303 + $new_id = $by_url[ $src[2] ];
6304 +
6305 + $rewritten = preg_replace_callback(
6306 + '/wp-image-(\d+)/',
6307 + function ( $class_match ) use ( $new_id, &$changed ) {
6308 + if ( (int) $class_match[1] === $new_id ) {
6309 + return $class_match[0];
6310 + }
6311 +
6312 + ++$changed;
6313 +
6314 + return 'wp-image-' . $new_id;
6315 + },
6316 + $tag
6317 + );
6318 +
6319 + return null === $rewritten ? $tag : $rewritten;
6320 + },
6321 + $content
6322 + );
6323 +
6324 + // preg_replace_callback() returns null on failure (backtrack limit on a large page, for
6325 + // instance). Never hand a partial result back.
6326 + if ( null === $result || '' === $result ) {
6327 + return null;
6328 + }
6329 +
6330 + if ( ! $changed ) {
6331 + return null;
6332 + }
6333 +
6334 + // Same tag count, same src count: the rewrite touched classes and nothing structural.
6335 + if ( preg_match_all( '/<img\b/i', $result ) !== $img_before
6336 + || preg_match_all( '/\ssrc=/i', $result ) !== $src_before ) {
6337 + return null;
6338 + }
6339 +
6340 + return $result;
6341 + }
6342 +
6343 + /**
3313 6344 * Rebuild the block stylesheet for a page whose content we just rewrote.
3314 6345 *
3315 6346 * The addon keeps each block's styling in a generated per-page stylesheet, and every rule
3316 6347 * is keyed to the block id it was written for. That file is produced when the page is
@@ -3328,8 +6359,16 @@
3328 6359 if ( ! $post_id ) {
3329 6360 return false;
3330 6361 }
3331 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 +
3332 6371 foreach ( get_declared_classes() as $class ) {
3333 6372 if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
3334 6373 continue;
3335 6374 }
@@ -3411,19 +6450,23 @@
3411 6450 if ( false === strpos( $content, '{' ) ) {
3412 6451 return $content;
3413 6452 }
3414 6453
3415 - $stripped = wp_unslash( $content );
3416 - $stripped_attrs = self::wdkit_block_attr_count( $stripped );
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 + }
3417 6461
6462 + $stripped = wp_unslash( $content );
6463 + $stripped_attrs = self::wdkit_block_attr_count( $stripped );
6464 +
3418 6465 if ( $stripped_attrs > $as_is ) {
3419 6466 return $stripped;
3420 6467 }
3421 6468
3422 - if ( $as_is > 0 ) {
3423 - return $content;
3424 - }
3425 -
3426 6469 // Neither form parses into attributes even though the markup contains JSON: better to
3427 6470 // leave the content exactly as it arrived than to rewrite it into something bare.
3428 6471 return null;
3429 6472 }
@@ -3442,8 +6485,9 @@
3442 6485 * @return array The block with its markup repointed.
3443 6486 */
3444 6487 private static function wdkit_relink_block_markup( $block_data ) {
3445 6488
6489 + self::wdkit_require_import_images();
3446 6490 $map = Wdkit_Import_Images::get_url_map();
3447 6491
3448 6492 if ( empty( $map ) ) {
3449 6493 return $block_data;
@@ -3484,9 +6528,9 @@
3484 6528 *
3485 6529 * @param mixed $node Block attributes, walked recursively.
3486 6530 * @return mixed Attributes with local media.
3487 6531 */
3488 - private static function wdkit_import_block_media( $node ) {
6532 + private static function wdkit_import_block_media( $node, $defer_media = false ) {
3489 6533
3490 6534 if ( ! is_array( $node ) ) {
3491 6535 return $node;
3492 6536 }
@@ -3496,9 +6540,9 @@
3496 6540 if ( '' !== $url
3497 6541 && ( array_key_exists( 'id', $node ) || array_key_exists( 'Id', $node )
3498 6542 || preg_match( '/\.(?:jpe?g|png|gif|svg|webp|avif|bmp)$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) )
3499 6543 ) {
3500 - $imported = Wdkit_Import_Images::wdkit_Import_media( $node );
6544 + $imported = Wdkit_Import_Images::wdkit_Import_media( $node, $defer_media );
3501 6545
3502 6546 // Only accept a real result. The importer returns the node untouched when it
3503 6547 // cannot localise the file, and anything falsy here would wipe out the URL and
3504 6548 // leave the block with no image at all.
@@ -3511,9 +6555,9 @@
3511 6555 // map of per-size URLs, and returning here left every one of those pointing at the
3512 6556 // site the template came from - which is what the widgets actually render from.
3513 6557 foreach ( $node as $key => $value ) {
3514 6558 if ( is_array( $value ) ) {
3515 - $node[ $key ] = self::wdkit_import_block_media( $value );
6559 + $node[ $key ] = self::wdkit_import_block_media( $value, $defer_media );
3516 6560 }
3517 6561 }
3518 6562
3519 6563 return $node;
@@ -3573,8 +6617,12 @@
3573 6617 'website_kit' => $website_kit,
3574 6618 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
3575 6619 );
3576 6620
6621 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6622 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
6623 + }
6624 +
3577 6625 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3578 6626 $output = array();
3579 6627
3580 6628 if ( is_wp_error( $response ) ) {
@@ -3599,43 +6647,1926 @@
3599 6647 $output['description'] = $response['description'];
3600 6648 $output['data'] = $result;
3601 6649 $output['success'] = $response['success'];
3602 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 +
3603 6671 wp_send_json( $output );
3604 6672 wp_die();
3605 6673 }
3606 6674
6675 + /**
6676 + * Batched template-JSON fetch for a full AI kit import.
6677 + *
6678 + * Mirrors wdkit_import_kit_template() but accepts every template of a kit at once and
6679 + * forwards their ids to the Laravel `import_template_batch` route in a single request,
6680 + * instead of one `import_template` round trip per template. The per-template
6681 + * `response`/`args`/`temp_data` shape returned for each item is identical to what
6682 + * wdkit_import_kit_template() returns for a single template, so the existing
6683 + * import_kit_temps()/import_page_section_content() insertion path on the JS/PHP side
6684 + * is unaffected — only the template-fetch round trips are collapsed.
6685 + *
6686 + * Any failure (network, missing route on an older/self-hosted backend, malformed
6687 + * response) returns success=false so the caller falls back to the existing
6688 + * one-call-per-template flow.
6689 + *
6690 + * @since 2.7.0
6691 + */
6692 + protected function wkit_import_kit_bundle() {
6693 +
6694 + if ( ! current_user_can( 'manage_options' ) ) {
6695 + return false;
6696 + }
6697 +
6698 + $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
6699 + $templates = ! empty( $_POST['template_ids'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_ids'] ) ), true ) : array();
6700 + $email = ! empty( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : '';
6701 + $editor = isset( $_POST['editor'] ) ? sanitize_text_field( wp_unslash( $_POST['editor'] ) ) : '';
6702 + $website_kit = isset( $_POST['website_kit'] ) ? sanitize_text_field( wp_unslash( $_POST['website_kit'] ) ) : '';
6703 + $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
6704 +
6705 + if ( empty( $templates ) || ! is_array( $templates ) ) {
6706 + wp_send_json(
6707 + array(
6708 + 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
6709 + 'description' => esc_html__( 'Invalid import: Check your details and try again.', 'wdesignkit' ),
6710 + 'success' => false,
6711 + )
6712 + );
6713 + wp_die();
6714 + }
6715 +
6716 + $ids = array();
6717 + foreach ( $templates as $temp ) {
6718 + if ( ! empty( $temp['id'] ) ) {
6719 + $ids[] = (int) $temp['id'];
6720 + }
6721 + }
6722 +
6723 + if ( empty( $ids ) ) {
6724 + wp_send_json(
6725 + array(
6726 + 'message' => esc_html__( 'Invalid import', 'wdesignkit' ),
6727 + 'success' => false,
6728 + )
6729 + );
6730 + wp_die();
6731 + }
6732 +
6733 + $token = $this->wdkit_login_user_token( $email );
6734 +
6735 + $batch_args = array(
6736 + 'token' => $token,
6737 + 'template_ids' => $ids,
6738 + 'website_kit' => $website_kit,
6739 + 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
6740 + );
6741 +
6742 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
6743 + $batch_args = wdkit_kit_import_with_site_identity( $batch_args );
6744 + }
6745 +
6746 + $response = WDesignKit_Data_Query::get_data( 'import_template_batch', $batch_args );
6747 +
6748 + if ( is_wp_error( $response ) || empty( $response['success'] ) ) {
6749 + wp_send_json(
6750 + array(
6751 + 'message' => is_wp_error( $response ) ? $response->get_error_message() : ( $response['message'] ?? esc_html__( 'Batch import failed', 'wdesignkit' ) ),
6752 + 'success' => false,
6753 + )
6754 + );
6755 + wp_die();
6756 + }
6757 +
6758 + $by_id = array();
6759 + foreach ( (array) $response['templates'] as $tpl_result ) {
6760 + if ( isset( $tpl_result['id'] ) ) {
6761 + $by_id[ (int) $tpl_result['id'] ] = $tpl_result;
6762 + }
6763 + }
6764 +
6765 + $results = array();
6766 + foreach ( $templates as $temp ) {
6767 + $tid = ! empty( $temp['id'] ) ? (int) $temp['id'] : 0;
6768 + $tpl_response = isset( $by_id[ $tid ] ) ? $by_id[ $tid ] : array(
6769 + 'success' => false,
6770 + 'message' => esc_html__( 'Template not found in batch response', 'wdesignkit' ),
6771 + );
6772 +
6773 + // import_temp_json() resolves `editor` per template from that template's own
6774 + // post_builder (Get_temp_builer()), not one value for the whole kit — a kit can
6775 + // mix builders. The client sends that already-resolved value as `_editor` on
6776 + // each item; fall back to the request-level $editor if it's missing.
6777 + $args = array(
6778 + 'editor' => ! empty( $temp['_editor'] ) ? sanitize_text_field( $temp['_editor'] ) : $editor,
6779 + 'custom_meta' => $custom_meta,
6780 + );
6781 +
6782 + $results[] = array(
6783 + 'id' => $tid,
6784 + 'success' => ! empty( $tpl_response['success'] ),
6785 + 'message' => $tpl_response['message'] ?? '',
6786 + 'data' => array(
6787 + 'response' => $tpl_response,
6788 + 'args' => $args,
6789 + 'id' => $tid,
6790 + 'temp_data' => $temp,
6791 + ),
6792 + );
6793 + }
6794 +
6795 + do_action( 'wdkit_template_imported', 'kit', sanitize_key( $builder ), count( $ids ) );
6796 +
6797 + wp_send_json(
6798 + array(
6799 + 'results' => $results,
6800 + 'success' => true,
6801 + )
6802 + );
6803 + wp_die();
6804 + }
6805 +
6806 + /**
6807 + * Hand the assembled site bundle to the client without importing it.
6808 + *
6809 + * The AI import path needs this: personalizing a template runs ~1,600 lines of browser
6810 + * logic (replace_elementor_txt and the media/global chain) against site_obj.site_info,
6811 + * which only exists in the browser. So an AI kit fetches the bundle, transforms every
6812 + * template with the same proven code the per-template path uses, and posts the result
6813 + * back to wkit_import_site_bundle(). A non-AI kit never calls this — it lets
6814 + * wkit_import_site_bundle() fetch and build in one request, with no payload leaving
6815 + * the server.
6816 + *
6817 + * @since 2.7.0
6818 + */
6819 + protected function wkit_fetch_site_bundle() {
6820 +
6821 + $t_fetch = microtime( true );
6822 + $bundle = $this->wdkit_fetch_site_bundle();
6823 +
6824 + if ( is_wp_error( $bundle ) ) {
6825 + wp_send_json(
6826 + array(
6827 + 'message' => $bundle->get_error_message(),
6828 + 'description' => $bundle->get_error_message(),
6829 + 'success' => false,
6830 + )
6831 + );
6832 + wp_die();
6833 + }
6834 +
6835 + // Widget manifest only. The non-AI path needs to know what to enable before it
6836 + // imports, but has no use for the content itself — sending a multi-megabyte kit down
6837 + // just to count widget names would cost more than the problem it solves. The bundle
6838 + // stays parked in the cache wdkit_fetch_site_bundle() just filled, so the import call
6839 + // that follows reuses it instead of assembling the kit a second time.
6840 + if ( ! empty( $_POST['widgets_only'] ) ) {
6841 + $manifest = self::wdkit_bundle_widget_manifest( is_array( $bundle ) ? $bundle : array() );
6842 +
6843 + wp_send_json(
6844 + array(
6845 + 'success' => true,
6846 + 'widgets' => $manifest['widgets'],
6847 + 'extensions' => $manifest['extensions'],
6848 + 'timing_ms' => array(
6849 + 'cloud_assemble' => isset( $bundle['cloud_assemble_ms'] ) ? (int) $bundle['cloud_assemble_ms'] : 0,
6850 + 'wp_total' => (int) round( ( microtime( true ) - $t_fetch ) * 1000 ),
6851 + ),
6852 + )
6853 + );
6854 + wp_die();
6855 + }
6856 +
6857 + if ( is_array( $bundle ) ) {
6858 + unset( $bundle['cache_key'] );
6859 +
6860 + $bundle['timing_ms'] = array(
6861 + 'cloud_assemble' => isset( $bundle['cloud_assemble_ms'] ) ? (int) $bundle['cloud_assemble_ms'] : 0,
6862 + 'wp_total' => (int) round( ( microtime( true ) - $t_fetch ) * 1000 ),
6863 + 'payload_kb' => (int) round( strlen( (string) wp_json_encode( $bundle ) ) / 1024 ),
6864 + );
6865 + }
6866 +
6867 + wp_send_json( $bundle );
6868 + wp_die();
6869 + }
6870 +
6871 + /**
6872 + * Ingest a pre-assembled kit site bundle in one request.
6873 + *
6874 + * The cloud half is GenerateKitSiteBundle(): it resolves every template in the kit,
6875 + * splits them into pages/sections, and pre-merges the kit's The Plus globals into one
6876 + * object. This side builds all of it inside a single PHP request, in place of the
6877 + * historical one-AJAX-call-per-template walk.
6878 + *
6879 + * Per-template insertion is NOT reimplemented here — wdkit_bundle_insert_item() below is
6880 + * a port of import_page_section_content(), the canonical shipped import path: Elementor
6881 + * templates go through \Elementor\Plugin::$instance->documents->create() plus
6882 + * $document->save() rather than hand-written meta, Gutenberg templates through the Nexter
6883 + * block processor, and images through wdkit_media_import() under the same $defer_media
6884 + * contract. All this method removes is the HTTP round trip that used to sit around each
6885 + * one; every legacy route and handler is left untouched.
6886 + *
6887 + * @since 2.7.0
6888 + */
6889 + protected function wkit_import_site_bundle() {
6890 + $response = $this->wdkit_import_site_bundle_data();
6891 +
6892 + wp_send_json( $response );
6893 + wp_die();
6894 + }
6895 +
6896 + /**
6897 + * The kit importer itself, reachable without a request.
6898 + *
6899 + * Extracted from the AJAX action above so the PHP import runner imports through THIS
6900 + * engine rather than carrying a second one.
6901 + *
6902 + * ── Why this extraction is the whole point of the merge ─────────────────────
6903 + *
6904 + * The runner grew its own content pipeline: one cloud round trip per template, its own
6905 + * media handling, its own page store writing `_elementor_data` by hand. Measured on a
6906 + * 15-template kit that came to 71-139s against ~8s here, and it also skipped things
6907 + * this path does - Elementor's own documents->create()/save(), unique element ids,
6908 + * server-side block CSS, the theme-builder remap, the nav menu build. Two pipelines
6909 + * meant two sets of behaviour to keep in step, and only one of them was fast.
6910 + *
6911 + * So the runner now hands its (optionally AI-merged) bundle straight to this method.
6912 + * The browser flow reaches the identical code through the action above, so "the fast
6913 + * flow" and "the runner" are the same importer, and speed cannot drift between them.
6914 + *
6915 + * @since 2.7.2
6916 + *
6917 + * @param array|null $request Request fields to read instead of `$_POST`. The runner
6918 + * passes its own array - normally carrying `bundle`
6919 + * directly, already fetched and AI-merged, which is the
6920 + * same entry tests and WP-CLI have always used.
6921 + * @return array The response the AJAX action emits.
6922 + */
6923 + public function wdkit_import_site_bundle_data( $request = null ) {
6924 +
6925 + // Elementor sideloads every image a page references from inside this request, and a
6926 + // single oversized source image decodes to more than the whole memory limit. Same
6927 + // guard import_page_section_content() takes, for the same reason.
6928 + $this->wdkit_guard_oversized_images();
6929 +
6930 + // Kit-level millisecond costs for this request, returned in the response.
6931 + $kit_timing = array();
6932 + $t_request = microtime( true );
6933 +
6934 + // A whole kit in one request, where import_page_section_content() handled one
6935 + // template in 120s. Harmless no-op where set_time_limit() is disabled.
6936 + if ( function_exists( 'set_time_limit' ) ) {
6937 + @set_time_limit( 300 );
6938 + }
6939 +
6940 + // Whether the fields came off the wire. Only then is the content slashed, and only
6941 + // then may it be unslashed: running wp_unslash() over a bundle a PHP caller built
6942 + // would strip the one level of escaping block markup depends on - the
6943 + // `var(--tpgb-C7)` global references - and every global colour on every
6944 + // page would silently stop resolving.
6945 + $from_request = ! is_array( $request );
6946 +
6947 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the AJAX router verified the nonce before dispatching; the runner supplies its own array.
6948 + $request = $from_request ? $_POST : $request;
6949 +
6950 + $raw_bundle = isset( $request['bundle'] ) ? $request['bundle'] : '';
6951 +
6952 + if ( $from_request ) {
6953 + $raw_bundle = wp_unslash( $raw_bundle );
6954 + }
6955 +
6956 + $bundle = is_array( $raw_bundle ) ? $raw_bundle : json_decode( $raw_bundle, true );
6957 +
6958 + // Normal path: the client sends only the kit's template ids and the bundle is fetched
6959 + // here. Sending it down to the browser and straight back up would double the transfer
6960 + // and put a multi-megabyte kit up against post_max_size on the return trip, so the
6961 + // assembled JSON never leaves the server. Passing `bundle` directly still works and is
6962 + // what the import can be driven with directly (tests, WP-CLI, other callers).
6963 + if ( ( empty( $bundle ) || ! is_array( $bundle ) ) && ! empty( $request['template_ids'] ) ) {
6964 + // Normally a cache hit: the widgets_only call immediately before this one already
6965 + // assembled the kit, so this reuses it rather than paying the cloud round trip
6966 + // again. A cold call (no preceding fetch, or the cache expired) still assembles.
6967 + $bundle = $this->wdkit_fetch_site_bundle( $request, isset( $request['token'] ) ? (string) $request['token'] : '' );
6968 +
6969 + // Error check FIRST. This used to sit below the cache-key cleanup, which indexes
6970 + // $bundle as an array - and on the failure path $bundle is a WP_Error object, so
6971 + // the cleanup fataled with "Cannot use object of type WP_Error as array" and took
6972 + // the whole chunk request down. That matters far beyond one lost error message:
6973 + // a chunk that dies never returns its `imported` entries, so the client never
6974 + // seeds post_ids with the demo-id -> local-id mapping, and update_section_id()
6975 + // then silently skips remapping every dynamic widget (tp-switcher, tp-tabs-tours,
6976 + // tp-accordion...). The final chunk dying also means wdkit_bundle_build_menu()
6977 + // never runs, so the kit imports with no navigation menu.
6978 + if ( is_wp_error( $bundle ) ) {
6979 + return array(
6980 + 'message' => $bundle->get_error_message(),
6981 + 'description' => $bundle->get_error_message(),
6982 + 'success' => false,
6983 + );
6984 + }
6985 +
6986 + // Consumed. Holding a whole kit in the options table past the import it was
6987 + // assembled for is dead weight, and a retry after a failure should get a fresh
6988 + // copy rather than whatever this attempt was handed.
6989 + if ( ! empty( $bundle['cache_key'] ) ) {
6990 + delete_transient( $bundle['cache_key'] );
6991 + }
6992 + }
6993 +
6994 + if ( empty( $bundle ) || ! is_array( $bundle ) ) {
6995 + return array(
6996 + 'message' => esc_html__( 'Invalid bundle', 'wdesignkit' ),
6997 + 'description' => esc_html__( 'Invalid bundle payload: nothing to import.', 'wdesignkit' ),
6998 + 'success' => false,
6999 + );
7000 + }
7001 +
7002 + $builder = ! empty( $bundle['builder'] ) ? sanitize_key( $bundle['builder'] ) : 'elementor';
7003 + $pages = ( ! empty( $bundle['pages'] ) && is_array( $bundle['pages'] ) ) ? $bundle['pages'] : array();
7004 + $sections = ( ! empty( $bundle['sections'] ) && is_array( $bundle['sections'] ) ) ? $bundle['sections'] : array();
7005 + $nav_menu = ( ! empty( $bundle['nav_menu'] ) && is_array( $bundle['nav_menu'] ) ) ? $bundle['nav_menu'] : array();
7006 + $defer_media = ! empty( $request['defer_media'] );
7007 + $custom_meta = ! empty( $request['custom_meta'] );
7008 + $wireframe = ! empty( $request['wirefram_import'] );
7009 +
7010 + // Chunked ingestion, used by the AI path: the browser transforms every template and
7011 + // posts them back a few at a time so a large kit never meets post_max_size in one
7012 + // POST. Each chunk carries the same `session`; the last one sets `finalize`. With no
7013 + // session at all (the non-AI single-call path) this behaves exactly as before —
7014 + // one request that imports and finalizes.
7015 + $session = isset( $request['session'] ) ? sanitize_key( $request['session'] ) : '';
7016 + $is_final = empty( $session ) || ! empty( $request['finalize'] );
7017 + $session_key = $session ? 'wdkit_sb_' . $session : '';
7018 + $carried = $session_key ? get_transient( $session_key ) : false;
7019 + $carried = is_array( $carried ) ? $carried : array();
7020 +
7021 + if ( empty( $pages ) && empty( $sections ) ) {
7022 + return array(
7023 + 'message' => esc_html__( 'Invalid bundle', 'wdesignkit' ),
7024 + 'description' => esc_html__( 'The bundle contained no templates to import.', 'wdesignkit' ),
7025 + 'success' => false,
7026 + );
7027 + }
7028 +
7029 + // ── 1. Kit globals, once for the whole kit ──────────────────────────────────────
7030 + // The cloud already merged every template's globals into this one object (same
7031 + // dedupe-by-_id rule wdkit_merge_tp_globals() applies), so the presets land before
7032 + // the first page renders instead of accumulating one template at a time.
7033 + $site_settings = ( ! empty( $bundle['site_settings'] ) && is_array( $bundle['site_settings'] ) ) ? $bundle['site_settings'] : array();
7034 +
7035 + // Only on the first chunk: the merge dedupes by `_id` so repeating it is harmless,
7036 + // but it rewrites kit meta and regenerates the kit stylesheet every time.
7037 + if ( empty( $carried ) && ! empty( $site_settings['tp_globals'] ) && is_array( $site_settings['tp_globals'] ) ) {
7038 + $t_globals = microtime( true );
7039 + $this->wdkit_merge_tp_globals(
7040 + $site_settings['tp_globals'],
7041 + ( ! empty( $site_settings['tp_global_refs'] ) && is_array( $site_settings['tp_global_refs'] ) )
7042 + ? $site_settings['tp_global_refs']
7043 + : array()
7044 + );
7045 + // This one also regenerates the kit stylesheet, so it is worth its own number.
7046 + $kit_timing['globals_merge'] = (int) round( ( microtime( true ) - $t_globals ) * 1000 );
7047 + }
7048 +
7049 + // ── 2. Sections first, then pages ───────────────────────────────────────────────
7050 + // Headers, footers and nav are what the pages reference, and a theme-builder
7051 + // condition attached to a section should exist before the first page renders.
7052 + $imported = array();
7053 + $errors = array();
7054 + $widgets_to_enable = array();
7055 + $deferred_pages = array();
7056 + $template_map = array();
7057 +
7058 + $groups = array(
7059 + 'section' => $sections,
7060 + 'page' => $pages,
7061 + );
7062 +
7063 + foreach ( $groups as $group => $items ) {
7064 + foreach ( $items as $item ) {
7065 + if ( ! is_array( $item ) ) {
7066 + continue;
7067 + }
7068 +
7069 + if ( $wireframe ) {
7070 + $item = self::wdkit_wireframe_bundle_item( $item );
7071 + }
7072 +
7073 + $result = $this->wdkit_bundle_insert_item( $item, $builder, $defer_media, $custom_meta );
7074 +
7075 + // One template failing (empty content, builder missing, insert error) is
7076 + // recorded and skipped — it must not cost the rest of the site.
7077 + if ( empty( $result['success'] ) ) {
7078 + $errors[] = array(
7079 + 'id' => isset( $item['id'] ) ? sanitize_text_field( (string) $item['id'] ) : '',
7080 + 'title' => isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '',
7081 + 'message' => ! empty( $result['message'] ) ? $result['message'] : esc_html__( 'Template could not be imported.', 'wdesignkit' ),
7082 + );
7083 + continue;
7084 + }
7085 +
7086 + // A template that stored fewer widgets than it arrived with imported "successfully"
7087 + // — the page exists and is linked — but its content is incomplete. Report it as
7088 + // an error as well as an import so the row fails loudly instead of the kit
7089 + // looking clean while a page renders empty. Deleting the page instead would
7090 + // trade a visibly broken page for a silently missing one.
7091 + if ( ! empty( $result['unregistered'] ) ) {
7092 + $errors[] = array(
7093 + 'id' => isset( $item['id'] ) ? sanitize_text_field( (string) $item['id'] ) : '',
7094 + 'title' => isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '',
7095 + 'message' => sprintf(
7096 + /* translators: %s: comma-separated list of widget type slugs. */
7097 + esc_html__( 'Imported without these widgets, which are not registered on this site: %s', 'wdesignkit' ),
7098 + implode( ', ', array_map( 'sanitize_text_field', $result['unregistered'] ) )
7099 + ),
7100 + );
7101 + }
7102 +
7103 + // The loader's progress rows are keyed by cloud template id, not post id.
7104 + $result['group'] = $group;
7105 + $result['template_id'] = isset( $item['id'] ) ? $item['id'] : '';
7106 +
7107 + // Sum each phase across every template in this chunk.
7108 + if ( ! empty( $result['timing_ms'] ) && is_array( $result['timing_ms'] ) ) {
7109 + foreach ( $result['timing_ms'] as $phase => $ms ) {
7110 + $key = 'sum_' . $phase;
7111 + $kit_timing[ $key ] = ( isset( $kit_timing[ $key ] ) ? $kit_timing[ $key ] : 0 ) + (int) $ms;
7112 + }
7113 + }
7114 + $imported[] = $result;
7115 +
7116 + if ( ! empty( $item['id'] ) ) {
7117 + $template_map[ (string) $item['id'] ] = $result['id'];
7118 + }
7119 +
7120 + if ( ! empty( $result['widget_list'] ) ) {
7121 + $widgets_to_enable = array_unique( array_merge( $widgets_to_enable, $result['widget_list'] ) );
7122 + }
7123 +
7124 + // Same payload wkit_schedule_deferred_media_sync() receives from the JS side.
7125 + // Built here because this request already knows every post id it created, so
7126 + // the kit needs no extra round trip to register its media work.
7127 + if ( $defer_media && ! empty( $result['image_urls'] ) ) {
7128 + $deferred_pages[] = array(
7129 + 'post_id' => $result['id'],
7130 + 'builder' => $result['editor'],
7131 + 'image_urls' => $result['image_urls'],
7132 + );
7133 + }
7134 + }
7135 + }
7136 +
7137 + // Fold in everything earlier chunks of this session already built, so the finalize
7138 + // steps below see the whole kit and not just this chunk.
7139 + if ( ! empty( $carried ) ) {
7140 + $imported = array_merge( isset( $carried['imported'] ) ? $carried['imported'] : array(), $imported );
7141 + $errors = array_merge( isset( $carried['errors'] ) ? $carried['errors'] : array(), $errors );
7142 + $widgets_to_enable = array_unique( array_merge( isset( $carried['widgets'] ) ? $carried['widgets'] : array(), $widgets_to_enable ) );
7143 + $deferred_pages = array_merge( isset( $carried['deferred'] ) ? $carried['deferred'] : array(), $deferred_pages );
7144 + // `+` and NOT array_merge(): template ids are numeric keys, and array_merge()
7145 + // renumbers those into 0,1,2... which silently destroys the id => post_id mapping
7146 + // the nav menu resolves against. Left operand wins, so this chunk's entries take
7147 + // precedence over an earlier chunk's.
7148 + $carried_map = ( isset( $carried['template_map'] ) && is_array( $carried['template_map'] ) ) ? $carried['template_map'] : array();
7149 + $template_map = $template_map + $carried_map;
7150 + }
7151 +
7152 + // Not the last chunk: bank what this one made and let the client send the next.
7153 + if ( ! $is_final ) {
7154 + set_transient(
7155 + $session_key,
7156 + array(
7157 + 'imported' => $imported,
7158 + 'errors' => $errors,
7159 + 'widgets' => array_values( $widgets_to_enable ),
7160 + 'deferred' => $deferred_pages,
7161 + 'template_map' => $template_map,
7162 + ),
7163 + 15 * MINUTE_IN_SECONDS
7164 + );
7165 +
7166 + return array(
7167 + 'message' => esc_html__( 'Bundle chunk imported.', 'wdesignkit' ),
7168 + 'imported' => $imported,
7169 + 'errors' => $errors,
7170 + 'partial' => true,
7171 + 'success' => true,
7172 + );
7173 + }
7174 +
7175 + if ( $session_key ) {
7176 + delete_transient( $session_key );
7177 + }
7178 +
7179 + if ( empty( $imported ) ) {
7180 + return array(
7181 + 'message' => esc_html__( 'Import failed', 'wdesignkit' ),
7182 + 'description' => esc_html__( 'No template in the bundle could be imported.', 'wdesignkit' ),
7183 + 'errors' => $errors,
7184 + 'success' => false,
7185 + );
7186 + }
7187 +
7188 + // ── 3. Front page and shop page ─────────────────────────────────────────────────
7189 + // An explicit flag from the bundle wins; the title heuristic is the same fallback
7190 + // wdkit_handle_create_full_site() and update_site_setting() already rely on.
7191 + $front_page_id = 0;
7192 + $shop_page_id = 0;
7193 +
7194 + foreach ( $imported as $entry ) {
7195 + if ( 'page' !== $entry['group'] ) {
7196 + continue;
7197 + }
7198 +
7199 + $title_lower = strtolower( (string) $entry['title'] );
7200 +
7201 + if ( ! $front_page_id
7202 + && ( ! empty( $entry['is_front_page'] )
7203 + || false !== strpos( $title_lower, 'home' )
7204 + || false !== strpos( $title_lower, 'landing' ) )
7205 + ) {
7206 + $front_page_id = $entry['id'];
7207 + }
7208 +
7209 + if ( ! $shop_page_id
7210 + && ( ! empty( $entry['is_shop_page'] )
7211 + || false !== strpos( $title_lower, 'shop' )
7212 + || false !== strpos( $title_lower, 'store' ) )
7213 + ) {
7214 + $shop_page_id = $entry['id'];
7215 + }
7216 + }
7217 +
7218 + if ( $front_page_id ) {
7219 + update_option( 'show_on_front', 'page' );
7220 + update_option( 'page_on_front', $front_page_id );
7221 + }
7222 +
7223 + if ( $shop_page_id && function_exists( 'wc_get_page_id' ) ) {
7224 + update_option( 'woocommerce_shop_page_id', $shop_page_id );
7225 + }
7226 +
7227 + // ── 4. Primary menu ─────────────────────────────────────────────────────────────
7228 + $t_menu = microtime( true );
7229 + $menu_id = $this->wdkit_bundle_build_menu( $nav_menu, $imported, $template_map );
7230 + $kit_timing['menu_build'] = (int) round( ( microtime( true ) - $t_menu ) * 1000 );
7231 +
7232 + // ── 4.5. Remap demo navigation links to actual site URLs ────────────────────────
7233 + $url_map = self::wdkit_build_nav_url_map( $imported );
7234 +
7235 + if ( ! empty( $url_map ) ) {
7236 + foreach ( $imported as $entry ) {
7237 + $pid = (int) $entry['id'];
7238 + $post = get_post( $pid );
7239 + if ( ! $post ) {
7240 + continue;
7241 + }
7242 +
7243 + $orig_content = $post->post_content;
7244 + $new_content = strtr( $orig_content, $url_map );
7245 + if ( $new_content !== $orig_content ) {
7246 + // Same reason as the deferred pass: on multisite, and on any host that
7247 + // defines DISALLOW_UNFILTERED_HTML, an administrator does not hold
7248 + // `unfiltered_html` either, so this admin-driven write would also lose
7249 + // <input>/<iframe>/<form> from content the import just produced.
7250 + self::wdkit_write_post_content( $pid, $new_content );
7251 +
7252 + // wdkit_bundle_insert_item() already built this page's CSS file once, right
7253 + // after its own wp_insert_post() - against the pre-remap content, since this
7254 + // step runs afterward, once every page in the kit exists and every other
7255 + // page's real permalink is known. The block hashes that CSS was keyed to no
7256 + // longer line up with what post_content now says (Gutenberg's own class
7257 + // names embed a hash of the block's attributes), so without this the page
7258 + // renders with whatever styling the two versions still happen to share and
7259 + // silently drops the rest - the same class of bug the deferred-media cron
7260 + // callback was fixed for earlier.
7261 + self::wdkit_rebuild_block_css( $pid );
7262 + }
7263 +
7264 + $elem_data = get_post_meta( $pid, '_elementor_data', true );
7265 + if ( ! empty( $elem_data ) && is_string( $elem_data ) ) {
7266 + $new_elem = strtr( $elem_data, $url_map );
7267 + if ( $new_elem !== $elem_data ) {
7268 + // get_post_meta() hands back the unslashed JSON, and
7269 + // update_post_meta() unslashes again on the way in - so writing it
7270 + // straight back corrupts it. Same reason as the deferred cron's
7271 + // write above.
7272 + update_post_meta( $pid, '_elementor_data', wp_slash( $new_elem ) );
7273 + }
7274 + }
7275 + }
7276 + }
7277 +
7278 + // ── 5. Every widget the kit uses, enabled in one call ───────────────────────────
7279 + if ( ! empty( $widgets_to_enable ) && has_filter( 'tpae_enable_selected_widgets' ) ) {
7280 + apply_filters(
7281 + 'tpae_enable_selected_widgets',
7282 + array(
7283 + 'widgets' => array_values( $widgets_to_enable ),
7284 + 'extensions' => array(),
7285 + )
7286 + );
7287 + }
7288 +
7289 + // ── 6. Deferred image sideloading, off this request ─────────────────────────────
7290 + $scheduled = 0;
7291 + $stagger = 0;
7292 +
7293 + foreach ( $deferred_pages as $deferred ) {
7294 + // Stagger so every page's sideload loop does not fire in one wp-cron tick —
7295 + // see the identical stagger in wkit_schedule_deferred_media_sync().
7296 + wp_schedule_single_event(
7297 + time() + 2 + $stagger,
7298 + 'wdkit_async_sideload_page_images',
7299 + array( $deferred['post_id'], $deferred['builder'], $deferred['image_urls'] )
7300 + );
7301 +
7302 + $stagger += 2;
7303 + ++$scheduled;
7304 + }
7305 +
7306 + // ── 7. One cache clear for the kit, not one per page ────────────────────────────
7307 + $t_clear = microtime( true );
7308 + if ( did_action( 'elementor/loaded' ) ) {
7309 + \Elementor\Plugin::$instance->files_manager->clear_cache();
7310 + }
7311 +
7312 + if ( class_exists( 'Tpgb_Library' ) && method_exists( 'Tpgb_Library', 'remove_backend_dir_files' ) ) {
7313 + Tpgb_Library()->remove_backend_dir_files();
7314 + }
7315 + $kit_timing['cache_clear'] = (int) round( ( microtime( true ) - $t_clear ) * 1000 );
7316 +
7317 + do_action( 'wdkit_template_imported', 'kit', sanitize_key( $builder ), count( $imported ) );
7318 +
7319 + if ( isset( $bundle['cloud_assemble_ms'] ) ) {
7320 + $kit_timing['cloud_assemble'] = (int) $bundle['cloud_assemble_ms'];
7321 + }
7322 + $kit_timing['templates'] = count( $imported );
7323 + $kit_timing['request_total'] = (int) round( ( microtime( true ) - $t_request ) * 1000 );
7324 + $kit_timing['peak_memory_mb'] = round( memory_get_peak_usage( true ) / 1048576, 1 );
7325 +
7326 + // Also to the log when debugging, so a staging run leaves a trace even if nobody had
7327 + // the network tab open.
7328 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7329 + error_log( 'WDKIT site bundle timing: ' . wp_json_encode( $kit_timing ) );
7330 + }
7331 +
7332 + return array(
7333 + 'message' => esc_html__( 'Successfully Imported.', 'wdesignkit' ),
7334 + 'description' => esc_html__( 'Yay! Your site has been successfully imported.', 'wdesignkit' ),
7335 + 'timing_ms' => $kit_timing,
7336 + 'imported' => $imported,
7337 + 'errors' => $errors,
7338 + 'front_page_id' => $front_page_id,
7339 + 'shop_page_id' => $shop_page_id,
7340 + 'menu_id' => $menu_id,
7341 + 'media_scheduled' => $scheduled,
7342 + 'site_link' => get_site_url(),
7343 + 'success' => true,
7344 + );
7345 + }
7346 +
7347 + /**
7348 + * Fetch the assembled site bundle for this kit from the cloud.
7349 + *
7350 + * Mirrors wkit_import_kit_bundle()'s argument handling: the token is resolved from the
7351 + * stored cloud session for $email, never taken from the request. Returns the decoded
7352 + * bundle, or a WP_Error the caller reports so the client can fall back to the legacy
7353 + * per-template path (an older or self-hosted backend has no v2 route at all).
7354 + *
7355 + * @since 2.7.0
7356 + *
7357 + * @param array|null $request Request fields to read instead of `$_POST`. The AJAX
7358 + * actions pass nothing and this behaves exactly as before;
7359 + * the PHP import runner passes its own array, because it
7360 + * runs on cron where there is no request to read. Same
7361 + * shape either way, except `template_ids` may already be an
7362 + * array rather than a JSON string.
7363 + * @param string $token Cloud token, for callers that already resolved one. Only
7364 + * ever supplied in PHP: a token is never read out of
7365 + * `$request`, so a browser request cannot present its own.
7366 + * @return array|WP_Error
7367 + */
7368 + private function wdkit_fetch_site_bundle( $request = null, $token = '' ) {
7369 +
7370 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the AJAX router verified the nonce before dispatching; the runner supplies its own array.
7371 + $request = is_array( $request ) ? $request : $_POST;
7372 +
7373 + $raw_templates = array();
7374 +
7375 + if ( ! empty( $request['template_ids'] ) ) {
7376 + $raw_templates = is_array( $request['template_ids'] )
7377 + ? $request['template_ids']
7378 + : json_decode( wp_unslash( $request['template_ids'] ), true );
7379 + }
7380 +
7381 + if ( empty( $raw_templates ) || ! is_array( $raw_templates ) ) {
7382 + return new WP_Error( 'wdkit_bundle_no_templates', esc_html__( 'No templates supplied for the site bundle.', 'wdesignkit' ) );
7383 + }
7384 +
7385 + // Sanitize AFTER decoding — running sanitize_text_field() over the raw JSON strips
7386 + // `<` and `>` and corrupts any template whose title contains them.
7387 + $templates = array();
7388 + foreach ( $raw_templates as $raw ) {
7389 + if ( is_array( $raw ) ) {
7390 + $id = ! empty( $raw['id'] ) ? (int) $raw['id'] : 0;
7391 +
7392 + if ( ! $id ) {
7393 + continue;
7394 + }
7395 +
7396 + $templates[] = array(
7397 + 'id' => $id,
7398 + 'title' => isset( $raw['title'] ) ? sanitize_text_field( $raw['title'] ) : '',
7399 + 'wp_post_type' => isset( $raw['wp_post_type'] ) ? sanitize_key( $raw['wp_post_type'] ) : 'page',
7400 + '_editor' => isset( $raw['_editor'] ) ? sanitize_text_field( $raw['_editor'] ) : '',
7401 + );
7402 + } elseif ( (int) $raw ) {
7403 + $templates[] = array( 'id' => (int) $raw );
7404 + }
7405 + }
7406 +
7407 + if ( empty( $templates ) ) {
7408 + return new WP_Error( 'wdkit_bundle_no_templates', esc_html__( 'No usable templates supplied for the site bundle.', 'wdesignkit' ) );
7409 + }
7410 +
7411 + $email = ! empty( $request['email'] ) ? strtolower( sanitize_email( wp_unslash( $request['email'] ) ) ) : '';
7412 +
7413 + $args = array(
7414 + 'token' => ( is_string( $token ) && '' !== $token ) ? $token : $this->wdkit_login_user_token( $email ),
7415 + 'template_ids' => $templates,
7416 + 'website_kit' => isset( $request['website_kit'] ) ? sanitize_text_field( wp_unslash( $request['website_kit'] ) ) : '',
7417 + 'editor' => isset( $request['editor'] ) ? sanitize_text_field( wp_unslash( $request['editor'] ) ) : '',
7418 + 'builder' => isset( $request['builder'] ) ? sanitize_text_field( wp_unslash( $request['builder'] ) ) : '',
7419 + // get_option() returns false when unset, which `?? ''` does not catch.
7420 + 'unique_id' => get_option( 'wdkit_unique_id', '' ),
7421 + );
7422 +
7423 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
7424 + $args = wdkit_kit_import_with_site_identity( $args );
7425 + }
7426 +
7427 + // Assembling a whole kit reads every template's file server-side, so this needs more
7428 + // than get_data()'s 60s default.
7429 + $t_cloud = microtime( true );
7430 +
7431 + // The non-AI path asks for this bundle twice in a row - once to learn which widgets
7432 + // the kit needs (so they can be enabled in their own request, because a widget
7433 + // enabled mid-request is not registered until the next one), then again to import
7434 + // it. Assembling it twice would pay the whole cloud round trip twice, so the first
7435 + // call parks it here for the second. Keyed on the request that produced it, minus
7436 + // the credentials, so a different kit or a different template set never reads this.
7437 + $cache_key = 'wdkit_bundle_' . md5(
7438 + (string) wp_json_encode(
7439 + array_diff_key( $args, array( 'token' => '', 'poll_token' => '', 'site_url' => '' ) )
7440 + )
7441 + );
7442 + $cached = get_transient( $cache_key );
7443 +
7444 + if ( is_array( $cached ) && ! empty( $cached['success'] ) ) {
7445 + $cached['cloud_assemble_ms'] = 0;
7446 + $cached['from_cache'] = true;
7447 + $cached['cache_key'] = $cache_key;
7448 +
7449 + return $cached;
7450 + }
7451 +
7452 + $response = WDesignKit_Data_Query::get_data( 'v2/generate_kit_site_bundle', $args, array(), 180 );
7453 + $cloud_ms = (int) round( ( microtime( true ) - $t_cloud ) * 1000 );
7454 +
7455 + // The cloud assembly is its own round trip - credit checks, disk reads and tracking
7456 + // for every template in the kit - so it gets its own number rather than hiding inside
7457 + // whatever the caller measures.
7458 + if ( is_array( $response ) ) {
7459 + $response['cloud_assemble_ms'] = $cloud_ms;
7460 + }
7461 +
7462 + // Thirty minutes, not ten.
7463 + //
7464 + // Ten was sized for the browser's two-call sequence, where the gap is seconds. The
7465 + // runner reads this copy on every stage slice instead, so the window it has to
7466 + // survive is the WHOLE import — and an import is not always the ~1 minute it takes on
7467 + // a healthy host. A slow host, a retry, or a person who stops to answer the door
7468 + // pushes it past ten minutes, and then the cache silently expires mid-run: the next
7469 + // slice re-assembles the entire kit in the cloud, which is the one round trip the
7470 + // bundle exists to avoid, and it does it again on the slice after that.
7471 + //
7472 + // Still short enough that a kit edited upstream is not served stale for long, and
7473 + // wkit_import_site_bundle() deletes it as soon as it has consumed it, so the ceiling
7474 + // is only ever reached by a run that never finished.
7475 + // Only the widgets_only call parks a copy. The AI path fetches the whole bundle and
7476 + // posts the personalized content back, so it never re-reads this — caching there
7477 + // would write a megabyte per import that nothing ever looks at.
7478 + // The runner asks for the same parking, for the same reason: a stage request that
7479 + // runs out of time hands the rest of the kit to the next one, and every one of
7480 + // those must read this copy rather than re-assembling the kit in the cloud.
7481 + if ( is_array( $response ) && ! empty( $response['success'] ) && ! empty( $request['widgets_only'] ) ) {
7482 + set_transient( $cache_key, $response, 30 * MINUTE_IN_SECONDS );
7483 +
7484 + $response['cache_key'] = $cache_key;
7485 + }
7486 +
7487 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7488 + error_log( 'WDKIT bundle cloud assemble: ' . $cloud_ms . 'ms' );
7489 + }
7490 +
7491 + if ( is_wp_error( $response ) ) {
7492 + return $response;
7493 + }
7494 +
7495 + if ( empty( $response ) || ! is_array( $response ) || empty( $response['success'] ) ) {
7496 + // Diagnostic: the client silently falls back to the slower per-template path when
7497 + // this fails, so without this the only symptom is "the import behaved differently
7498 + // this time". Log what the cloud actually said.
7499 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
7500 + error_log(
7501 + 'WDKIT bundle assemble FAILED: type=' . gettype( $response )
7502 + . ' keys=' . ( is_array( $response ) ? implode( ',', array_keys( $response ) ) : '-' )
7503 + . ' body=' . substr( (string) wp_json_encode( $response ), 0, 700 )
7504 + );
7505 + }
7506 +
7507 + return new WP_Error(
7508 + 'wdkit_bundle_failed',
7509 + ! empty( $response['message'] )
7510 + ? $response['message']
7511 + : esc_html__( 'Could not assemble the site bundle.', 'wdesignkit' )
7512 + );
7513 + }
7514 +
7515 + return $response;
7516 + }
7517 +
7518 + /**
7519 + * Insert one bundle template — page or section.
7520 + *
7521 + * A port of import_page_section_content()'s two editor branches with the AJAX plumbing
7522 + * stripped out: same Elementor document creation, same media import, same block
7523 + * processing, same custom-meta restore. It returns a result array instead of calling
7524 + * wp_send_json(), so the caller can record one bad template and carry on with the kit.
7525 + *
7526 + * @since 2.7.0
7527 + *
7528 + * @param array $item One entry from the bundle's pages/sections list.
7529 + * @param string $builder Kit-level builder, used when the item names no editor.
7530 + * @param bool $defer_media Sideload this template's images later, off this request.
7531 + * @param bool $custom_meta Restore the template's nxt-* post meta.
7532 + * @return array Result array; `success` false carries a `message`.
7533 + */
7534 + /**
7535 + * The widget and extension names an assembled bundle needs enabled.
7536 + *
7537 + * The server-side twin of the browser's collect_widget_list(): same extension key map, and
7538 + * the same union across every template in the kit. It exists because the non-AI path never
7539 + * routes template content through the browser, so nothing there can walk it.
7540 + *
7541 + * Extensions are matched on the settings key being present rather than truthy. Over-
7542 + * collecting is harmless — the addon ignores any name outside its own catalogue, and an
7543 + * extension enabled but unused costs nothing — whereas under-collecting silently loses the
7544 + * styling it drives.
7545 + *
7546 + * @since 2.7.0
7547 + *
7548 + * @param array $bundle Assembled bundle.
7549 + * @return array{widgets: array, extensions: array}
7550 + */
7551 + private static function wdkit_bundle_widget_manifest( array $bundle ) {
7552 + $extension_keys = array(
7553 + 'sc_link_switch' => 'plus_section_column_link',
7554 + 'scwbf_options' => 'plus_glass_morphism',
7555 + 'seh_switch' => 'plus_equal_height',
7556 + );
7557 +
7558 + $widgets = array();
7559 + $extensions = array();
7560 +
7561 + foreach ( array( 'sections', 'pages' ) as $group ) {
7562 + $items = ( ! empty( $bundle[ $group ] ) && is_array( $bundle[ $group ] ) ) ? $bundle[ $group ] : array();
7563 +
7564 + foreach ( $items as $item ) {
7565 + if ( ! is_array( $item ) || ! isset( $item['content'] ) ) {
7566 + continue;
7567 + }
7568 +
7569 + $json = is_string( $item['content'] ) ? $item['content'] : (string) wp_json_encode( $item['content'] );
7570 +
7571 + $widgets = array_merge( $widgets, self::wdkit_widget_types_in( $json ) );
7572 +
7573 + foreach ( $extension_keys as $key => $extension ) {
7574 + if ( false !== strpos( $json, '"' . $key . '"' ) ) {
7575 + $extensions[] = $extension;
7576 + }
7577 + }
7578 + }
7579 + }
7580 +
7581 + return array(
7582 + 'widgets' => array_values( array_unique( $widgets ) ),
7583 + 'extensions' => array_values( array_unique( $extensions ) ),
7584 + );
7585 + }
7586 +
7587 + /**
7588 + * Which of $types the addon has not actually recorded as enabled.
7589 + *
7590 + * This deliberately reads the persisted enable list rather than the live widget registry,
7591 + * because within the enable request itself the registry is always stale: TPAE registers on
7592 + * `elementor/widgets/register` (priority 100) from whatever `check_elements` held at that
7593 + * moment, so a widget enabled later in the same request cannot appear until the NEXT one.
7594 + * Measured against the real endpoint: first call reports the just-enabled widgets missing,
7595 + * an identical second call reports none. Verifying against the registry here would
7596 + * therefore fail every healthy import and trigger a pointless retry every time; the
7597 + * persisted list is what the next request will build the registry from, so it is the
7598 + * honest answer to "did the enable take".
7599 + *
7600 + * The registry check still matters — it just belongs in the request that saves, which is a
7601 + * later request. See wdkit_unregistered_widget_types().
7602 + *
7603 + * @since 2.7.0
7604 + *
7605 + * @param array $types Widget type slugs as the client sends them (`tp-post-title`).
7606 + * @return array The subset not recorded as enabled, in the same slug form.
7607 + */
7608 + private static function wdkit_not_enabled_widget_types( array $types ) {
7609 + $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7610 +
7611 + if ( empty( $types ) ) {
7612 + return array();
7613 + }
7614 +
7615 + $options = get_option( 'theplus_options' );
7616 + $enabled = ( is_array( $options ) && ! empty( $options['check_elements'] ) && is_array( $options['check_elements'] ) )
7617 + ? $options['check_elements']
7618 + : null;
7619 +
7620 + // Unverifiable — the addon's settings row does not exist yet, which is itself a state
7621 + // where the enable cannot have worked (tpae_enable_selected_widgets() writes nothing
7622 + // when that option is empty, and still reports success). Report everything as not
7623 + // enabled so the caller retries rather than trusting a success it cannot confirm.
7624 + if ( null === $enabled ) {
7625 + return $types;
7626 + }
7627 +
7628 + // The caller sends every widget a template uses, core Elementor ones included, and the
7629 + // addon quietly ignores anything outside its own catalogue — so `heading`, `image` and
7630 + // `divider` are never going to appear in its enabled list no matter how many times we
7631 + // ask. Scoping to the catalogue is what keeps this from reporting a permanent failure
7632 + // on every healthy import, which would make the retry fire every time.
7633 + $managed = null;
7634 +
7635 + // has_filter() first: it is only true once the addon has constructed its hooks
7636 + // singleton, so get_instance() below is guaranteed to hand back the existing object
7637 + // rather than build one — asking a question must not register another plugin's hooks.
7638 + if ( has_filter( 'tpae_enable_selected_widgets' )
7639 + && class_exists( 'Tpae_Hooks' )
7640 + && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
7641 + $hooks = Tpae_Hooks::get_instance();
7642 +
7643 + if ( isset( $hooks->all_widgets ) && is_array( $hooks->all_widgets ) ) {
7644 + $managed = $hooks->all_widgets;
7645 + }
7646 + }
7647 +
7648 + // No catalogue to scope against. The settings row exists, so the enable had somewhere
7649 + // to write; treat that as verified rather than inventing a failure we cannot support.
7650 + // The save-time guard still catches anything that really did not register.
7651 + if ( null === $managed ) {
7652 + return array();
7653 + }
7654 +
7655 + $missing = array();
7656 +
7657 + foreach ( $types as $type ) {
7658 + // Same normalisation the addon applies before storing (`tp-post-title` -> `tp_post_title`).
7659 + $slug = str_replace( '-', '_', $type );
7660 +
7661 + if ( in_array( $slug, $managed, true ) && ! in_array( $slug, $enabled, true ) ) {
7662 + $missing[] = $type;
7663 + }
7664 + }
7665 +
7666 + return $missing;
7667 + }
7668 +
7669 + /**
7670 + * Every widgetType named anywhere in a payload, whatever shape it arrives in.
7671 + *
7672 + * Scans the serialized form rather than walking the tree so it cannot drift from the
7673 + * nesting rules Elementor actually uses (containers, inner sections, nested repeaters).
7674 + *
7675 + * @since 2.7.0
7676 + *
7677 + * @param mixed $payload Array, object or JSON string.
7678 + * @return array Unique widget type slugs.
7679 + */
7680 + /**
7681 + * Drop the redundant "Page" from an imported page's title.
7682 + *
7683 + * Catalog titles are written for browsing a list of kits ("About Us Page | Taj Bakery"),
7684 + * where the word does useful work. On the site itself every entry in Pages is a page, so it
7685 + * is noise on every row - and it rides along into the fallback slug too.
7686 + *
7687 + * Only stripped when it is a standalone word sitting immediately before the kit separator
7688 + * or at the end of the title, so a kit or page whose name legitimately contains the word
7689 + * ("Page Builder | …", "Landing Pages Bundle") keeps it. Pages only: a theme-builder
7690 + * template called "Blog Single Page" is describing what it is, not repeating itself, and
7691 + * those live in a different list entirely.
7692 + *
7693 + * @since 2.7.1
7694 + *
7695 + * @param string $title Catalog title.
7696 + * @param string $post_type Destination post type.
7697 + * @return string Title to save.
7698 + */
7699 + private static function wdkit_clean_page_title( $title, $post_type ) {
7700 +
7701 + if ( 'page' !== $post_type || '' === (string) $title ) {
7702 + return $title;
7703 + }
7704 +
7705 + $clean = preg_replace( '/\s*\bPages?\b(?=\s*(?:\||$))/i', '', (string) $title );
7706 + $clean = trim( preg_replace( '/\s+/', ' ', (string) $clean ) );
7707 +
7708 + // A title that was only ever the word itself ("Page", "Page | Kit") would come back
7709 + // empty or orphaned from its separator; leave those exactly as the catalog wrote them.
7710 + if ( '' === $clean || 0 === strpos( $clean, '|' ) ) {
7711 + return $title;
7712 + }
7713 +
7714 + /* Drop the kit's name: "About Us | Zion Technology" -> "About Us".
7715 + *
7716 + * Catalog titles are "<page> | <kit>", and the kit half is the demo brand. Keeping it
7717 + * named every imported page after someone else's business — it showed in the browser
7718 + * tab and in the theme's page heading ("About Us | Zion Technology" on a freight
7719 + * company's site). Both other ways a page can be created already cut here: the
7720 + * browser's import_new_temp() does `title.split("|")[0]`, and
7721 + * Wdkit_Page_Importer::store() does `explode( '|', … )[0]`. This path — the one-request
7722 + * bundle insert, which is the one the runner actually uses — was the only one that
7723 + * did not. */
7724 + $parts = explode( '|', $clean );
7725 + $name = trim( $parts[0] );
7726 +
7727 + return '' !== $name ? $name : $clean;
7728 + }
7729 +
7730 + /**
7731 + * Point a header's logo at the home page when the template shipped it unlinked.
7732 + *
7733 + * Kits are inconsistent here: some ship the logo image with `link_to: custom` and the
7734 + * authoring site's home URL (which the nav remap then rewrites to this site's), and some
7735 + * ship it with no link control at all. Elementor's image widget defaults to `link_to: none`,
7736 + * so the second kind renders a bare <img> and the logo is not clickable - which is not a
7737 + * choice any of these templates meant to make.
7738 + *
7739 + * Deliberately narrow, because "which image is the logo" is a guess everywhere else:
7740 + *
7741 + * - headers only, identified by the theme-builder section meta rather than the title, which
7742 + * is translated and kit-specific;
7743 + * - the core `image` widget only;
7744 + * - and only when the widget has no link at all. A template that set one - to home or
7745 + * anywhere else - is left exactly as its author wrote it.
7746 + *
7747 + * @since 2.7.1
7748 + *
7749 + * @param mixed $elements Elementor `elements` data.
7750 + * @param mixed $custom_meta The template's custom_meta (meta_key => array(value)).
7751 + * @return mixed Elements, with the logo linked where it was not.
7752 + */
7753 + private static function wdkit_link_header_logo( $elements, $custom_meta ) {
7754 +
7755 + $meta = is_object( $custom_meta ) ? get_object_vars( $custom_meta ) : $custom_meta;
7756 +
7757 + if ( ! is_array( $meta ) || empty( $meta['nxt-hooks-layout-sections'] ) ) {
7758 + return $elements;
7759 + }
7760 +
7761 + $section = $meta['nxt-hooks-layout-sections'];
7762 + $section = is_array( $section ) ? reset( $section ) : $section;
7763 +
7764 + if ( 'header' !== $section ) {
7765 + return $elements;
7766 + }
7767 +
7768 + $home = home_url( '/' );
7769 +
7770 + $walk = function ( $node ) use ( &$walk, $home ) {
7771 + if ( ! is_array( $node ) ) {
7772 + return $node;
7773 + }
7774 +
7775 + if ( isset( $node['elType'], $node['widgetType'] )
7776 + && 'widget' === $node['elType'] && 'image' === $node['widgetType'] ) {
7777 +
7778 + $settings = isset( $node['settings'] ) && is_array( $node['settings'] ) ? $node['settings'] : array();
7779 + $link_to = isset( $settings['link_to'] ) ? (string) $settings['link_to'] : '';
7780 + $link_url = isset( $settings['link']['url'] ) ? (string) $settings['link']['url'] : '';
7781 +
7782 + if ( '' === $link_url && ( '' === $link_to || 'none' === $link_to ) ) {
7783 + $settings['link_to'] = 'custom';
7784 + $settings['link'] = array(
7785 + 'url' => $home,
7786 + 'is_external' => '',
7787 + 'nofollow' => '',
7788 + 'custom_attributes' => '',
7789 + );
7790 +
7791 + $node['settings'] = $settings;
7792 + }
7793 + }
7794 +
7795 + if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
7796 + foreach ( $node['elements'] as $i => $child ) {
7797 + $node['elements'][ $i ] = $walk( $child );
7798 + }
7799 + }
7800 +
7801 + return $node;
7802 + };
7803 +
7804 + if ( ! is_array( $elements ) ) {
7805 + return $elements;
7806 + }
7807 +
7808 + foreach ( $elements as $i => $node ) {
7809 + $elements[ $i ] = $walk( $node );
7810 + }
7811 +
7812 + return $elements;
7813 + }
7814 +
7815 + private static function wdkit_widget_types_in( $payload ) {
7816 + $json = is_string( $payload ) ? $payload : (string) wp_json_encode( $payload );
7817 +
7818 + if ( ! is_string( $json ) || ! preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $json, $matches ) ) {
7819 + return array();
7820 + }
7821 +
7822 + return array_values( array_unique( $matches[1] ) );
7823 + }
7824 +
7825 + /**
7826 + * Which of $types Elementor cannot resolve right now.
7827 + *
7828 + * This is the check that makes an unregistered widget visible. Document::save() walks
7829 + * every node through Elements_Manager::create_element_instance(), which returns null for a
7830 + * widgetType the widgets manager does not know, and get_elements_raw_data() then
7831 + * `continue`s past it — the container survives, the widget is dropped, and the save still
7832 + * reports success. TPAE documents the same behaviour from the other side (see
7833 + * theplus-include-widgets.php: "without a registered type Elementor deletes their saved
7834 + * nodes on the next save"). So an unregistered type is silent data loss, not an error, and
7835 + * nothing downstream will ever tell us it happened.
7836 + *
7837 + * @since 2.7.0
7838 + *
7839 + * @param array $types Widget type slugs.
7840 + * @return array The subset that has no registered type.
7841 + */
7842 + private static function wdkit_unregistered_widget_types( array $types ) {
7843 + $types = array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
7844 +
7845 + if ( empty( $types ) ) {
7846 + return array();
7847 + }
7848 +
7849 + // Nothing can be resolved without the widgets manager, so everything is missing —
7850 + // which is the honest answer, and the one that stops a stripped save going out quietly.
7851 + if ( ! did_action( 'elementor/loaded' ) || ! isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
7852 + return $types;
7853 + }
7854 +
7855 + $known = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
7856 + $known = is_array( $known ) ? array_keys( $known ) : array();
7857 +
7858 + return array_values( array_diff( $types, $known ) );
7859 + }
7860 +
7861 + /**
7862 + * Make sure the media importer class is loaded before it is used.
7863 + *
7864 + * The class was only required deep inside the two media walks, so any call reached before
7865 + * one of those ran hit a fatal - "Class \"Wdkit_Import_Images\" not found". The per-item
7866 + * deferred-list clear at the top of wdkit_bundle_insert_item() is exactly such a call: it
7867 + * runs before any walk, by design, so that per-item isolation does not depend on which
7868 + * branch happens to execute.
7869 + *
7870 + * @since 2.7.1
7871 + *
7872 + * @return void
7873 + */
7874 + private static function wdkit_require_import_images() {
7875 + if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
7876 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
7877 + }
7878 + }
7879 +
7880 + /**
7881 + * The four placeholder shapes a wireframe import swaps images for.
7882 + *
7883 + * Same files the browser uses, so a wireframe site looks identical whichever route
7884 + * built it.
7885 + *
7886 + * @since 2.7.1
7887 + *
7888 + * @return array<string,string> ratio-name => absolute url.
7889 + */
7890 + private static function wdkit_wireframe_placeholders() {
7891 +
7892 + $base = WDKIT_SERVER_API_URL . 'images/v2/plugin/template/';
7893 +
7894 + return array(
7895 + 'hero' => $base . 'hero-placeholder.png',
7896 + 'long' => $base . 'long-placeholder.png',
7897 + 'square' => $base . 'square-placeholder.png',
7898 + 'vertical' => $base . 'vertical-placeholder.png',
7899 + );
7900 + }
7901 +
7902 + /**
7903 + * Swap a bundle item's images for wireframe placeholders, server-side.
7904 + *
7905 + * "Import as Wireframe" was only ever honoured on routes that hand the template content
7906 + * to the browser - the AI path, and the legacy per-template path. A dummy import takes
7907 + * the one-request route instead (see import_site_bundle() in import_loader.js), where the
7908 + * server fetches and builds the whole kit and the content never reaches the browser at
7909 + * all, so the client-side swap could not run and the toggle silently did nothing.
7910 + *
7911 + * It looked intermittent because the fast route only wins when it can: on a site whose
7912 + * plugins were just installed it bails and the legacy path takes over, which DOES swap.
7913 + * So wireframe worked on a fresh site and stopped working on a warm one - reproduced by
7914 + * importing a kit with AI first, then re-importing it as dummy with wireframe on.
7915 + *
7916 + * Shape is chosen from the WordPress size suffix when the URL carries one
7917 + * (`-700x700` -> square); everything else gets the hero 4:3, matching what the browser
7918 + * falls back to when it cannot measure a source image. SVGs are deliberately untouched:
7919 + * the browser's swap skips them too, so icons and logos survive on both routes.
7920 + *
7921 + * @since 2.7.1
7922 + *
7923 + * @param array $item Bundle item, whose `content` holds the template payload.
7924 + * @return array The item with its raster image URLs pointed at placeholders.
7925 + */
7926 + private static function wdkit_wireframe_bundle_item( $item ) {
7927 +
7928 + if ( empty( $item['content'] ) ) {
7929 + return $item;
7930 + }
7931 +
7932 + $is_string = is_string( $item['content'] );
7933 + $raw = $is_string ? $item['content'] : wp_json_encode( $item['content'] );
7934 +
7935 + if ( ! is_string( $raw ) || '' === $raw ) {
7936 + return $item;
7937 + }
7938 +
7939 + $shapes = self::wdkit_wireframe_placeholders();
7940 +
7941 + // Both spellings, because a url sits raw in markup and slash-escaped inside the
7942 + // builder's JSON - the same pairing wdkit_replace_url_all_encodings() handles.
7943 + $flat = str_replace( '\\/', '/', $raw );
7944 +
7945 + if ( ! preg_match_all( '#https?://[^"\'\s<>()\\\\]+\.(?:jpe?g|png|gif|webp|avif)#i', $flat, $matches ) ) {
7946 + return $item;
7947 + }
7948 +
7949 + $map = array();
7950 +
7951 + foreach ( array_unique( $matches[0] ) as $url ) {
7952 + // Already a placeholder (a re-import of an already-wireframed kit) - leave it.
7953 + if ( preg_match( '/(hero|long|square|vertical)-placeholder/', $url ) ) {
7954 + continue;
7955 + }
7956 +
7957 + $shape = 'hero';
7958 +
7959 + if ( preg_match( '/-(\d+)x(\d+)\.[A-Za-z0-9]+$/', $url, $dim ) ) {
7960 + $w = (int) $dim[1];
7961 + $h = (int) $dim[2];
7962 +
7963 + if ( $w > 0 && $h > 0 ) {
7964 + $ratio = $w / $h;
7965 +
7966 + if ( $ratio >= 1.6 ) {
7967 + $shape = 'long';
7968 + } elseif ( $ratio <= 0.75 ) {
7969 + $shape = 'vertical';
7970 + } elseif ( $ratio >= 0.9 && $ratio <= 1.1 ) {
7971 + $shape = 'square';
7972 + }
7973 + }
7974 + }
7975 +
7976 + $map[ $url ] = $shapes[ $shape ];
7977 + }
7978 +
7979 + if ( empty( $map ) ) {
7980 + return $item;
7981 + }
7982 +
7983 + foreach ( $map as $from => $to ) {
7984 + $raw = str_replace(
7985 + array( $from, str_replace( '/', '\\/', $from ) ),
7986 + array( $to, str_replace( '/', '\\/', $to ) ),
7987 + $raw
7988 + );
7989 + }
7990 +
7991 + $item['content'] = $is_string ? $raw : json_decode( $raw, true );
7992 +
7993 + return $item;
7994 + }
7995 +
7996 + private function wdkit_bundle_insert_item( $item, $builder, $defer_media, $custom_meta ) {
7997 +
7998 + // Start every item with an empty deferred list, unconditionally.
7999 + //
8000 + // Wdkit_Import_Images::$deferred_urls is static, and only the two `if ( $defer_media )`
8001 + // unions below drain it. Anything recorded on a path that does not reach one of them
8002 + // stays in the array and is handed to the NEXT item in the batch, which then schedules
8003 + // a cron event that rewrites a different page's media. Video makes this reachable in
8004 + // normal use: it defers regardless of $defer_media (see wdkit_Import_media()), so a
8005 + // non-deferred item carrying one leaves a URL behind. Clearing here means per-item
8006 + // isolation does not depend on which branch happens to run.
8007 + self::wdkit_require_import_images();
8008 + Wdkit_Import_Images::get_and_clear_deferred_urls();
8009 +
8010 + $raw_content = isset( $item['content'] ) ? $item['content'] : '';
8011 +
8012 + // The cloud ships each template's file byte-for-byte as the per-template route does,
8013 + // so this decodes to the same object import_page_section_content() works with.
8014 + $post_content = is_string( $raw_content )
8015 + ? json_decode( $raw_content )
8016 + : json_decode( wp_json_encode( $raw_content ) );
8017 +
8018 + if ( ! is_object( $post_content ) ) {
8019 + return array(
8020 + 'success' => false,
8021 + 'message' => esc_html__( 'Template content could not be read.', 'wdesignkit' ),
8022 + );
8023 + }
8024 +
8025 + $editor = ! empty( $item['editor'] ) ? sanitize_text_field( $item['editor'] ) : $builder;
8026 + $file_type = ! empty( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
8027 +
8028 + // import_page_section_content() requires file_type to name the editor and falls
8029 + // through to "Something went wrong" when it does not. Silently dropping a page is
8030 + // worse in a whole-site import, so fall back to the editor the bundle resolved for
8031 + // this template rather than bailing.
8032 + if ( '' === $file_type ) {
8033 + $file_type = ( 'gutenberg' === $editor ) ? 'wp_block' : 'elementor';
8034 + }
8035 +
8036 + $editor = ( 'wp_block' === $file_type ) ? 'gutenberg' : 'elementor';
8037 +
8038 + $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8039 + if ( '' === $post_title ) {
8040 + $post_title = isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '';
8041 + }
8042 +
8043 + // Before the slug is derived below, so the fallback slug loses the word too.
8044 + $post_title = self::wdkit_clean_page_title(
8045 + $post_title,
8046 + ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page'
8047 + );
8048 +
8049 + $post_slug = isset( $post_content->slug ) ? sanitize_title( $post_content->slug ) : '';
8050 +
8051 + // Falling straight back to the title gives every page a slug like
8052 + // "about-us-page-zion-technology" - the catalog's own display title, kit name and
8053 + // all, since that title is written for browsing a list of kits, not for a URL. The
8054 + // page's own path on the demo site (already fetched for the nav-link remap above) is
8055 + // a real slug someone chose for this exact page, so prefer that - except for the
8056 + // pages whose whole demo path IS the kit's folder (the homepage, and section-only
8057 + // entries like Header/Footer with no page of their own), where the segment left after
8058 + // stripping the kit folder is empty and there is nothing better to use than the title.
8059 + if ( '' === $post_slug && ! empty( $item['post_url'] ) ) {
8060 + $path_segments = array_values( array_filter( explode( '/', (string) wp_parse_url( (string) $item['post_url'], PHP_URL_PATH ) ) ) );
8061 +
8062 + if ( count( $path_segments ) > 1 ) {
8063 + $post_slug = sanitize_title( end( $path_segments ) );
8064 + }
8065 + }
8066 +
8067 + if ( '' === $post_slug ) {
8068 + $post_slug = sanitize_title( $post_title );
8069 + }
8070 +
8071 + $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
8072 +
8073 + if ( empty( $content ) ) {
8074 + return array(
8075 + 'success' => false,
8076 + 'message' => esc_html__( 'Content is Empty.', 'wdesignkit' ),
8077 + );
8078 + }
8079 +
8080 + // Whitelist the destination post type against the plugin's own list instead of
8081 + // trusting whatever the payload names.
8082 + $enqueue_instance = new Wdkit_Enqueue();
8083 + $allowed_types = $enqueue_instance->wdkit_get_post_type_list();
8084 + $post_type = ! empty( $item['wp_post_type'] ) ? sanitize_key( $item['wp_post_type'] ) : 'page';
8085 +
8086 + if ( ! array_key_exists( $post_type, $allowed_types ) ) {
8087 + $post_type = 'page';
8088 + }
8089 +
8090 + $widget_list = ! empty( $post_content->widget_list )
8091 + ? json_decode( wp_json_encode( $post_content->widget_list ), true )
8092 + : array();
8093 +
8094 + $image_urls = ( ! empty( $item['image_urls'] ) && is_array( $item['image_urls'] ) )
8095 + ? array_values( array_filter( array_map( 'esc_url_raw', $item['image_urls'] ) ) )
8096 + : array();
8097 +
8098 + // Same gate as the pages route - this half of the bundle payload is client-supplied too.
8099 + $image_urls = self::wdkit_filter_fetchable_urls( $image_urls );
8100 +
8101 + // Per-template millisecond costs, returned to the client so a staging run can be read
8102 + // straight off the network tab instead of from a log file.
8103 + $timing = array();
8104 + $t_item = microtime( true );
8105 + $inserted_id = 0;
8106 +
8107 + // Only the Elementor branch can lose widgets this way; Gutenberg blocks are stored
8108 + // as serialized markup and are not resolved through a type registry on save.
8109 + $unregistered = array();
8110 +
8111 + if ( 'gutenberg' === $editor ) {
8112 +
8113 + $blocks = parse_blocks( stripslashes( $content ) );
8114 +
8115 + $t_media = microtime( true );
8116 + $blocks = $this->wdkit_media_import( $blocks, 'gutenberg', $defer_media );
8117 + $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8118 +
8119 + // The cloud's image_urls (from the per-template site_images DB field) is not a
8120 + // reliable manifest - most templates never have it populated, so the background
8121 + // sideload pass would otherwise never get scheduled and these images would stay
8122 + // pointed at the source CDN forever. wdkit_Import_media() already recorded every
8123 + // URL it deferred while walking $blocks above; union that in here instead of
8124 + // depending on the cloud field alone.
8125 + if ( $defer_media ) {
8126 + $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8127 + $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8128 + }
8129 +
8130 + if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
8131 + $t_proc = microtime( true );
8132 + $processor = new WDKIT_Nexter_Block_Processor();
8133 + $blocks = $processor->run( $blocks );
8134 + $timing['block_processor'] = (int) round( ( microtime( true ) - $t_proc ) * 1000 );
8135 + }
8136 +
8137 + $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
8138 +
8139 + $t_insert = microtime( true );
8140 + $inserted_id = wp_insert_post(
8141 + array(
8142 + 'post_status' => 'publish',
8143 + 'post_type' => $post_type,
8144 + 'post_title' => $post_title,
8145 + 'post_name' => $post_slug,
8146 + 'post_content' => $content,
8147 + )
8148 + );
8149 +
8150 + $timing['post_insert'] = (int) round( ( microtime( true ) - $t_insert ) * 1000 );
8151 +
8152 + if ( is_wp_error( $inserted_id ) ) {
8153 + return array(
8154 + 'success' => false,
8155 + 'message' => $inserted_id->get_error_message(),
8156 + );
8157 + }
8158 +
8159 + $t_css = microtime( true );
8160 + self::wdkit_rebuild_block_css( $inserted_id );
8161 + $timing['block_css'] = (int) round( ( microtime( true ) - $t_css ) * 1000 );
8162 +
8163 + } else {
8164 +
8165 + if ( ! did_action( 'elementor/loaded' ) ) {
8166 + return array(
8167 + 'success' => false,
8168 + 'message' => esc_html__( 'Relevant Page Builder not installed or activated', 'wdesignkit' ),
8169 + );
8170 + }
8171 +
8172 + $content = $this->wdkit_content_remover( $content );
8173 +
8174 + $post_attributes = array(
8175 + 'post_title' => $post_title,
8176 + 'post_type' => $post_type,
8177 + 'post_status' => 'publish',
8178 + 'post_name' => $post_slug,
8179 + );
8180 +
8181 + // documents->create() is what makes this a real Elementor document — it owns
8182 + // _elementor_data, _elementor_edit_mode, _elementor_version and the template
8183 + // type. Writing that meta by hand is exactly what leaves a page opening as raw
8184 + // JSON in the Text Editor, so the canonical path never does.
8185 + $t_create = microtime( true );
8186 + if ( 'elementor_library' === $post_type ) {
8187 + $el_type = ! empty( $post_content->el_type ) ? sanitize_text_field( $post_content->el_type ) : 'page';
8188 + $new_document = \Elementor\Plugin::$instance->documents->create( $el_type, $post_attributes );
8189 + } else {
8190 + $new_document = \Elementor\Plugin::$instance->documents->create( $post_type, $post_attributes );
8191 + }
8192 + $timing['document_create'] = (int) round( ( microtime( true ) - $t_create ) * 1000 );
8193 +
8194 + if ( is_wp_error( $new_document ) ) {
8195 + return array(
8196 + 'success' => false,
8197 + 'message' => $new_document->get_error_message(),
8198 + );
8199 + }
8200 +
8201 + $settings = ! empty( $post_content->settings )
8202 + ? json_decode( wp_json_encode( $post_content->settings ), true )
8203 + : array();
8204 +
8205 + $elements = wp_json_encode( $content );
8206 +
8207 + $t_media = microtime( true );
8208 + $elements = $this->wdkit_media_import( $elements, 'elementor', $defer_media );
8209 + $timing['media_import'] = (int) round( ( microtime( true ) - $t_media ) * 1000 );
8210 +
8211 + // Same union the Gutenberg branch above does, and for the same reason: the cloud's
8212 + // image_urls is a manifest of the template's *images*, so anything the walk itself
8213 + // discovered - a video especially, which nothing else lists - is only ever going to
8214 + // reach the background pass if it is added here. Without this, wdkit_media_import()
8215 + // recorded those URLs and the schedule then ignored them, leaving the reference
8216 + // pointing at the authoring site for good.
8217 + if ( $defer_media ) {
8218 + $discovered_urls = array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() );
8219 + $image_urls = array_values( array_unique( array_merge( $image_urls, array_filter( $discovered_urls ) ) ) );
8220 + }
8221 +
8222 + // A header whose logo the template left unlinked gets one to the home page, before
8223 + // the save rather than as a second write.
8224 + $elements = self::wdkit_link_header_logo( $elements, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
8225 +
8226 + // Read the registry as late as possible — right before the save that consumes it.
8227 + // Anything named here is about to be dropped on the floor by Document::save()
8228 + // (see wdkit_unregistered_widget_types()), and this is the only moment the
8229 + // information still exists: afterwards the row simply looks like a page that was
8230 + // authored without those widgets.
8231 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8232 +
8233 + if ( ! empty( $unregistered ) ) {
8234 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8235 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8236 + }
8237 +
8238 + $t_save = microtime( true );
8239 + $new_document->save(
8240 + array(
8241 + 'elements' => $elements,
8242 + 'settings' => ! empty( $settings ) ? $settings : array(),
8243 + )
8244 + );
8245 + $timing['document_save'] = (int) round( ( microtime( true ) - $t_save ) * 1000 );
8246 +
8247 + $inserted_id = $new_document->get_main_id();
8248 + }
8249 +
8250 + if ( empty( $inserted_id ) ) {
8251 + return array(
8252 + 'success' => false,
8253 + 'message' => esc_html__( 'Something went wrong', 'wdesignkit' ),
8254 + );
8255 + }
8256 +
8257 + // nxt-* meta: theme-builder display conditions and similar. allowed_classes => false
8258 + // because the value comes from the imported kit body, where a serialized object could
8259 + // fire a POP gadget chain in any loaded plugin or theme (CWE-502) — see the identical
8260 + // restore in import_page_section_content().
8261 + if ( $custom_meta && ! empty( $post_content->custom_meta ) ) {
8262 + $meta_list = json_decode( wp_json_encode( $post_content->custom_meta ), true );
8263 +
8264 + if ( is_array( $meta_list ) ) {
8265 + foreach ( $meta_list as $meta_key => $meta_val ) {
8266 + if ( ! isset( $meta_val[0] ) ) {
8267 + continue;
8268 + }
8269 +
8270 + $value = $meta_val[0];
8271 +
8272 + if ( is_string( $value ) && is_serialized( $value ) ) {
8273 + $value = unserialize( $value, array( 'allowed_classes' => false ) );
8274 + }
8275 +
8276 + if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
8277 + add_post_meta( $inserted_id, $meta_key, $value );
8278 + }
8279 + }
8280 + }
8281 + }
8282 +
8283 + clean_post_cache( $inserted_id );
8284 +
8285 + // Diagnostic: how many widgets arrived for this template versus how many actually
8286 + // landed in the row. A template that comes in with content and stores none is a
8287 + // silent data loss - the page still imports, reports success, and renders empty.
8288 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
8289 + $in_widgets = preg_match_all( '/"widgetType":/', (string) wp_json_encode( $post_content ) );
8290 + $stored_raw = get_post_meta( $inserted_id, '_elementor_data', true );
8291 + $stored_widgets = preg_match_all( '/"widgetType":/', is_string( $stored_raw ) ? $stored_raw : (string) wp_json_encode( $stored_raw ) );
8292 +
8293 + error_log(
8294 + sprintf(
8295 + 'WDKIT insert: cloud=%s post=%d editor=%s title=%s widgets_in=%d widgets_stored=%d%s',
8296 + isset( $item['id'] ) ? $item['id'] : '?',
8297 + (int) $inserted_id,
8298 + $editor,
8299 + $post_title,
8300 + (int) $in_widgets,
8301 + (int) $stored_widgets,
8302 + ( $in_widgets > 0 && 0 === (int) $stored_widgets ) ? ' <-- LOST' : ''
8303 + )
8304 + );
8305 + }
8306 +
8307 + $timing['custom_meta'] = isset( $timing['custom_meta'] ) ? $timing['custom_meta'] : 0;
8308 + $timing['item_total'] = (int) round( ( microtime( true ) - $t_item ) * 1000 );
8309 +
8310 + // Deliberately not behind WP_DEBUG. The page imported, reported success and renders —
8311 + // it is simply missing widgets nobody asked it to drop, so a log line the site owner
8312 + // never enabled is the difference between a caught regression and a silent one.
8313 + if ( ! empty( $unregistered ) ) {
8314 + error_log(
8315 + sprintf(
8316 + 'WDKIT: %s (post %d) was saved without %d widget type(s) Elementor could not resolve: %s',
8317 + $post_title,
8318 + (int) $inserted_id,
8319 + count( $unregistered ),
8320 + implode( ', ', $unregistered )
8321 + )
8322 + );
8323 + }
8324 +
8325 + return array(
8326 + 'success' => true,
8327 + 'timing_ms' => $timing,
8328 + // Empty on every healthy import. Non-empty means this template was stored with
8329 + // content missing — wkit_import_site_bundle() turns it into a reported error.
8330 + 'unregistered' => ! empty( $unregistered ) ? $unregistered : array(),
8331 + 'id' => (int) $inserted_id,
8332 + // The template's id on the source site. wdkit_nxt_thembuilder_update() needs it to
8333 + // remap nxt_builder display conditions onto the ids this import just created.
8334 + 'old_page_id' => isset( $post_content->page_id ) ? $post_content->page_id : '',
8335 + 'title' => get_the_title( $inserted_id ),
8336 + 'edit_link' => get_edit_post_link( $inserted_id, 'internal' ),
8337 + 'view' => get_permalink( $inserted_id ),
8338 + 'editor' => $editor,
8339 + 'post_type' => $post_type,
8340 + 'widget_list' => is_array( $widget_list ) ? $widget_list : array(),
8341 + 'image_urls' => $image_urls,
8342 + 'post_url' => isset( $item['post_url'] ) ? sanitize_text_field( (string) $item['post_url'] ) : '',
8343 + 'is_front_page' => ! empty( $item['is_front_page'] ),
8344 + 'is_shop_page' => ! empty( $item['is_shop_page'] ),
8345 + );
8346 + }
8347 +
8348 + /**
8349 + * Create — or extend — the primary menu from the bundle's pre-ordered nav list.
8350 + *
8351 + * Two things here that a naive rebuild gets wrong on a site that is not empty: pages
8352 + * already in the menu are skipped, so re-importing a kit does not stack duplicate items;
8353 + * and only theme menu locations the site has left EMPTY get filled, so an existing
8354 + * menu assignment is never replaced by the imported one.
8355 + *
8356 + * @since 2.7.0
8357 + *
8358 + * @param array $nav_menu Bundle nav entries: template_id, title, position.
8359 + * @param array $imported Results from wdkit_bundle_insert_item().
8360 + * @param array $template_map Cloud template id => inserted post id.
8361 + * @return int Menu term id, or 0 when no menu was touched.
8362 + */
8363 + private function wdkit_bundle_build_menu( $nav_menu, $imported, $template_map ) {
8364 +
8365 + // Older bundles (and any kit whose pages are all untitled) carry no nav list — fall
8366 + // back to the pages this request created, in insertion order.
8367 + if ( empty( $nav_menu ) ) {
8368 + foreach ( $imported as $entry ) {
8369 + if ( 'page' === $entry['group'] && 'page' === $entry['post_type'] ) {
8370 + $nav_menu[] = array(
8371 + 'post_id' => $entry['id'],
8372 + 'title' => $entry['title'],
8373 + );
8374 + }
8375 + }
8376 + }
8377 +
8378 + if ( empty( $nav_menu ) ) {
8379 + return 0;
8380 + }
8381 +
8382 + // Deliberately untranslated: this string is the menu's identity across re-imports,
8383 + // so it must not change with the admin locale.
8384 + $menu_name = 'Primary Menu';
8385 + $menu_obj = wp_get_nav_menu_object( $menu_name );
8386 + $menu_id = $menu_obj ? (int) $menu_obj->term_id : wp_create_nav_menu( $menu_name );
8387 +
8388 + if ( is_wp_error( $menu_id ) || empty( $menu_id ) ) {
8389 + return 0;
8390 + }
8391 +
8392 + $existing = wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'any' ) );
8393 + $existing = is_array( $existing ) ? $existing : array();
8394 +
8395 + $already = array();
8396 + foreach ( $existing as $menu_item ) {
8397 + if ( ! empty( $menu_item->object_id ) ) {
8398 + $already[ (int) $menu_item->object_id ] = true;
8399 + }
8400 + }
8401 +
8402 + $position = count( $existing );
8403 +
8404 + foreach ( $nav_menu as $nav_item ) {
8405 + $post_id = 0;
8406 +
8407 + if ( ! empty( $nav_item['post_id'] ) ) {
8408 + $post_id = (int) $nav_item['post_id'];
8409 + } elseif ( ! empty( $nav_item['template_id'] ) && isset( $template_map[ (string) $nav_item['template_id'] ] ) ) {
8410 + $post_id = (int) $template_map[ (string) $nav_item['template_id'] ];
8411 + }
8412 +
8413 + if ( ! $post_id || isset( $already[ $post_id ] ) ) {
8414 + continue;
8415 + }
8416 +
8417 + ++$position;
8418 +
8419 + wp_update_nav_menu_item(
8420 + $menu_id,
8421 + 0,
8422 + array(
8423 + 'menu-item-title' => ! empty( $nav_item['title'] )
8424 + ? sanitize_text_field( (string) $nav_item['title'] )
8425 + : get_the_title( $post_id ),
8426 + 'menu-item-object-id' => $post_id,
8427 + 'menu-item-object' => get_post_type( $post_id ),
8428 + 'menu-item-type' => 'post_type',
8429 + 'menu-item-status' => 'publish',
8430 + 'menu-item-position' => $position,
8431 + )
8432 + );
8433 +
8434 + $already[ $post_id ] = true;
8435 + }
8436 +
8437 + // Only ever fill a location the active theme actually registers and the site has
8438 + // left empty. The old hardcoded primary/main-menu/header-menu write clobbered live
8439 + // menu assignments on any site that already had them.
8440 + $registered = function_exists( 'get_registered_nav_menus' ) ? get_registered_nav_menus() : array();
8441 + $locations = get_theme_mod( 'nav_menu_locations', array() );
8442 + $locations = is_array( $locations ) ? $locations : array();
8443 + $changed = false;
8444 +
8445 + foreach ( array_keys( $registered ) as $location ) {
8446 + if ( empty( $locations[ $location ] ) ) {
8447 + $locations[ $location ] = $menu_id;
8448 + $changed = true;
8449 + }
8450 + }
8451 +
8452 + if ( $changed ) {
8453 + set_theme_mod( 'nav_menu_locations', $locations );
8454 + }
8455 +
8456 + return (int) $menu_id;
8457 + }
8458 +
3607 8459 public function wdkit_enable_template_widgets() {
3608 8460 $widget_list = ! empty( $_POST['widget_list'] ) ? json_decode( wp_unslash( $_POST['widget_list'] ), true ) : array();
3609 8461 $extensions_list = ! empty( $_POST['extensions_list'] ) ? json_decode( wp_unslash( $_POST['extensions_list'] ), true ) : array();
3610 8462
8463 + $res = $this->wdkit_enable_widgets_data( $widget_list, $extensions_list );
8464 +
8465 + wp_send_json( $res );
8466 + wp_die();
8467 + }
8468 +
8469 + /**
8470 + * Enable the widgets and extensions a kit's content actually uses.
8471 + *
8472 + * Extracted from wdkit_enable_template_widgets() so the PHP import runner can reach it
8473 + * without `$_POST`. The lists are derived from imported kit content by the caller — they
8474 + * are never accepted from a remote payload.
8475 + *
8476 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
8477 + *
8478 + * @since 2.6.5
8479 + *
8480 + * @param array $widget_list Widget names used by the kit.
8481 + * @param array $extensions_list Extension names used by the kit.
8482 + * @return array Response array, exactly as the AJAX action used to emit.
8483 + */
8484 + public function wdkit_enable_widgets_data( $widget_list = array(), $extensions_list = array() ) {
8485 + $widget_list = is_array( $widget_list ) ? $widget_list : array();
8486 + $extensions_list = is_array( $extensions_list ) ? $extensions_list : array();
8487 +
3611 8488 if ( empty( $widget_list ) && empty( $extensions_list ) ) {
3612 - $res = array(
8489 + return array(
3613 8490 'massage' => __( 'Widget array not found', 'wdesignkit' ),
3614 8491 'description' => __( 'Widget array not found', 'wdesignkit' ),
3615 8492 'success' => false,
3616 8493 );
3617 - wp_send_json( $res );
3618 - wp_die();
3619 8494 }
3620 8495
8496 + // The widget list is what the caller needs verified; the extensions ride along but
8497 + // have no registry to check against.
8498 + $requested = is_array( $widget_list ) ? $widget_list : array();
8499 +
3621 8500 if ( ! has_filter( 'tpae_enable_selected_widgets' ) ) {
3622 - $res = array(
3623 - 'massage' => __( 'Relevant Plugin not Activated', 'wdesignkit' ),
3624 - 'description' => __( 'Relevant Plugin not Installed / Activated', 'wdesignkit' ),
3625 - 'success' => false,
8501 + if ( class_exists( 'Tpae_Hooks' ) && method_exists( 'Tpae_Hooks', 'get_instance' ) ) {
8502 + Tpae_Hooks::get_instance();
8503 + } elseif ( defined( 'L_THEPLUS_PATH' ) && file_exists( L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8504 + require_once L_THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8505 + } elseif ( defined( 'THEPLUS_PATH' ) && file_exists( THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php' ) ) {
8506 + require_once THEPLUS_PATH . 'includes/admin/tpae_hooks/class-tpae-hooks.php';
8507 + }
8508 + }
8509 +
8510 + // Ensure theplus_options exists so tpae_enable_selected_widgets does not bail silently
8511 + // on a fresh install where the option row has not been created yet.
8512 + $theplus_options = get_option( 'theplus_options', false );
8513 + if ( empty( $theplus_options ) || ! is_array( $theplus_options ) ) {
8514 + $theplus_options = array(
8515 + 'check_elements' => array(),
8516 + 'extras_elements' => array(),
3626 8517 );
3627 - wp_send_json( $res );
3628 - wp_die();
8518 + update_option( 'theplus_options', $theplus_options );
3629 8519 }
3630 8520
3631 - $w_list = array(
3632 - 'widgets' => $widget_list,
3633 - 'extensions' => $extensions_list,
3634 - );
8521 + if ( has_filter( 'tpae_enable_selected_widgets' ) ) {
8522 + $w_list = array(
8523 + 'widgets' => $widget_list,
8524 + 'extensions' => $extensions_list,
8525 + );
3635 8526
3636 - $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
8527 + $result = apply_filters( 'tpae_enable_selected_widgets', $w_list );
8528 + } else {
8529 + // Fallback: If filter is not loaded in this process (e.g. right after activation or CLI),
8530 + // directly merge into theplus_options so subsequent requests and Elementor boot find them.
8531 + $to_add = array();
8532 + foreach ( $widget_list as $w ) {
8533 + $to_add[] = str_replace( '-', '_', $w );
8534 + }
8535 + $ext_to_add = array();
8536 + foreach ( $extensions_list as $e ) {
8537 + $ext_to_add[] = str_replace( '-', '_', $e );
8538 + }
3637 8539
8540 + $theplus_options['check_elements'] = array_values( array_unique( array_merge( isset( $theplus_options['check_elements'] ) && is_array( $theplus_options['check_elements'] ) ? $theplus_options['check_elements'] : array(), $to_add ) ) );
8541 + $theplus_options['extras_elements'] = array_values( array_unique( array_merge( isset( $theplus_options['extras_elements'] ) && is_array( $theplus_options['extras_elements'] ) ? $theplus_options['extras_elements'] : array(), $ext_to_add ) ) );
8542 + update_option( 'theplus_options', $theplus_options );
8543 +
8544 + $result = array( 'success' => true );
8545 + }
8546 +
8547 + // Register newly enabled widgets into Elementor\'s in-memory manager so Document::save()
8548 + // in the current PHP execution does not silently drop them.
8549 + if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
8550 + try {
8551 + $widgets_manager = \Elementor\Plugin::$instance->widgets_manager;
8552 + if ( class_exists( '\TheplusAddons\L_Theplus_Widgets_Include' ) && method_exists( '\TheplusAddons\L_Theplus_Widgets_Include', 'get_instance' ) ) {
8553 + \TheplusAddons\L_Theplus_Widgets_Include::get_instance()->add_widgets( $widgets_manager );
8554 + }
8555 + if ( class_exists( '\TheplusAddons\Theplus_Widgets_Include' ) && method_exists( '\TheplusAddons\Theplus_Widgets_Include', 'get_instance' ) ) {
8556 + \TheplusAddons\Theplus_Widgets_Include::get_instance()->add_widgets( $widgets_manager );
8557 + }
8558 + } catch ( \Throwable $e ) {
8559 + // Best-effort in-memory registration.
8560 + }
8561 + }
8562 +
8563 + // What the caller actually needs to know. A 200 with success:true only says the filter
8564 + // ran — tpae_enable_selected_widgets() reports success even when it wrote nothing
8565 + // (it bails silently if the addon's own settings row is still empty). So answer from
8566 + // the state it was supposed to write.
8567 + $not_enabled = self::wdkit_not_enabled_widget_types( $requested );
8568 +
3638 8569 if ( ! empty( $result['success'] ) ) {
3639 8570 $res = array(
3640 8571 'massage' => __( 'Enabled widgets successfully', 'wdesignkit' ),
3641 8572 'description' => __( 'Used widgets have been enabled successfully', 'wdesignkit' ),
@@ -3652,10 +8583,14 @@
3652 8583 'success' => false,
3653 8584 );
3654 8585 }
3655 8586
3656 - wp_send_json( $res );
3657 - wp_die();
8587 + // Authoritative result of the enable, and the only field the caller should retry on.
8588 + // Both callers need it: the browser importer retries on it, and the runner's
8589 + // enable_kit_widgets() decides from it whether the content stage may start.
8590 + $res['not_enabled'] = $not_enabled;
8591 +
8592 + return $res;
3658 8593 }
3659 8594
3660 8595 /**
3661 8596 * Import single template and section from plugin only
@@ -3705,8 +8640,12 @@
3705 8640 'editor' => $args['editor'],
3706 8641 'unique_id' => get_option( 'wdkit_unique_id' ) ?? '',
3707 8642 );
3708 8643
8644 + if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
8645 + $temp_args = wdkit_kit_import_with_site_identity( $temp_args );
8646 + }
8647 +
3709 8648 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3710 8649
3711 8650 if ( is_wp_error( $response ) ) {
3712 8651 wp_send_json( array(
@@ -3791,8 +8730,13 @@
3791 8730 // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
3792 8731 // container background images especially — resolve to nothing and render empty.
3793 8732 $content = self::wdkit_repair_attachment_ids( $content );
3794 8733
8734 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $content ) );
8735 + if ( ! empty( $unregistered ) ) {
8736 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8737 + }
8738 +
3795 8739 $document->save([
3796 8740 'elements' => $content
3797 8741 ]);
3798 8742 }
@@ -3834,8 +8778,18 @@
3834 8778 // lookup, and media that is already local resolves straight from its URL, so no
3835 8779 // image is fetched or stored twice.
3836 8780 $content = $this->wdkit_relink_gutenberg_content( $content );
3837 8781
8782 + // See wdkit_write_post_content(): this endpoint replaces the whole post_content
8783 + // with markup the import itself produced, so kses here strips legitimate block
8784 + // markup rather than untrusted input. Kept inline instead of routed through that
8785 + // helper because this call needs wp_slash() and the WP_Error return.
8786 + $had_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
8787 +
8788 + if ( $had_kses ) {
8789 + kses_remove_filters();
8790 + }
8791 +
3838 8792 $result = wp_update_post(
3839 8793 array(
3840 8794 'ID' => $post_id,
3841 8795 'post_content' => wp_slash( $content ),
@@ -3842,8 +8796,12 @@
3842 8796 ),
3843 8797 true
3844 8798 );
3845 8799
8800 + if ( $had_kses ) {
8801 + kses_init_filters();
8802 + }
8803 +
3846 8804 if ( is_wp_error( $result ) ) {
3847 8805 return array(
3848 8806 'success' => false,
3849 8807 'message' => $result->get_error_message(),
@@ -3873,8 +8831,13 @@
3873 8831 // source template's attachment IDs, so repair them or this save undoes the
3874 8832 // create-time fix and background images stop rendering.
3875 8833 $elements = self::wdkit_repair_attachment_ids( $elements );
3876 8834
8835 + $unregistered = self::wdkit_unregistered_widget_types( self::wdkit_widget_types_in( $elements ) );
8836 + if ( ! empty( $unregistered ) ) {
8837 + self::get_instance()->wdkit_enable_widgets_data( $unregistered );
8838 + }
8839 +
3877 8840 $document->save( array( 'elements' => $elements ) );
3878 8841 }
3879 8842
3880 8843 return array(
@@ -3923,8 +8886,11 @@
3923 8886 }
3924 8887
3925 8888 if ( isset( $_POST['thumb_image'] ) ) {
3926 8889 $thumb_image = ! empty( $_POST['thumb_image'] ) ? esc_url_raw( $_POST['thumb_image'] ) : '';
8890 + // Capped once here covers both download_url( $thumb_image ) calls below (Elementor
8891 + // and Gutenberg branches share this variable).
8892 + $thumb_image = Wdkit_Image_Guard::cap_pexels_source( $thumb_image );
3927 8893 }
3928 8894
3929 8895 if ( isset( $_POST['template_id'] ) ) {
3930 8896 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
@@ -3931,8 +8897,14 @@
3931 8897 }
3932 8898
3933 8899 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3934 8900
8901 + // AI kit imports send this so the page saves with its source CDN image URLs still in
8902 + // place - the JS side then registers it for wkit_schedule_deferred_media_sync() once
8903 + // every page in the kit has been created, and the actual media_sideload_image() work
8904 + // happens later, off this request. See wdkit_media_import()'s $defer_media branch.
8905 + $defer_media = ! empty( $_POST['defer_media'] );
8906 +
3935 8907 if ( isset( $_POST['data'] ) ) {
3936 8908 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3937 8909 }
3938 8910
@@ -3952,9 +8924,27 @@
3952 8924 }
3953 8925
3954 8926 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3955 8927 $post_content = $data;
8928 + // Restore The Plus Addons' globals before the page is built, so the widgets'
8929 + // tp_global_preset references resolve as soon as it renders. Done here rather
8930 + // than in the save-template UI's confirmation dialog so that every import path
8931 + // - the library, the abilities, the theme builder - gets it.
8932 + if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
8933 + $this->wdkit_merge_tp_globals(
8934 + json_decode( wp_json_encode( $post_content->tp_globals ), true ),
8935 + isset( $post_content->tp_global_refs )
8936 + ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
8937 + : array()
8938 + );
8939 + }
8940 +
3956 8941 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
8942 +
8943 + // Same title cleanup the bundle path applies, so a kit imported through either
8944 + // route lists identically in Pages.
8945 + $post_title = self::wdkit_clean_page_title( $post_title, $post_type );
8946 +
3957 8947 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3958 8948 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3959 8949 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
3960 8950 $temp_con = '';
@@ -3970,14 +8960,24 @@
3970 8960 wp_die();
3971 8961 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3972 8962
3973 8963 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
8964 +
3974 8965 $blocks = parse_blocks( stripslashes( $content ) );
8966 + $blocks = $this->wdkit_media_import( $blocks, $editor, $defer_media );
3975 8967
3976 - $blocks = $this->wdkit_media_import( $blocks, $editor );
8968 + // wdkit_media_import() above just skipped every raster image in this section's
8969 + // own content (the "Blog Detail" dummy template's own decorative images, not a
8970 + // user-picked stock photo) — this is this method's only chance to learn which
8971 + // URLs those were, so the deferred-media response field below is built from it
8972 + // rather than left to the bundle-only image_urls the client already tracks.
8973 + $deferred_image_urls = $defer_media
8974 + ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
8975 + : array();
3977 8976
3978 8977 $processor = new WDKIT_Nexter_Block_Processor();
3979 8978 $blocks = $processor->run( $blocks );
8979 +
3980 8980 $content = serialize_blocks( $blocks );
3981 8981
3982 8982 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3983 8983
@@ -3990,8 +8990,12 @@
3990 8990 'post_content' => $content,
3991 8991 )
3992 8992 );
3993 8993
8994 + if ( ! is_wp_error( $inserted_post ) && $inserted_post ) {
8995 + self::wdkit_rebuild_block_css( $inserted_post );
8996 + }
8997 +
3994 8998 if ( is_wp_error( $inserted_post ) ) {
3995 8999 wp_send_json(
3996 9000 array(
3997 9001 'import_failed' => $inserted_post->get_error_message(),
@@ -4000,9 +9004,19 @@
4000 9004 );
4001 9005 wp_die();
4002 9006 }
4003 9007
4004 - if ( ! empty( $thumb_image ) ) {
9008 + // A picked stock image is already in the media library by the time the posts
9009 + // are created, so thumb_image is usually a url on this site rather than a
9010 + // remote one. That can never go down the download path - the external-url
9011 + // guard rejects loopback addresses by design, which silently dropped the
9012 + // featured image - so recognise the existing attachment and point at it.
9013 + // Anything genuinely remote still takes the untouched branch below.
9014 + $local_thumb_id = ! empty( $thumb_image ) ? self::wdkit_local_attachment_from_url( $thumb_image ) : 0;
9015 +
9016 + if ( $local_thumb_id ) {
9017 + set_post_thumbnail( $inserted_post, $local_thumb_id );
9018 + } elseif ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
4005 9019 // $featured_image_url = esc_url_raw( $thumb_image );
4006 9020 $tmp = download_url( $thumb_image );
4007 9021 if ( is_wp_error( $tmp ) ) {
4008 9022 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -4038,9 +9052,9 @@
4038 9052 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
4039 9053 if ( ! empty( $custom_meta ) ) {
4040 9054 foreach ( $custom_meta as $meta_key => $meta_val ) {
4041 9055 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
4042 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9056 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
4043 9057 }
4044 9058
4045 9059 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
4046 9060 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -4072,8 +9086,15 @@
4072 9086 Tpgb_Library()->remove_backend_dir_files();
4073 9087 }
4074 9088
4075 9089 clean_post_cache( $inserted_post );
9090 +
9091 + // This whole method imports exactly one section per call — unlike
9092 + // wdkit_import_template()/wdkit_import_kit_template(), it never fired this hook
9093 + // at all, so single-section imports (Header/Footer/CTA/etc., a primary import
9094 + // path per the Template Type sidebar) were invisible to tracking entirely.
9095 + do_action( 'wdkit_template_imported', 'single', sanitize_key( $editor ), 1 );
9096 +
4076 9097 wp_send_json(
4077 9098 array(
4078 9099 $temp_id => $temp_detail,
4079 9100 'description' => 'Yay! Your Section has been Successfully Imported.',
@@ -4078,8 +9099,12 @@
4078 9099 $temp_id => $temp_detail,
4079 9100 'description' => 'Yay! Your Section has been Successfully Imported.',
4080 9101 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
4081 9102 'inserted_id' => $inserted_post,
9103 + // Raster images this call itself deferred - the caller unions this into
9104 + // whatever image_urls its own template-fetch response already carries
9105 + // before scheduling the background sideload pass.
9106 + 'image_urls' => $deferred_image_urls,
4082 9107 'success' => true,
4083 9108 )
4084 9109 );
4085 9110 wp_die();
@@ -4129,9 +9154,9 @@
4129 9154 }
4130 9155
4131 9156 $inserted_id = $new_document->get_main_id();
4132 9157
4133 - if ( ! empty( $thumb_image ) ) {
9158 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
4134 9159 // $featured_image_url = esc_url_raw( $thumb_image );
4135 9160 $tmp = download_url( $thumb_image );
4136 9161 if ( is_wp_error( $tmp ) ) {
4137 9162 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -4165,10 +9190,20 @@
4165 9190
4166 9191 $settings = ( isset( $post_content->settings ) && ! empty( $post_content->settings ) ) ? json_decode( wp_json_encode( $post_content->settings ), true ) : array();
4167 9192
4168 9193 $content = wp_json_encode( $content );
4169 - $content = $this->wdkit_media_import( $content, $file_type );
9194 + $content = $this->wdkit_media_import( $content, $file_type, $defer_media );
4170 9195
9196 + // Same header-logo guarantee the bundle path applies, so a kit imported
9197 + // through either route ends up with the same clickable logo.
9198 + $content = self::wdkit_link_header_logo( $content, isset( $post_content->custom_meta ) ? $post_content->custom_meta : array() );
9199 +
9200 + $widgets_in = self::wdkit_widget_types_in( $content );
9201 + $unregistered = self::wdkit_unregistered_widget_types( $widgets_in );
9202 + if ( ! empty( $unregistered ) ) {
9203 + $this->wdkit_enable_widgets_data( $unregistered );
9204 + }
9205 +
4171 9206 $new_document->save(
4172 9207 array(
4173 9208 'elements' => $content,
4174 9209 'settings' => ! empty( $settings ) ? $settings : array(),
@@ -4185,9 +9220,9 @@
4185 9220 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
4186 9221 if ( ! empty( $custom_meta ) ) {
4187 9222 foreach ( $custom_meta as $meta_key => $meta_val ) {
4188 9223 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
4189 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
9224 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
4190 9225 }
4191 9226 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
4192 9227 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
4193 9228 }
@@ -4211,8 +9246,12 @@
4211 9246 }
4212 9247
4213 9248 \Elementor\Plugin::$instance->files_manager->clear_cache();
4214 9249
9250 + // See the matching note in the Gutenberg branch above — this method never
9251 + // fired the tracking hook for either editor.
9252 + do_action( 'wdkit_template_imported', 'single', 'elementor', 1 );
9253 +
4215 9254 wp_send_json(
4216 9255 array(
4217 9256 $temp_id => $temp_detail,
4218 9257 'content' => $temp_con,
@@ -4218,8 +9257,13 @@
4218 9257 'content' => $temp_con,
4219 9258 'description' => 'Yay! Your Section has been Successfully Imported.',
4220 9259 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
4221 9260 'inserted_id' => $inserted_id,
9261 + // Same contract as the Gutenberg branch: the media this call
9262 + // chose to defer, for the caller to schedule.
9263 + 'image_urls' => $defer_media
9264 + ? array_map( 'esc_url_raw', Wdkit_Import_Images::get_and_clear_deferred_urls() )
9265 + : array(),
4222 9266 'success' => true,
4223 9267 )
4224 9268 );
4225 9269 wp_die();
@@ -4249,9 +9293,14 @@
4249 9293 * Replace unicode glitch
4250 9294 *
4251 9295 * @since 2.0.0
4252 9296 */
4253 - private function replace_unicode_glitch( $content ) {
9297 + /**
9298 + * Widened from private to public in 2.6.5 so the PHP page importer can apply the exact
9299 + * same unicode normalisation the AJAX page import applies. Pure string transform, no
9300 + * state, no capability implications.
9301 + */
9302 + public function replace_unicode_glitch( $content ) {
4254 9303
4255 9304 // Fix escaped unicode like \u003c → <
4256 9305 $content = preg_replace_callback(
4257 9306 '/\\\\u([0-9a-fA-F]{4})/',
@@ -4280,8 +9329,31 @@
4280 9329 */
4281 9330 protected function update_plugin_setting() {
4282 9331 $temp_id = isset( $_POST['plugin_type'] ) ? sanitize_text_field( $_POST['plugin_type'] ) : '';
4283 9332
9333 + $response = $this->wdkit_apply_plugin_settings_data( $temp_id );
9334 +
9335 + wp_send_json( $response );
9336 + wp_die();
9337 + }
9338 +
9339 + /**
9340 + * Apply the plugin-side settings an imported kit needs.
9341 + *
9342 + * Extracted from update_plugin_setting() so the PHP import runner can reach it without
9343 + * `$_POST`. The option list is a closed set written by this method alone — there is no
9344 + * path here for a caller to name an option, which is the point.
9345 + *
9346 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9347 + *
9348 + * @since 2.6.5
9349 + *
9350 + * @param string $temp_id Plugin identifier; only 'elementor' is recognised.
9351 + * @return array Response array, exactly as the AJAX action used to emit.
9352 + */
9353 + public function wdkit_apply_plugin_settings_data( $temp_id ) {
9354 + $temp_id = is_string( $temp_id ) ? $temp_id : '';
9355 +
4284 9356 if ( $temp_id == 'elementor' ) {
4285 9357 $unfiltered_files = get_option( 'elementor_unfiltered_files_upload', false );
4286 9358 $load_fa4 = get_option( 'elementor_load_fa4_shim', false );
4287 9359 $Inline_font_icons = get_option( 'elementor_experiment-e_font_icon_svg', false );
@@ -4323,10 +9395,9 @@
4323 9395 'success' => false,
4324 9396 );
4325 9397 }
4326 9398
4327 - wp_send_json( $response );
4328 - wp_die();
9399 + return $response;
4329 9400 }
4330 9401
4331 9402 /**
4332 9403 * generate different color logo
@@ -4348,8 +9419,12 @@
4348 9419 }
4349 9420
4350 9421 $image_url = esc_url_raw( $_POST['image_url'] );
4351 9422
9423 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
9424 + wp_send_json_error( 'Image could not be downloaded.' );
9425 + }
9426 +
4352 9427 $tmp_file = download_url( $image_url );
4353 9428 if ( is_wp_error( $tmp_file ) ) {
4354 9429 wp_send_json_error( 'Image could not be downloaded.' );
4355 9430 }
@@ -4385,9 +9460,21 @@
4385 9460
4386 9461 $upload_dir = wp_upload_dir();
4387 9462 $result_urls = array();
4388 9463
9464 + $colour_index = 0;
4389 9465 foreach ( $img_colors as $name => $rgb ) {
9466 + // $name is a key from the posted colours payload and went straight into the output
9467 + // filename, so traversal sequences in it steered imagepng() outside the upload
9468 + // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
9469 + // path segment; a key made only of dots/separators sanitizes to empty, so fall back
9470 + // to a positional index rather than writing to a bare "colored--<time>.png".
9471 + ++$colour_index;
9472 + $safe_name = sanitize_file_name( (string) $name );
9473 + if ( '' === $safe_name ) {
9474 + $safe_name = 'colour-' . $colour_index;
9475 + }
9476 +
4390 9477 $new = imagecreatetruecolor( $width, $height );
4391 9478 imagesavealpha( $new, true );
4392 9479 imagealphablending( $new, false );
4393 9480
@@ -4413,9 +9500,9 @@
4413 9500 imagesetpixel( $new, $x, $y, $color );
4414 9501 }
4415 9502 }
4416 9503
4417 - $filename = 'colored-' . $name . '-' . time() . '.png';
9504 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
4418 9505 $filepath = $upload_dir['path'] . '/' . $filename;
4419 9506
4420 9507 imagepng( $new, $filepath );
4421 9508 imagedestroy( $new );
@@ -4446,8 +9533,29 @@
4446 9533 *
4447 9534 * @since 2.0.0
4448 9535 */
4449 9536 protected function update_theme_setting() {
9537 + $response = $this->wdkit_apply_theme_settings_data();
9538 +
9539 + wp_send_json( $response );
9540 + wp_die();
9541 + }
9542 +
9543 + /**
9544 + * Apply the Nexter theme container settings an imported kit expects.
9545 + *
9546 + * Extracted from update_theme_setting() so the PHP import runner can reach it without
9547 + * `$_POST`. Note that this method takes no input at all and never has: it writes a
9548 + * fixed set of `nxt-theme-options` sub-keys, which is why it is inherently idempotent
9549 + * and why there is nothing here a caller could steer.
9550 + *
9551 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9552 + *
9553 + * @since 2.6.5
9554 + *
9555 + * @return array Response array, exactly as the AJAX action used to emit.
9556 + */
9557 + public function wdkit_apply_theme_settings_data() {
4450 9558 $theme_db = get_option( 'nxt-theme-options', false );
4451 9559
4452 9560 $container_type = 'container-fluid';
4453 9561 $fluid_spacing = array(
@@ -4568,10 +9676,9 @@
4568 9676 'description' => esc_html__( 'Theme Setting updated', 'wdesignkit' ),
4569 9677 'success' => true,
4570 9678 );
4571 9679
4572 - wp_send_json( $response );
4573 - wp_die();
9680 + return $response;
4574 9681 }
4575 9682
4576 9683 /**
4577 9684 * change site setting for import kit
@@ -4578,8 +9685,22 @@
4578 9685 *
4579 9686 * @since 2.0.0
4580 9687 */
4581 9688 protected function update_site_setting() {
9689 +
9690 + // This is the import's "Finalizing Settings" request, and it is not the cheap
9691 + // bookkeeping call its name suggests: it generates the site logo/icon and then runs
9692 + // wdkit_sweep_attachment_ids() across every page that was just created, which resolves
9693 + // attachment ids and can pull WordPress into Imagick subsize generation. Every other
9694 + // heavy handler here already extends the limit (see the bundle import, the page-section
9695 + // import, the deferred-media cron); this one did not, so on a kit with a large image it
9696 + // fataled at PHP's default 30s. The client has no recovery path for that - the step
9697 + // simply stays on "Finalizing Settings" and polls forever, which is exactly the hang
9698 + // reported against Elementor kits. Harmless no-op where set_time_limit() is disabled.
9699 + if ( function_exists( 'set_time_limit' ) ) {
9700 + @set_time_limit( 300 );
9701 + }
9702 +
4582 9703 $temp_id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
4583 9704 $shop_id = isset( $_POST['shop_id'] ) ? sanitize_text_field( wp_unslash( $_POST['shop_id'] ) ) : '';
4584 9705 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
4585 9706 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
@@ -4584,10 +9705,59 @@
4584 9705 $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'page';
4585 9706 $site_name = isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '';
4586 9707 $site_tagline = isset( $_POST['site_tagline'] ) ? sanitize_text_field( wp_unslash( $_POST['site_tagline'] ) ) : '';
4587 9708
4588 - $this->wdkit_nxt_thembuilder_update();
9709 + $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9710 + $page_information = json_decode( $page_information, true );
4589 9711
9712 + $response = $this->wdkit_apply_site_settings_data(
9713 + array(
9714 + 'id' => $temp_id,
9715 + 'shop_id' => $shop_id,
9716 + 'temp_type' => $temp_type,
9717 + 'site_name' => $site_name,
9718 + 'site_tagline' => $site_tagline,
9719 + 'page_information' => $page_information,
9720 + )
9721 + );
9722 +
9723 + wp_send_json( $response );
9724 + wp_die();
9725 + }
9726 +
9727 + /**
9728 + * Apply a kit's site settings: front page, shop page, name and tagline.
9729 + *
9730 + * Extracted from update_site_setting() so the PHP import runner can reach it without
9731 + * `$_POST`. The set of options written is closed and hard-coded here —
9732 + * `show_on_front`, `page_on_front`, `woocommerce_shop_page_id`, `blogname`,
9733 + * `blogdescription`. There is deliberately no way for a caller to name an option.
9734 + *
9735 + * Theme-builder condition rewriting still runs through
9736 + * wdkit_nxt_thembuilder_update(), which now takes its page map as an argument.
9737 + *
9738 + * The AJAX action above is now a thin adapter over this and its response is unchanged.
9739 + *
9740 + * @since 2.6.5
9741 + *
9742 + * @param array $args {id, shop_id, temp_type, site_name, site_tagline, page_information,
9743 + * defer_attachment_downloads}.
9744 + * @return array Response array, exactly as the AJAX action used to emit.
9745 + */
9746 + public function wdkit_apply_site_settings_data( $args ) {
9747 + $args = is_array( $args ) ? $args : array();
9748 +
9749 + $temp_id = isset( $args['id'] ) ? sanitize_text_field( (string) $args['id'] ) : '';
9750 + $shop_id = isset( $args['shop_id'] ) ? sanitize_text_field( (string) $args['shop_id'] ) : '';
9751 + $temp_type = isset( $args['temp_type'] ) ? sanitize_text_field( (string) $args['temp_type'] ) : 'page';
9752 + $site_name = isset( $args['site_name'] ) ? sanitize_text_field( (string) $args['site_name'] ) : '';
9753 + $site_tagline = isset( $args['site_tagline'] ) ? sanitize_text_field( (string) $args['site_tagline'] ) : '';
9754 +
9755 + $this->wdkit_nxt_thembuilder_update(
9756 + isset( $args['page_information'] ) ? $args['page_information'] : null,
9757 + ! empty( $args['defer_attachment_downloads'] )
9758 + );
9759 +
4590 9760 if ( ! empty( $shop_id ) ) {
4591 9761 update_option( 'woocommerce_shop_page_id', $shop_id );
4592 9762 }
4593 9763
@@ -4600,8 +9770,28 @@
4600 9770 }
4601 9771
4602 9772 if ( ! empty( $site_tagline ) ) {
4603 9773 update_option( 'blogdescription', $site_tagline );
9774 +
9775 + /* Remembered so a LATER import can tell a tagline this importer wrote from
9776 + * one the site's owner wrote. Only the former is ours to clear. */
9777 + update_option( 'wdkit_applied_tagline', $site_tagline );
9778 + } else {
9779 + /* Skipped the tagline question - which is not the same as "leave whatever is
9780 + * there". Importing a second kit onto a site that already carries the FIRST
9781 + * import's tagline left the new business sitting under the old one's slogan
9782 + * (a SaaS kit headed "Golden Crust Melts Inside"), because nothing ever
9783 + * cleared it.
9784 + *
9785 + * Cleared only when the current tagline is character-for-character the one a
9786 + * previous import wrote. A tagline the site's own owner typed is left
9787 + * completely alone - a kit import has no business wiping it. */
9788 + $applied = (string) get_option( 'wdkit_applied_tagline', '' );
9789 +
9790 + if ( '' !== $applied && $applied === (string) get_option( 'blogdescription', '' ) ) {
9791 + update_option( 'blogdescription', '' );
9792 + delete_option( 'wdkit_applied_tagline' );
9793 + }
4604 9794 }
4605 9795
4606 9796 $response = array(
4607 9797 'message' => esc_html__( 'Site link updated', 'wdesignkit' ),
@@ -4616,10 +9806,9 @@
4616 9806 'success' => false,
4617 9807 );
4618 9808 }
4619 9809
4620 - wp_send_json( $response );
4621 - wp_die();
9810 + return $response;
4622 9811 }
4623 9812
4624 9813 /**
4625 9814 * update theme builder
@@ -4624,20 +9813,41 @@
4624 9813 /**
4625 9814 * update theme builder
4626 9815 *
4627 9816 * @since 2.0.4
9817 + *
9818 + * @param array|null $page_information Page map; falls back to $_POST when null.
9819 + * @param bool $local_only Passed straight through to
9820 + * wdkit_sweep_attachment_ids() — see its docblock.
4628 9821 */
4629 - public function wdkit_nxt_thembuilder_update() {
9822 + public function wdkit_nxt_thembuilder_update( $page_information = null, $local_only = false ) {
4630 9823
4631 - $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
4632 - $page_information = json_decode( $page_information, true );
9824 + /* Falls back to `$_POST` when called with no argument, so every existing caller —
9825 + * and the AJAX path — behaves exactly as before. The PHP runner passes the map in. */
9826 + if ( null === $page_information ) {
9827 + $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
9828 + $page_information = json_decode( $page_information, true );
9829 + }
4633 9830
9831 + $t_finalize = microtime( true );
9832 +
4634 9833 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
4635 9834
4636 9835 // Every page and attachment now exists, so resolve any image ID the per-page
4637 9836 // pass could not (siblings import concurrently and share icons).
4638 - $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ) );
9837 + $t_sweep = microtime( true );
9838 + $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ), $local_only );
4639 9839
9840 + // This walks every page's content looking for unresolved attachment ids, so it
9841 + // scales with kit size and was not covered by the per-template numbers.
9842 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
9843 + error_log( sprintf(
9844 + 'WDKIT finalize: sweep_attachment_ids %dms over %d pages',
9845 + (int) round( ( microtime( true ) - $t_sweep ) * 1000 ),
9846 + count( $page_information )
9847 + ) );
9848 + }
9849 +
4640 9850 // Step 1: banavo mapping [ old_id => new_id ]
4641 9851 $id_mapping = array();
4642 9852 foreach ( $page_information as $page_info ) {
4643 9853 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -4710,32 +9920,136 @@
4710 9920 *
4711 9921 * @since 2.2.2
4712 9922 */
4713 9923 public function wdkit_check_user_credit() {
4714 - $array_data = array(
4715 - 'token' => isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '',
4716 - );
9924 + $token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
4717 9925
9926 + /* The caller sends the token from its own localStorage, which is per browser. The
9927 + * SITE's cloud session is not - it lives in a transient written at login and is what
9928 + * every import actually authenticates with. Reading only the browser copy meant a
9929 + * connected site told a second browser, an incognito window or another WP admin that
9930 + * it needed to log in, while an import from that same site would have worked
9931 + * (ClickUp 14ynqxywrg4). */
9932 + if ( '' === $token && function_exists( 'wdkit_kit_import_resolve_token' ) ) {
9933 + $token = (string) wdkit_kit_import_resolve_token();
9934 + }
9935 +
9936 + if ( '' === $token ) {
9937 + wp_send_json(
9938 + array(
9939 + 'success' => false,
9940 + 'reason' => 'no_session',
9941 + 'logged_in' => false,
9942 + 'message' => esc_html__( 'Not signed into WDesignKit on this site.', 'wdesignkit' ),
9943 + )
9944 + );
9945 + wp_die();
9946 + }
9947 +
9948 + $array_data = array( 'token' => $token );
9949 +
4718 9950 $response = $this->wkit_api_call( $array_data, 'ai/credits/get' );
4719 9951 $success = ! empty( $response['success'] ) ? $response['success'] : false;
4720 9952
4721 9953 if ( empty( $success ) ) {
4722 - $response = array(
4723 - 'success' => false,
4724 - 'message' => esc_html__( 'Data Not Found', 'wdesignkit' ),
4725 - 'description' => esc_html__( 'Data not found', 'wdesignkit' ),
9954 + /* Why it failed, not just that it did. The caller used to receive one flat
9955 + * "Data Not Found" for every failure and read it as "no credits left", so a
9956 + * cloud request that merely timed out was reported to the user as "your site
9957 + * limit for today has been reached" - a limit that had not been reached. A
9958 + * transport failure says so through wkit_api_call()'s own message. */
9959 + $massage = isset( $response['massage'] ) ? (string) $response['massage'] : '';
9960 + $unreachable = ( '' !== $massage && false !== stripos( $massage, 'API request error' ) );
9961 +
9962 + wp_send_json(
9963 + array(
9964 + 'success' => false,
9965 + 'reason' => $unreachable ? 'unreachable' : 'rejected',
9966 +
9967 + /* A token existed and was sent, so whatever went wrong here is not the
9968 + * user being signed out. */
9969 + 'logged_in' => true,
9970 + 'message' => $unreachable
9971 + ? esc_html__( 'Could not reach WDesignKit.', 'wdesignkit' )
9972 + : esc_html__( 'Data Not Found', 'wdesignkit' ),
9973 + 'description' => $massage,
9974 + )
4726 9975 );
4727 -
4728 - wp_send_json( $response );
4729 9976 wp_die();
4730 9977 }
4731 9978
4732 9979 $response = json_decode( wp_json_encode( $response['data'] ), true );
4733 9980
9981 + if ( is_array( $response ) ) {
9982 + /* The cloud answered for this token, so the site is signed in - said plainly so
9983 + * the caller does not have to infer it from its own localStorage. */
9984 + $response['logged_in'] = true;
9985 + }
9986 +
9987 + $this->wdkit_cache_cloud_usage( $response );
9988 +
4734 9989 wp_send_json( $response );
4735 9990 wp_die();
4736 9991 }
4737 9992
9993 + /**
9994 + * Caches the storage / credit figures this response carried.
9995 + *
9996 + * This handler is the ONLY place those numbers ever exist on the site: the cloud endpoint
9997 + * authenticates with a user token that only a logged-in dashboard request carries, so the
9998 + * analytics heartbeat — which runs on cron with no user at all — can never fetch them itself.
9999 + * Caching them here is what lets Posimyth_Tracker_WDK report them, and it reports the cache's
10000 + * age alongside so a stale reading is recognisable as one.
10001 + *
10002 + * Field names are probed rather than assumed: the cloud has renamed these before, and the
10003 + * licence ability already carries six spellings of its own key field for the same reason. An
10004 + * unrecognised shape simply caches nothing rather than storing a wrong number.
10005 + *
10006 + * Only the figures are kept. No token, no account id, no email — the analytics consent copy
10007 + * promises non-sensitive data only, and this is read by the payload builder.
10008 + *
10009 + * @since 2.6.4
10010 + *
10011 + * @param mixed $data Decoded `data` object from the credits endpoint.
10012 + * @return void
10013 + */
10014 + private function wdkit_cache_cloud_usage( $data ) {
10015 + if ( ! is_array( $data ) ) {
10016 + return;
10017 + }
10018 +
10019 + $pick = static function ( $source, array $fields ) {
10020 + foreach ( $fields as $field ) {
10021 + if ( isset( $source[ $field ] ) && is_numeric( $source[ $field ] ) ) {
10022 + return (float) $source[ $field ];
10023 + }
10024 + }
10025 + return null;
10026 + };
10027 +
10028 + $usage = array(
10029 + 'storage_used' => $pick( $data, array( 'used_storage', 'storage_used', 'used_space' ) ),
10030 + 'storage_total' => $pick( $data, array( 'total_storage', 'storage_total', 'storage', 'total_space' ) ),
10031 + 'credit_used' => $pick( $data, array( 'used_credit', 'credit_used', 'used_credits' ) ),
10032 + 'credit_total' => $pick( $data, array( 'total_credit', 'credit_total', 'credits', 'real_credit' ) ),
10033 + );
10034 +
10035 + $usage = array_filter(
10036 + $usage,
10037 + static function ( $value ) {
10038 + return null !== $value;
10039 + }
10040 + );
10041 +
10042 + if ( empty( $usage ) ) {
10043 + return;
10044 + }
10045 +
10046 + $usage['cached_at'] = gmdate( 'Y-m-d H:i:s' );
10047 +
10048 + // Not autoloaded: read once a week by the heartbeat, never on a front-end request.
10049 + update_option( 'wdkit_cloud_usage', $usage, false );
10050 + }
10051 +
4738 10052 public function wdkit_nxt_thembuilder_reset() {
4739 10053 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
4740 10054 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
4741 10055
@@ -5068,10 +10382,35 @@
5068 10382 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
5069 10383 );
5070 10384 }
5071 10385
5072 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
10386 + // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
10387 + // three segments arrive from $_POST with only wp_unslash() applied — which strips
10388 + // nothing path-relevant — so "../" in any of them walked out of the builder directory
10389 + // and this handler returned the decoded contents of any .json file the web server user
10390 + // could read (CWE-22, ClickUp 86d41zaun).
10391 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
5073 10392
10393 + if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
10394 + return array(
10395 + 'success' => false,
10396 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10397 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10398 + );
10399 + }
10400 +
10401 + $json_path = $safe_path['base'];
10402 +
10403 + // Re-check the resolved file: the component guard above cannot see a symlink. Returns
10404 + // false for a path that does not exist, which is the same answer we want anyway.
10405 + if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
10406 + return array(
10407 + 'success' => false,
10408 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
10409 + 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
10410 + );
10411 + }
10412 +
5074 10413 $json_data = wp_json_file_decode( "$json_path.json" );
5075 10414 if ( ! empty( $json_data ) ) {
5076 10415 $result = (object) array(
5077 10416 'success' => true,
@@ -5097,9 +10436,9 @@
5097 10436 *
5098 10437 * @since 1.0.0
5099 10438 */
5100 10439 protected function wdkit_download_widget() {
5101 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10440 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
5102 10441 $data = json_decode( stripslashes( $data ) );
5103 10442
5104 10443 $array_data = array(
5105 10444 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -5186,10 +10525,15 @@
5186 10525 wp_send_json( $responce );
5187 10526 wp_die();
5188 10527 }
5189 10528
5190 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
5191 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
10529 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
10530 + // already sanitized, which collapsed spaces to hyphens and left the underscore pass
10531 + // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
10532 + // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
10533 + // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
10534 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10535 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
5192 10536 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
5193 10537
5194 10538 if ( ! is_dir( $builder_type_path ) ) {
5195 10539 wp_mkdir_p( $builder_type_path );
@@ -5202,15 +10546,24 @@
5202 10546 if ( ! empty( $img_url ) ) {
5203 10547 // SSRF guard (CWE-918): validate the resolved host before fetching.
5204 10548 $img_body = wdesignkit_safe_remote_get( $img_url );
5205 10549 if ( ! is_wp_error( $img_body ) ) {
5206 - $img_ext = pathinfo( $img_url )['extension'];
10550 + // The remote extension was written verbatim here, so a cloud response naming a
10551 + // ".php" image put executable PHP in the builder directory (CWE-434,
10552 + // ClickUp 86d41cczd). An empty return means the bytes are not an image.
10553 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
5207 10554
5208 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
5209 - $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10555 + if ( '' !== $img_ext ) {
10556 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
10557 + $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10558 + }
5210 10559 }
5211 10560 }
5212 10561
10562 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
10563 + wdesignkit_invalidate_widget_registry( $builder );
10564 + }
10565 +
5213 10566 // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
5214 10567 $result = (object) array(
5215 10568 'success' => true,
5216 10569 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
@@ -5228,9 +10581,9 @@
5228 10581 *
5229 10582 * @since 1.0.0
5230 10583 */
5231 10584 protected function wdkit_add_widget() {
5232 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
10585 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
5233 10586 $data = base64_decode( $data );
5234 10587 $data = json_decode( $data );
5235 10588
5236 10589 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -5286,21 +10639,47 @@
5286 10639
5287 10640 // SSRF guard (CWE-918): validate the resolved host before fetching.
5288 10641 $img_body = wdesignkit_safe_remote_get( $img_url );
5289 10642 if ( ! is_wp_error( $img_body ) ) {
5290 - $img_ext = pathinfo( $img_url )['extension'];
10643 + // Verified against the payload rather than trusted from the URL (CWE-434,
10644 + // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
10645 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
5291 10646 include_once ABSPATH . 'wp-admin/includes/file.php';
5292 10647 \WP_Filesystem();
5293 10648 global $wp_filesystem;
5294 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
5295 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
5296 - $file_path = WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name";
10649 + // Canonical helpers, so the JSON read and the image write here address the same
10650 + // base name every other writer uses (ClickUp 86d41cck5). They also apply
10651 + // sanitize_file_name(), which $title and $w_uniq had not been through.
10652 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
10653 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
5297 10654
5298 - $u_r_l = wp_json_file_decode( "$file_path.json" );
5299 - $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10655 + // $builder reaches here with only sanitize_text_field() applied and no
10656 + // allowlist, so it was a live traversal segment in this path (CWE-22,
10657 + // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
10658 + // one had neither the builder allowlist nor a containment check.
10659 + $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
10660 + if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
10661 + wp_send_json(
10662 + (object) array(
10663 + 'success' => false,
10664 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
10665 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
10666 + )
10667 + );
10668 + wp_die();
10669 + }
5300 10670
10671 + $builder = $safe_path['builder'];
10672 + $file_path = $safe_path['base'];
10673 +
10674 + $u_r_l = wp_json_file_decode( "$file_path.json" );
10675 +
10676 + if ( '' !== $img_ext ) {
10677 + $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
10678 + $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
10679 + }
10680 +
5301 10681 $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
5302 - $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5303 10682 }
5304 10683 }
5305 10684
5306 10685 wp_send_json( $response );
@@ -5401,9 +10780,9 @@
5401 10780 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
5402 10781 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
5403 10782 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
5404 10783 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
5405 - 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
10784 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
5406 10785 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
5407 10786 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
5408 10787 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
5409 10788 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
@@ -5721,9 +11100,16 @@
5721 11100 $token = $this->wdkit_login_user_token( $email );
5722 11101 $args = array( 'token' => $token );
5723 11102
5724 11103 if ( 'session' !== $logout_type ) {
5725 - delete_transient( 'wdkit_auth_' . $email );
11104 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
11105 +
11106 + /* Logged out means the site no longer acts for this account - see
11107 + * Wdkit_Import_Remote::forget_registration(). */
11108 + if ( class_exists( 'Wdkit_Import_Remote' ) ) {
11109 + Wdkit_Import_Remote::forget_registration();
11110 + }
11111 +
5726 11112 // Clear stored license data on logout so banner shows again
5727 11113 delete_option( 'wdkit_licence_data' );
5728 11114 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
5729 11115 }
@@ -5743,9 +11129,9 @@
5743 11129 */
5744 11130 protected function wdkit_login_user_token( $email = '' ) {
5745 11131
5746 11132 if ( ! empty( $email ) ) {
5747 - $user_key = strstr( $email, '@', true );
11133 + $user_key = wdesignkit_cloud_session_key( $email );
5748 11134 $get_login = get_transient( 'wdkit_auth_' . $user_key );
5749 11135
5750 11136 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
5751 11137 return $get_login['token'];
@@ -5763,9 +11149,9 @@
5763 11149 * @param string $data send all post data.
5764 11150 * @param string $type store text data.
5765 11151 * @param string $condition store text data.
5766 11152 */
5767 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
11153 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
5768 11154
5769 11155 if ( 'none' === $condition ) {
5770 11156 return $data[ $type ];
5771 11157 } elseif ( 'cr_widget' === $condition ) {
@@ -5770,8 +11156,10 @@
5770 11156 return $data[ $type ];
5771 11157 } elseif ( 'cr_widget' === $condition ) {
5772 11158 return $data[ $type ];
5773 11159 }
11160 +
11161 + return null;
5774 11162 }
5775 11163
5776 11164
5777 11165 /**
@@ -5925,8 +11313,570 @@
5925 11313 );
5926 11314
5927 11315 wp_send_json( $response );
5928 11316 wp_die();
11317 + }
11318 +
11319 + /**
11320 + * The Gutenberg global-options record a site starts from.
11321 + *
11322 + * Nexter has no defaults of its own until something writes `tpgb_global_options`, and
11323 + * this is what WDesignKit seeds it with: five base colours, the base type scale, the
11324 + * gradients, spacing and box-shadow entries, and an empty container width.
11325 + *
11326 + * Extracted from wdkit_get_global_val() so the PHP import runner can seed the same
11327 + * record. The runner appends the kit's palette to whatever the site already has, and on
11328 + * a site where nothing had written the option yet it was appending to NOTHING — a
11329 + * headless import came out with 13 colours instead of 18, 12 type entries instead of 19
11330 + * and no gradients, spacing or box shadows at all, because the baseline it should have
11331 + * built on did not exist. The wizard never saw it: the globals screen calls
11332 + * wdkit_get_global_val() first, which seeds the option as a side effect.
11333 + *
11334 + * @since 2.7.2
11335 + *
11336 + * @return array
11337 + */
11338 + public static function wdkit_default_gutenberg_globals() {
11339 + return array(
11340 + 'active' => 'preset1',
11341 + 'darkMode' => 'none',
11342 + 'presets' => array(
11343 + 'preset1' => array(
11344 + 'name' => 'Preset 1',
11345 + 'key' => 'preset1',
11346 + 'colors' => array(
11347 + array(
11348 + 'label' => 'Primary',
11349 + 'value' => '#8072FC',
11350 + ),
11351 + array(
11352 + 'label' => 'Secondary',
11353 + 'value' => '#6FC784',
11354 + ),
11355 + array(
11356 + 'label' => 'Tertiary',
11357 + 'value' => '#FF5A6E',
11358 + ),
11359 + array(
11360 + 'label' => 'Accent',
11361 + 'value' => '#F3F3F3',
11362 + ),
11363 + array(
11364 + 'label' => 'Background',
11365 + 'value' => '#888888',
11366 + ),
11367 + ),
11368 + 'gradient' => array(
11369 + array(
11370 + 'label' => 'Primary',
11371 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11372 + ),
11373 +
11374 + array(
11375 + 'label' => 'Secondary',
11376 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11377 + ),
11378 +
11379 + array(
11380 + 'label' => 'Tertiary',
11381 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11382 + ),
11383 +
11384 + array(
11385 + 'label' => 'Accent',
11386 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11387 + ),
11388 +
11389 + array(
11390 + 'label' => 'Background',
11391 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11392 + ),
11393 + ),
11394 + 'spacing' => array(
11395 + array(
11396 + 'label' => 'Large',
11397 + 'value' => array(
11398 + 'md' => 70,
11399 + 'unit' => 'px',
11400 + ),
11401 + ),
11402 + array(
11403 + 'label' => 'Medium',
11404 + 'value' => array(
11405 + 'md' => 40,
11406 + 'unit' => 'px',
11407 + ),
11408 + ),
11409 + array(
11410 + 'label' => 'Small',
11411 + 'value' => array(
11412 + 'md' => 20,
11413 + 'unit' => 'px',
11414 + ),
11415 +
11416 + ),
11417 + ),
11418 + 'typography' => array(
11419 + array(
11420 + 'label' => 'Display Text',
11421 + 'value' => array(
11422 + 'openTypography' => 1,
11423 + 'size' => array(
11424 + 'md' => 65,
11425 + 'unit' => 'px',
11426 + ),
11427 + 'height' => array(
11428 + 'md' => 75,
11429 + 'unit' => 'px',
11430 + ),
11431 + 'fontFamily' => array(
11432 + 'family' => 'Roboto',
11433 + 'type' => 'sans-serif',
11434 + 'fontWeight' => 700,
11435 + ),
11436 + 'spacing' => array(
11437 + 'md' => 0,
11438 + 'unit' => 'px',
11439 + ),
11440 + ),
11441 + ),
11442 + array(
11443 + 'label' => 'Headline',
11444 + 'value' => array(
11445 + 'openTypography' => 1,
11446 + 'size' => array(
11447 + 'md' => 45,
11448 + 'unit' => 'px',
11449 + ),
11450 + 'height' => array(
11451 + 'md' => 60,
11452 + 'unit' => 'px',
11453 + ),
11454 + 'fontFamily' => array(
11455 + 'family' => 'Roboto',
11456 + 'type' => 'sans-serif',
11457 + 'fontWeight' => 700,
11458 + ),
11459 + 'spacing' => array(
11460 + 'md' => 0,
11461 + 'unit' => 'px',
11462 + ),
11463 + ),
11464 + ),
11465 + array(
11466 + 'label' => 'Sub Headline',
11467 + 'value' => array(
11468 + 'openTypography' => 1,
11469 + 'size' => array(
11470 + 'md' => 38,
11471 + 'unit' => 'px',
11472 + ),
11473 + 'height' => array(
11474 + 'md' => 45,
11475 + 'unit' => 'px',
11476 + ),
11477 + 'fontFamily' => array(
11478 + 'family' => 'Roboto',
11479 + 'type' => 'sans-serif',
11480 + 'fontWeight' => 500,
11481 + ),
11482 + 'spacing' => array(
11483 + 'md' => 0,
11484 + 'unit' => 'px',
11485 + ),
11486 + ),
11487 + ),
11488 + array(
11489 + 'label' => 'Title 1',
11490 + 'value' => array(
11491 + 'openTypography' => 1,
11492 + 'size' => array(
11493 + 'md' => 30,
11494 + 'unit' => 'px',
11495 + ),
11496 + 'height' => array(
11497 + 'md' => 40,
11498 + 'unit' => 'px',
11499 + ),
11500 + 'fontFamily' => array(
11501 + 'family' => 'Roboto',
11502 + 'type' => 'sans-serif',
11503 + 'fontWeight' => 500,
11504 + ),
11505 + 'spacing' => array(
11506 + 'md' => 0,
11507 + 'unit' => 'px',
11508 + ),
11509 + ),
11510 + ),
11511 + array(
11512 + 'label' => 'Title 2',
11513 + 'value' => array(
11514 + 'openTypography' => 1,
11515 + 'size' => array(
11516 + 'md' => 25,
11517 + 'unit' => 'px',
11518 + ),
11519 + 'height' => array(
11520 + 'md' => 30,
11521 + 'unit' => 'px',
11522 + ),
11523 + 'fontFamily' => array(
11524 + 'family' => 'Roboto',
11525 + 'type' => 'sans-serif',
11526 + 'fontWeight' => 400,
11527 + ),
11528 + 'spacing' => array(
11529 + 'md' => 0,
11530 + 'unit' => 'px',
11531 + ),
11532 + ),
11533 + ),
11534 + array(
11535 + 'label' => 'Body',
11536 + 'value' => array(
11537 + 'openTypography' => 1,
11538 + 'size' => array(
11539 + 'md' => 17,
11540 + 'unit' => 'px',
11541 + ),
11542 + 'height' => array(
11543 + 'md' => 22,
11544 + 'unit' => 'px',
11545 + ),
11546 + 'fontFamily' => array(
11547 + 'family' => 'Roboto',
11548 + 'type' => 'sans-serif',
11549 + 'fontWeight' => 400,
11550 + ),
11551 + 'spacing' => array(
11552 + 'md' => 0,
11553 + 'unit' => 'px',
11554 + ),
11555 + ),
11556 + ),
11557 + array(
11558 + 'label' => 'Captions',
11559 + 'value' => array(
11560 + 'openTypography' => 1,
11561 + 'size' => array(
11562 + 'md' => 13,
11563 + 'unit' => 'px',
11564 + ),
11565 + 'height' => array(
11566 + 'md' => 16,
11567 + 'unit' => 'px',
11568 + ),
11569 + 'fontFamily' => array(
11570 + 'family' => 'Roboto',
11571 + 'type' => 'sans-serif',
11572 + 'fontWeight' => 400,
11573 + ),
11574 + 'spacing' => array(
11575 + 'md' => 0,
11576 + 'unit' => 'px',
11577 + ),
11578 + ),
11579 + ),
11580 + ),
11581 + 'boxshadow' => array(
11582 + array(
11583 + 'label' => 'Normal Shadow',
11584 + 'value' => array(
11585 + 'openShadow' => 1,
11586 + 'inset' => 0,
11587 + 'horizontal' => 2,
11588 + 'vertical' => 6,
11589 + 'blur' => 10,
11590 + 'spread' => 0,
11591 + 'color' => 'rgba(0,0,0,0.15)',
11592 + ),
11593 + ),
11594 + array(
11595 + 'label' => 'Hover Shadow',
11596 + 'value' => array(
11597 + 'openShadow' => 1,
11598 + 'inset' => 0,
11599 + 'horizontal' => 2,
11600 + 'vertical' => 5,
11601 + 'blur' => 14,
11602 + 'spread' => 3,
11603 + 'color' => 'rgba(0,0,0,0.2)',
11604 + ),
11605 + ),
11606 + ),
11607 + ),
11608 + 'preset2' => array(
11609 + 'name' => 'Preset 2',
11610 + 'key' => 'preset2',
11611 + 'colors' => array(
11612 + array(
11613 + 'label' => 'Primary',
11614 + 'value' => '#8072FC',
11615 + ),
11616 + array(
11617 + 'label' => 'Secondary',
11618 + 'value' => '#6FC784',
11619 + ),
11620 + array(
11621 + 'label' => 'Tertiary',
11622 + 'value' => '#FF5A6E',
11623 + ),
11624 + array(
11625 + 'label' => 'Accent',
11626 + 'value' => '#F3F3F3',
11627 + ),
11628 + array(
11629 + 'label' => 'Background',
11630 + 'value' => '#888888',
11631 + ),
11632 + ),
11633 + 'gradient' => array(
11634 + array(
11635 + 'label' => 'Primary',
11636 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11637 + ),
11638 +
11639 + array(
11640 + 'label' => 'Secondary',
11641 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11642 + ),
11643 +
11644 + array(
11645 + 'label' => 'Tertiary',
11646 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11647 + ),
11648 +
11649 + array(
11650 + 'label' => 'Accent',
11651 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11652 + ),
11653 +
11654 + array(
11655 + 'label' => 'Background',
11656 + 'value' => 'linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)',
11657 + ),
11658 + ),
11659 + 'spacing' => array(
11660 + array(
11661 + 'label' => 'Large',
11662 + 'value' => array(
11663 + 'md' => 70,
11664 + 'unit' => 'px',
11665 + ),
11666 + ),
11667 + array(
11668 + 'label' => 'Medium',
11669 + 'value' => array(
11670 + 'md' => 40,
11671 + 'unit' => 'px',
11672 + ),
11673 + ),
11674 + array(
11675 + 'label' => 'Small',
11676 + 'value' => array(
11677 + 'md' => 20,
11678 + 'unit' => 'px',
11679 + ),
11680 +
11681 + ),
11682 + ),
11683 + 'typography' => array(
11684 + array(
11685 + 'label' => 'Display Text',
11686 + 'value' => array(
11687 + 'openTypography' => 1,
11688 + 'size' => array(
11689 + 'md' => 65,
11690 + 'unit' => 'px',
11691 + ),
11692 + 'height' => array(
11693 + 'md' => 75,
11694 + 'unit' => 'px',
11695 + ),
11696 + 'fontFamily' => array(
11697 + 'family' => 'Roboto',
11698 + 'type' => 'sans-serif',
11699 + 'fontWeight' => 700,
11700 + ),
11701 + 'spacing' => array(
11702 + 'md' => 0,
11703 + 'unit' => 'px',
11704 + ),
11705 + ),
11706 + ),
11707 + array(
11708 + 'label' => 'Headline',
11709 + 'value' => array(
11710 + 'openTypography' => 1,
11711 + 'size' => array(
11712 + 'md' => 45,
11713 + 'unit' => 'px',
11714 + ),
11715 + 'height' => array(
11716 + 'md' => 60,
11717 + 'unit' => 'px',
11718 + ),
11719 + 'fontFamily' => array(
11720 + 'family' => 'Roboto',
11721 + 'type' => 'sans-serif',
11722 + 'fontWeight' => 700,
11723 + ),
11724 + 'spacing' => array(
11725 + 'md' => 0,
11726 + 'unit' => 'px',
11727 + ),
11728 + ),
11729 + ),
11730 + array(
11731 + 'label' => 'Sub Headline',
11732 + 'value' => array(
11733 + 'openTypography' => 1,
11734 + 'size' => array(
11735 + 'md' => 38,
11736 + 'unit' => 'px',
11737 + ),
11738 + 'height' => array(
11739 + 'md' => 45,
11740 + 'unit' => 'px',
11741 + ),
11742 + 'fontFamily' => array(
11743 + 'family' => 'Roboto',
11744 + 'type' => 'sans-serif',
11745 + 'fontWeight' => 500,
11746 + ),
11747 + 'spacing' => array(
11748 + 'md' => 0,
11749 + 'unit' => 'px',
11750 + ),
11751 + ),
11752 + ),
11753 + array(
11754 + 'label' => 'Title 1',
11755 + 'value' => array(
11756 + 'openTypography' => 1,
11757 + 'size' => array(
11758 + 'md' => 30,
11759 + 'unit' => 'px',
11760 + ),
11761 + 'height' => array(
11762 + 'md' => 40,
11763 + 'unit' => 'px',
11764 + ),
11765 + 'fontFamily' => array(
11766 + 'family' => 'Roboto',
11767 + 'type' => 'sans-serif',
11768 + 'fontWeight' => 500,
11769 + ),
11770 + 'spacing' => array(
11771 + 'md' => 0,
11772 + 'unit' => 'px',
11773 + ),
11774 + ),
11775 + ),
11776 + array(
11777 + 'label' => 'Title 2',
11778 + 'value' => array(
11779 + 'openTypography' => 1,
11780 + 'size' => array(
11781 + 'md' => 25,
11782 + 'unit' => 'px',
11783 + ),
11784 + 'height' => array(
11785 + 'md' => 30,
11786 + 'unit' => 'px',
11787 + ),
11788 + 'fontFamily' => array(
11789 + 'family' => 'Roboto',
11790 + 'type' => 'sans-serif',
11791 + 'fontWeight' => 400,
11792 + ),
11793 + 'spacing' => array(
11794 + 'md' => 0,
11795 + 'unit' => 'px',
11796 + ),
11797 + ),
11798 + ),
11799 + array(
11800 + 'label' => 'Body',
11801 + 'value' => array(
11802 + 'openTypography' => 1,
11803 + 'size' => array(
11804 + 'md' => 17,
11805 + 'unit' => 'px',
11806 + ),
11807 + 'height' => array(
11808 + 'md' => 22,
11809 + 'unit' => 'px',
11810 + ),
11811 + 'fontFamily' => array(
11812 + 'family' => 'Roboto',
11813 + 'type' => 'sans-serif',
11814 + 'fontWeight' => 400,
11815 + ),
11816 + 'spacing' => array(
11817 + 'md' => 0,
11818 + 'unit' => 'px',
11819 + ),
11820 + ),
11821 + ),
11822 + array(
11823 + 'label' => 'Captions',
11824 + 'value' => array(
11825 + 'openTypography' => 1,
11826 + 'size' => array(
11827 + 'md' => 13,
11828 + 'unit' => 'px',
11829 + ),
11830 + 'height' => array(
11831 + 'md' => 16,
11832 + 'unit' => 'px',
11833 + ),
11834 + 'fontFamily' => array(
11835 + 'family' => 'Roboto',
11836 + 'type' => 'sans-serif',
11837 + 'fontWeight' => 400,
11838 + ),
11839 + 'spacing' => array(
11840 + 'md' => 0,
11841 + 'unit' => 'px',
11842 + ),
11843 + ),
11844 + ),
11845 + ),
11846 + 'boxshadow' => array(
11847 + array(
11848 + 'label' => 'Normal Shadow',
11849 + 'value' => array(
11850 + 'openShadow' => 1,
11851 + 'inset' => 0,
11852 + 'horizontal' => 2,
11853 + 'vertical' => 6,
11854 + 'blur' => 10,
11855 + 'spread' => 0,
11856 + 'color' => 'rgba(0,0,0,0.15)',
11857 + ),
11858 + ),
11859 + array(
11860 + 'label' => 'Hover Shadow',
11861 + 'value' => array(
11862 + 'openShadow' => 1,
11863 + 'inset' => 0,
11864 + 'horizontal' => 2,
11865 + 'vertical' => 5,
11866 + 'blur' => 14,
11867 + 'spread' => 3,
11868 + 'color' => 'rgba(0,0,0,0.2)',
11869 + ),
11870 + ),
11871 + ),
11872 + ),
11873 + ),
11874 + 'globalContainer' => array(
11875 + 'md' => '',
11876 + 'unit' => 'px',
11877 + ),
11878 + );
5929 11879 }
5930 11880 }
5931 11881
5932 11882 Wdkit_Api_Call::get_instance();