| 1 |
<?php |
| 2 |
/** |
| 3 |
* Programmatic hooks for external plugins to trigger WDesignKit's kit import |
| 4 |
* and full site creation flows without duplicating any internal logic. |
| 5 |
* |
| 6 |
* ────────────────────────────────────────────────────────────────────────── |
| 7 |
* HOOK 1 — Single template fetch (building block) |
| 8 |
* ────────────────────────────────────────────────────────────────────────── |
| 9 |
* $result = apply_filters( 'wdkit_import_kit_content', [], [ |
| 10 |
* 'template_id' => 'abc123', // required |
| 11 |
* 'editor' => 'elementor', // 'elementor' | 'gutenberg' |
| 12 |
* 'website_kit' => 'my-kit-slug', // optional |
| 13 |
* 'api_type' => 'import_template', // cloud endpoint (note: import_kit_template has no cloud route) |
| 14 |
* 'custom_meta' => false, // restore nxt-* post meta |
| 15 |
* ] ); |
| 16 |
* |
| 17 |
* ────────────────────────────────────────────────────────────────────────── |
| 18 |
* HOOK 2 — Full site creation (master hook — use this from Sprout MCP) |
| 19 |
* ────────────────────────────────────────────────────────────────────────── |
| 20 |
* $result = apply_filters( 'wdkit_create_full_site', [], [ |
| 21 |
* 'kit_id' => 'my-kit-slug', // required — WDesignKit kit ID |
| 22 |
* 'editor' => 'elementor', // 'elementor' | 'gutenberg' |
| 23 |
* 'templates' => [ // required — list from cloud |
| 24 |
* [ |
| 25 |
* 'id' => 'tpl-123', |
| 26 |
* 'title' => 'Home|Landing', |
| 27 |
* 'type' => 'page', // 'page' | 'section' |
| 28 |
* 'wp_post_type' => 'page', |
| 29 |
* ], |
| 30 |
* // ... more templates |
| 31 |
* ], |
| 32 |
* 'site_name' => 'My Business', // optional |
| 33 |
* 'tagline' => 'We build things', // optional |
| 34 |
* 'skip' => ['reset_site'], // optional steps to skip |
| 35 |
* ] ); |
| 36 |
* |
| 37 |
* Progress lifecycle (fire-and-forget — Sprout listens, WDesignKit fires): |
| 38 |
* add_action( 'wdkit_site_step', function( $step, $status, $data ) { }, 10, 3 ); |
| 39 |
* $step : 'reset_site' | 'plugin_settings' | 'theme_settings' | |
| 40 |
* 'import_pages' | 'enable_widgets' | 'finalize' |
| 41 |
* $status : 'start' | 'done' | 'fail' |
| 42 |
* $data : context array for the step |
| 43 |
* |
| 44 |
* @package Wdesignkit |
| 45 |
* @since 2.3.1 |
| 46 |
*/ |
| 47 |
|
| 48 |
if ( ! defined( 'ABSPATH' ) ) { |
| 49 |
exit; |
| 50 |
} |
| 51 |
|
| 52 |
// ─── Hook 1: single template fetch ────────────────────────────────────────── |
| 53 |
add_filter( 'wdkit_import_kit_content', 'wdkit_handle_kit_import_hook', 10, 2 ); |
| 54 |
|
| 55 |
// ─── Hook 2: full site creation ───────────────────────────────────────────── |
| 56 |
add_filter( 'wdkit_create_full_site', 'wdkit_handle_create_full_site', 10, 2 ); |
| 57 |
|
| 58 |
// ════════════════════════════════════════════════════════════════════════════ |
| 59 |
// HOOK 1 — Single template fetch |
| 60 |
// ════════════════════════════════════════════════════════════════════════════ |
| 61 |
|
| 62 |
/** |
| 63 |
* Handle the wdkit_import_kit_content filter. |
| 64 |
* Fetches one template's JSON content from WDesignKit cloud. |
| 65 |
* |
| 66 |
* @param array $output Ignored — always overwritten. |
| 67 |
* @param array $args Import arguments. Required: template_id. |
| 68 |
* @return array{success:bool,message:string,description:string,id:string,response:array} |
| 69 |
*/ |
| 70 |
function wdkit_handle_kit_import_hook( array $output, array $args ): array { |
| 71 |
|
| 72 |
if ( ! class_exists( 'WDesignKit_Data_Query' ) ) { |
| 73 |
return [ |
| 74 |
'success' => false, |
| 75 |
'message' => __( 'WDesignKit plugin is not loaded.', 'wdesignkit' ), |
| 76 |
'description' => '', |
| 77 |
'id' => '', |
| 78 |
'response' => [], |
| 79 |
]; |
| 80 |
} |
| 81 |
|
| 82 |
$token = wdkit_kit_import_resolve_token(); |
| 83 |
if ( ! wdkit_kit_import_can_authenticate( $token ) ) { |
| 84 |
return [ |
| 85 |
'success' => false, |
| 86 |
'message' => __( 'Not logged in to WDesignKit cloud. Go to WP Admin → WDesignKit and click Login.', 'wdesignkit' ), |
| 87 |
'description' => '', |
| 88 |
'id' => '', |
| 89 |
'response' => [], |
| 90 |
]; |
| 91 |
} |
| 92 |
|
| 93 |
$template_id = sanitize_text_field( (string) ( $args['template_id'] ?? '' ) ); |
| 94 |
$editor = sanitize_text_field( (string) ( $args['editor'] ?? 'elementor' ) ); |
| 95 |
$website_kit = sanitize_text_field( (string) ( $args['website_kit'] ?? '' ) ); |
| 96 |
// Default to 'import_template' — get_data() posts to api/wp/{api_type}, and there is NO |
| 97 |
// api/wp/import_kit_template route on the cloud (it 404s → "Unexpected response"). The |
| 98 |
// working endpoint is import_template, which is also what wdkit_import_kit_template() uses. |
| 99 |
$api_type = sanitize_text_field( (string) ( $args['api_type'] ?? 'import_template' ) ); |
| 100 |
$custom_meta = ! empty( $args['custom_meta'] ); |
| 101 |
|
| 102 |
if ( $template_id === '' ) { |
| 103 |
return [ |
| 104 |
'success' => false, |
| 105 |
'message' => __( 'template_id is required.', 'wdesignkit' ), |
| 106 |
'description' => '', |
| 107 |
'id' => '', |
| 108 |
'response' => [], |
| 109 |
]; |
| 110 |
} |
| 111 |
|
| 112 |
// Mirrors $temp_args in wdkit_import_kit_template() exactly — only these |
| 113 |
// five keys are sent to the cloud for a kit import. |
| 114 |
$cloud_args = wdkit_kit_import_with_site_identity( |
| 115 |
[ |
| 116 |
'token' => $token, |
| 117 |
'template_id' => $template_id, |
| 118 |
'editor' => $editor, |
| 119 |
'website_kit' => $website_kit, |
| 120 |
'unique_id' => get_option( 'wdkit_unique_id', '' ), |
| 121 |
] |
| 122 |
); |
| 123 |
|
| 124 |
/** |
| 125 |
* Fires just before WDesignKit makes the cloud import request. |
| 126 |
* |
| 127 |
* @param string $template_id Template ID being imported. |
| 128 |
* @param array $cloud_args Arguments sent to the cloud API. |
| 129 |
*/ |
| 130 |
do_action( 'wdkit_before_kit_import', $template_id, $cloud_args ); |
| 131 |
|
| 132 |
$response = WDesignKit_Data_Query::get_data( $api_type, $cloud_args ); |
| 133 |
|
| 134 |
if ( is_wp_error( $response ) ) { |
| 135 |
return [ |
| 136 |
'success' => false, |
| 137 |
'message' => $response->get_error_message(), |
| 138 |
'description' => '', |
| 139 |
'id' => $template_id, |
| 140 |
'response' => [], |
| 141 |
]; |
| 142 |
} |
| 143 |
|
| 144 |
if ( ! is_array( $response ) ) { |
| 145 |
return [ |
| 146 |
'success' => false, |
| 147 |
'message' => __( 'Unexpected response from WDesignKit cloud.', 'wdesignkit' ), |
| 148 |
'description' => '', |
| 149 |
'id' => $template_id, |
| 150 |
'response' => [], |
| 151 |
]; |
| 152 |
} |
| 153 |
|
| 154 |
// Cloud signals a soft failure via content === 'error' (line 2866 in class-api.php). |
| 155 |
if ( isset( $response['content'] ) && 'error' === $response['content'] ) { |
| 156 |
return [ |
| 157 |
'success' => false, |
| 158 |
'message' => $response['message'] ?? 'Cloud returned a content error.', |
| 159 |
'description' => $response['description'] ?? '', |
| 160 |
'id' => $template_id, |
| 161 |
'response' => $response, |
| 162 |
]; |
| 163 |
} |
| 164 |
|
| 165 |
// Restore nxt-* post meta onto the current post when custom_meta is requested. |
| 166 |
if ( $custom_meta && ! empty( $response['content'] ) ) { |
| 167 |
$current_post_id = get_the_ID(); |
| 168 |
$decoded = json_decode( (string) $response['content'], true ); |
| 169 |
|
| 170 |
if ( $current_post_id && is_array( $decoded ) && ! empty( $decoded['custom_meta'] ) ) { |
| 171 |
foreach ( $decoded['custom_meta'] as $meta_key => $meta_val ) { |
| 172 |
$value = $meta_val[0] ?? null; |
| 173 |
if ( is_string( $value ) && is_serialized( $value ) ) { |
| 174 |
// allowed_classes => false: $value comes from the imported kit body, so a |
| 175 |
// serialized object here would be instantiated and could fire a POP gadget |
| 176 |
// chain in any loaded plugin or theme (CWE-502, ClickUp 86d41zauw). |
| 177 |
// |
| 178 |
// maybe_unserialize() CANNOT express this — WP core declares it as |
| 179 |
// maybe_unserialize( $data ) and calls @unserialize( trim( $data ) ) with no |
| 180 |
// options, so an options array passed to it is silently ignored and the object |
| 181 |
// is still built. The is_serialized() check above already gates this call, so |
| 182 |
// unserialize() direct is an exact drop-in. Legitimate values here are arrays |
| 183 |
// (theme-builder conditions and similar); anything object-shaped is not. |
| 184 |
$value = unserialize( $value, array( 'allowed_classes' => false ) ); |
| 185 |
} |
| 186 |
if ( get_post_meta( $current_post_id, $meta_key, true ) === '' ) { |
| 187 |
add_post_meta( $current_post_id, $meta_key, $value ); |
| 188 |
} else { |
| 189 |
update_post_meta( $current_post_id, $meta_key, $value ); |
| 190 |
} |
| 191 |
} |
| 192 |
} |
| 193 |
} |
| 194 |
|
| 195 |
$result = [ |
| 196 |
'success' => (bool) ( $response['success'] ?? ! empty( $response['content'] ) ), |
| 197 |
'message' => $response['message'] ?? '', |
| 198 |
'description' => $response['description'] ?? '', |
| 199 |
'id' => $template_id, |
| 200 |
'response' => $response, |
| 201 |
]; |
| 202 |
|
| 203 |
/** |
| 204 |
* Fires after the kit import attempt completes. |
| 205 |
* |
| 206 |
* @param array $result Import result. |
| 207 |
* @param string $template_id Template ID that was imported. |
| 208 |
*/ |
| 209 |
do_action( 'wdkit_after_kit_import', $result, $template_id ); |
| 210 |
|
| 211 |
return $result; |
| 212 |
} |
| 213 |
|
| 214 |
// ════════════════════════════════════════════════════════════════════════════ |
| 215 |
// HOOK 2 — Full site creation |
| 216 |
// ════════════════════════════════════════════════════════════════════════════ |
| 217 |
|
| 218 |
/** |
| 219 |
* Handle the wdkit_create_full_site filter. |
| 220 |
* |
| 221 |
* Runs all 4 site-creation steps internally; fires wdkit_site_step actions |
| 222 |
* at start/done/fail of each step so external plugins can track progress |
| 223 |
* without knowing the internal sequence. |
| 224 |
* |
| 225 |
* @param array $output Ignored — always overwritten. |
| 226 |
* @param array $args { |
| 227 |
* @type string $kit_id WDesignKit kit ID (required). |
| 228 |
* @type string $editor 'elementor' | 'gutenberg' (default: 'elementor'). |
| 229 |
* @type array $templates List of template objects from cloud (required). |
| 230 |
* @type string $site_name Optional site title. |
| 231 |
* @type string $tagline Optional site tagline. |
| 232 |
* @type string[] $skip Steps to skip: 'reset_site', 'plugin_settings', |
| 233 |
* 'theme_settings', 'enable_widgets'. |
| 234 |
* @type string $session_id Optional. Pass a session id from a previous response to RESUME |
| 235 |
* that import: templates that already landed are skipped and only |
| 236 |
* the unfinished ones run. Omit to start a fresh import. |
| 237 |
* |
| 238 |
* Optional and additive — omitting all of these gives exactly the previous behaviour: |
| 239 |
* @type string $import_type 'normal_import' | 'ai_import'. |
| 240 |
* @type string $site_type Business/site type (required when import_type=ai_import). |
| 241 |
* @type string $site_description Site description (required when import_type=ai_import). |
| 242 |
* @type array $ai_document Pre-generated AI content: {pages, products, posts, taxonomy}. |
| 243 |
* @type array $images Chosen stock images: [{url,width,height}]. |
| 244 |
* @type array $products Product records for WooCommerce. |
| 245 |
* @type bool $blog_post Import blog posts (default true). |
| 246 |
* @type array $plugin_catalogue Catalogue the templates' plugins_id resolve against. |
| 247 |
* @type array $site_global Site-level globals (container width, body background). |
| 248 |
* @type bool $allow_destructive_cleanup Default false. Gates every permanent deletion. |
| 249 |
* } |
| 250 |
* @return array{success:bool,message:string,site_url:string,home_page_id:int,shop_page_id:int,pages:array,steps:array,errors:array,session_id:string,status:string} |
| 251 |
*/ |
| 252 |
function wdkit_handle_create_full_site( array $output, array $args ): array { |
| 253 |
|
| 254 |
// ── Guard ──────────────────────────────────────────────────────────────── |
| 255 |
if ( ! class_exists( 'WDesignKit_Data_Query' ) ) { |
| 256 |
return wdkit_site_error( 'WDesignKit plugin is not loaded.' ); |
| 257 |
} |
| 258 |
|
| 259 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 260 |
return wdkit_site_error( 'Insufficient permissions.' ); |
| 261 |
} |
| 262 |
|
| 263 |
$token = wdkit_kit_import_resolve_token(); |
| 264 |
if ( ! wdkit_kit_import_can_authenticate( $token ) ) { |
| 265 |
return wdkit_site_error( 'Not logged in to WDesignKit cloud. Go to WP Admin → WDesignKit and click Login.' ); |
| 266 |
} |
| 267 |
|
| 268 |
if ( ! class_exists( 'Wdkit_Import_Bridge' ) ) { |
| 269 |
return wdkit_site_error( 'Import engine is not available.' ); |
| 270 |
} |
| 271 |
|
| 272 |
// ── Delegate ───────────────────────────────────────────────────────────── |
| 273 |
// The three guards above are unchanged and still run first, in the same order, so an |
| 274 |
// unauthenticated or under-privileged caller gets the same refusal it always got. |
| 275 |
// |
| 276 |
// Everything after them used to be ~280 lines of inline sequence here. It is now |
| 277 |
// Wdkit_Import_Runner, reached through Wdkit_Import_Bridge, which preserves this |
| 278 |
// function's args, its `wdkit_site_step` events and its return shape. What the caller |
| 279 |
// gains is the work the inline version could not do: AI merge, image substitution and |
| 280 |
// sideload, taxonomy, WooCommerce products, blog posts, navigation rewriting, global |
| 281 |
// colours and typography, theme-builder conditions, and resume after a failure. |
| 282 |
// |
| 283 |
// Resume: the response now carries `session_id`. Passing it back as `session_id` on a |
| 284 |
// later call re-runs only the templates that did not finish. |
| 285 |
return Wdkit_Import_Bridge::create_full_site( $args ); |
| 286 |
} |
| 287 |
|
| 288 |
// ════════════════════════════════════════════════════════════════════════════ |
| 289 |
// Shared helpers |
| 290 |
// ════════════════════════════════════════════════════════════════════════════ |
| 291 |
|
| 292 |
/** |
| 293 |
* Add this site's own credential to a kit-content request. |
| 294 |
* |
| 295 |
* Kit content is normally authenticated with the signed-in account's JWT. A sandbox has no |
| 296 |
* signed-in account — it is created for the user, not by them — so the cloud accepts the site's |
| 297 |
* poll token instead, which already identifies both the site and the account that owns it. |
| 298 |
* |
| 299 |
* Sent alongside the JWT rather than in place of it: when both are present the cloud prefers the |
| 300 |
* JWT, so an ordinary logged-in site behaves exactly as before. |
| 301 |
* |
| 302 |
* @param array $args Cloud request arguments. |
| 303 |
* @return array The same arguments, with poll_token and site_url added when this site has one. |
| 304 |
*/ |
| 305 |
function wdkit_kit_import_with_site_identity( array $args ): array { |
| 306 |
if ( ! class_exists( 'Wdkit_Import_Remote' ) ) { |
| 307 |
return $args; |
| 308 |
} |
| 309 |
|
| 310 |
return array_merge( $args, Wdkit_Import_Remote::cloud_identity() ); |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* Whether a kit-content request can be authenticated at all. |
| 315 |
* |
| 316 |
* Either credential is enough: an account JWT, or this site's own poll token. |
| 317 |
* |
| 318 |
* @param string $token Resolved cloud JWT, possibly ''. |
| 319 |
* @return bool |
| 320 |
*/ |
| 321 |
function wdkit_kit_import_can_authenticate( string $token ): bool { |
| 322 |
if ( '' !== $token ) { |
| 323 |
return true; |
| 324 |
} |
| 325 |
|
| 326 |
return class_exists( 'Wdkit_Import_Remote' ) && array() !== Wdkit_Import_Remote::cloud_identity(); |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Resolve an active WDesignKit cloud token without depending on the abilities system. |
| 331 |
* Checks current WP user transient first, then scans all wdkit_auth_* transients. |
| 332 |
* |
| 333 |
* @return string Token string, or empty string if not logged in. |
| 334 |
*/ |
| 335 |
function wdkit_kit_import_resolve_token(): string { |
| 336 |
// Shared with every cloud ability — see wdesignkit_mcp_find_auth_session() in |
| 337 |
// includes/abilities/class-wdk-ability-main.php. It checks the account recorded at login |
| 338 |
// first, so the session is found even though its transient is keyed off the CLOUD email |
| 339 |
// rather than the WordPress user's. The local lookup below only tried the WP user's key and |
| 340 |
// then an unordered LIMIT 10 scan, which reported "not logged in" straight after a |
| 341 |
// successful login on any site that had accumulated a few stored sessions. |
| 342 |
if ( function_exists( 'wdesignkit_mcp_find_auth_session' ) ) { |
| 343 |
$session = wdesignkit_mcp_find_auth_session(); |
| 344 |
|
| 345 |
return ! empty( $session['found'] ) ? (string) $session['data']['token'] : ''; |
| 346 |
} |
| 347 |
|
| 348 |
// Fallback for the (unexpected) case where the abilities loader has not run. |
| 349 |
$current_user = wp_get_current_user(); |
| 350 |
if ( $current_user && $current_user->user_email ) { |
| 351 |
$key = strstr( $current_user->user_email, '@', true ); |
| 352 |
$data = get_transient( 'wdkit_auth_' . $key ); |
| 353 |
if ( ! empty( $data['token'] ) ) { |
| 354 |
return (string) $data['token']; |
| 355 |
} |
| 356 |
} |
| 357 |
|
| 358 |
global $wpdb; |
| 359 |
$rows = $wpdb->get_results( |
| 360 |
$wpdb->prepare( |
| 361 |
"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 362 |
$wpdb->esc_like( '_transient_wdkit_auth_' ) . '%' |
| 363 |
), |
| 364 |
ARRAY_A |
| 365 |
); |
| 366 |
|
| 367 |
foreach ( ( $rows ?: [] ) as $row ) { |
| 368 |
$key = str_replace( '_transient_', '', $row['option_name'] ); |
| 369 |
$timeout = get_option( '_transient_timeout_' . $key ); |
| 370 |
if ( $timeout && (int) $timeout < time() ) { |
| 371 |
continue; |
| 372 |
} |
| 373 |
$data = @maybe_unserialize( $row['option_value'] ); |
| 374 |
if ( is_array( $data ) && ! empty( $data['token'] ) ) { |
| 375 |
return (string) $data['token']; |
| 376 |
} |
| 377 |
} |
| 378 |
|
| 379 |
return ''; |
| 380 |
} |
| 381 |
|
| 382 |
/** |
| 383 |
* Build a standard error return for wdkit_create_full_site. |
| 384 |
* |
| 385 |
* @param string $message Human-readable error. |
| 386 |
* @return array |
| 387 |
*/ |
| 388 |
function wdkit_site_error( string $message ): array { |
| 389 |
return [ |
| 390 |
'success' => false, |
| 391 |
'message' => $message, |
| 392 |
'site_url' => '', |
| 393 |
'home_page_id' => 0, |
| 394 |
'shop_page_id' => 0, |
| 395 |
'pages' => [], |
| 396 |
'steps' => [], |
| 397 |
'errors' => [ [ 'message' => $message ] ], |
| 398 |
]; |
| 399 |
} |
| 400 |
|