| 1 |
<?php |
| 2 |
/** |
| 3 |
* PHP import runner. |
| 4 |
* |
| 5 |
* Executes an import in the same four stages the browser importer reports, without a browser: |
| 6 |
* |
| 7 |
* install_plugin_theme → plugin/theme dependencies |
| 8 |
* importing_site_content → per template: fetch, AI merge, image substitution, insert |
| 9 |
* setting_up_site → site name/tagline, front page |
| 10 |
* finalizing_site_setting → widgets, cleanup |
| 11 |
* |
| 12 |
* ── What this is and is not ───────────────────────────────────────────────── |
| 13 |
* |
| 14 |
* It is the engine behind `wdkit_handle_create_full_site()` — the PHP-side full-site import |
| 15 |
* entry point that the WP Abilities / MCP surface already exposed. It is NOT wired into the |
| 16 |
* browser wizard: import_loader.js still orchestrates its own ~25 admin-ajax calls exactly as |
| 17 |
* before, and nothing in this file is reachable from that path. |
| 18 |
* |
| 19 |
* There is no endpoint of its own, no REST route, no polling and no webhook. A caller reaches |
| 20 |
* it either through that existing filter or by calling `for_context()` directly in PHP. |
| 21 |
* |
| 22 |
* The stage names match import_loader.js deliberately, so progress from either path means the |
| 23 |
* same thing. `wdkit_import_runner_stage` is fired at every stage boundary so an entry point |
| 24 |
* can republish progress in its own vocabulary. |
| 25 |
* |
| 26 |
* ── Deliberate limits, stated plainly ─────────────────────────────────────── |
| 27 |
* |
| 28 |
* Implemented: kit dependency resolution and plugin/theme install, optional site reset, |
| 29 |
* taxonomy, page and section creation for both builders, AI merge from a pre-generated |
| 30 |
* document, stock and team image substitution with sideload, global colours and typography, |
| 31 |
* plugin/theme/site settings, navigation rewriting, theme-builder condition rewriting, |
| 32 |
* WooCommerce products, blog posts, widget enabling, guarded cleanup, resumable per-item |
| 33 |
* progress, AI payload cleanup. |
| 34 |
* |
| 35 |
* Verified against real WordPress: page import (both builders), AI merge, failure/resume/ |
| 36 |
* idempotency, and the destructive-cleanup guards. NOT verified at runtime: product creation |
| 37 |
* and blog-post creation (environment blocked — no WooCommerce, and the post count gate). |
| 38 |
* |
| 39 |
* NOT implemented, and deliberately so: |
| 40 |
* |
| 41 |
* Calling the AI generators. `wkit_generate_product_data` / `wkit_generate_post_data` / |
| 42 |
* `ai/template_import_batch` are cloud calls tied to a user token and credit balance. |
| 43 |
* The runner consumes an already-generated document instead — which is the point: a |
| 44 |
* remote caller generates once, on its own account, then hands the result over. |
| 45 |
* |
| 46 |
* `reset_site` IS implemented (Wdkit_Import_Reset) but defaults to false. It is destructive to |
| 47 |
* content the importer did not create, so it only runs when a caller explicitly asks. |
| 48 |
* |
| 49 |
* @package Wdesignkit |
| 50 |
* @since 2.6.5 |
| 51 |
*/ |
| 52 |
|
| 53 |
if ( ! defined( 'ABSPATH' ) ) { |
| 54 |
exit; |
| 55 |
} |
| 56 |
|
| 57 |
if ( ! class_exists( 'Wdkit_Import_Runner' ) ) { |
| 58 |
|
| 59 |
/** |
| 60 |
* Stage-based PHP importer. |
| 61 |
*/ |
| 62 |
class Wdkit_Import_Runner { |
| 63 |
|
| 64 |
/** Stage names, shared with the browser importer. */ |
| 65 |
const STAGE_DEPENDENCIES = 'install_plugin_theme'; |
| 66 |
const STAGE_CONTENT = 'importing_site_content'; |
| 67 |
const STAGE_SETUP = 'setting_up_site'; |
| 68 |
const STAGE_FINALIZE = 'finalizing_site_setting'; |
| 69 |
|
| 70 |
/** |
| 71 |
* Wall-clock budget for ONE stage request, in seconds. |
| 72 |
* |
| 73 |
* The wizard drives the runner over admin-ajax, so a stage request is bound by PHP's |
| 74 |
* max_execution_time — 30s on a default install. A 13-template kit with media does not |
| 75 |
* fit, and the request dies inside GD/Imagick with "Maximum execution time exceeded", |
| 76 |
* which the browser sees as a failed stage. |
| 77 |
* |
| 78 |
* So a stage does as much as it can inside this budget and reports `complete => false` |
| 79 |
* with what is left; the browser calls the same stage again. Every item already has its |
| 80 |
* own session guard, so resuming mid-stage re-does nothing. The old importer stayed |
| 81 |
* under the limit by making ~25 small requests — this achieves the same thing without |
| 82 |
* giving up the 4-row progress UI. |
| 83 |
* |
| 84 |
* Deliberately well under 30s: one more template must be able to finish after the check. |
| 85 |
*/ |
| 86 |
const STAGE_BUDGET = 8; |
| 87 |
|
| 88 |
/** |
| 89 |
* Hardest bound on one stage request: how many templates it may take on. |
| 90 |
* |
| 91 |
* The time budget alone is not enough. It is checked BETWEEN templates, so a template |
| 92 |
* that starts just under the deadline still runs to completion — and one template with |
| 93 |
* media costs 5-9s. Measured slices with an 8s budget and no cap ran 11s to 39.5s; the |
| 94 |
* 39.5s one only survived because set_time_limit(0) happened to work on that host. |
| 95 |
* |
| 96 |
* ONE. Measured: with a cap of 2 the slices were mostly 1.5-3.4s, but one hit 28.8s — |
| 97 |
* a single media-heavy template can cost nearly the whole limit by itself, so any cap |
| 98 |
* above one leaves a pathological pair able to blow it. At one template per request the |
| 99 |
* worst case IS one template, which is exactly the granularity the existing browser |
| 100 |
* importer has always used (one import_page_section call per template). More requests, |
| 101 |
* each no larger than what already works in production. |
| 102 |
* |
| 103 |
* ── Raised, because what made a template expensive is gone ────────────────── |
| 104 |
* |
| 105 |
* That 28.8s template was expensive for two reasons, and the merge removed both: |
| 106 |
* |
| 107 |
* - it fetched its own payload from the cloud (one assembled round trip per |
| 108 |
* template). The kit now arrives in ONE response, primed before the loop starts by |
| 109 |
* Wdkit_Page_Importer::prime_bundle(), so a template's fetch is an array lookup. |
| 110 |
* - it downloaded and resized every image it referenced, inside the request. Media is |
| 111 |
* now warmed concurrently and anything left is handed to the background sweep, the |
| 112 |
* same way the bundle importer does it. |
| 113 |
* |
| 114 |
* With those gone the cap was costing an HTTP request and a PHP bootstrap per template |
| 115 |
* to protect against a cost that no longer exists - a 13-template kit paid 13 round |
| 116 |
* trips to import 13 cheap templates, serially, which is precisely the "old flow |
| 117 |
* finishes in a minute, new flow takes five" gap that RUNNER_LANES was reverted over. |
| 118 |
* |
| 119 |
* So the TIME BUDGET is the bound now, and this is only a ceiling on how much one |
| 120 |
* request will take on. It is deliberately larger than any kit's template count so that |
| 121 |
* a normal kit finishes in one or two requests, while out_of_time() still stops a slow |
| 122 |
* host mid-kit and hands the rest to the next request exactly as before. The worst case |
| 123 |
* is unchanged in shape - budget, plus one template that started inside it. |
| 124 |
*/ |
| 125 |
const STAGE_ITEM_CAP = 25; |
| 126 |
|
| 127 |
/** |
| 128 |
* Second, more generous ceiling for a template whose AI copy prime_ai_batch() already |
| 129 |
* fetched — merging it and saving the post is a DB write, not a network wait, so it |
| 130 |
* does not need STAGE_BUDGET's protection. It still needs SOME ceiling, though: giving |
| 131 |
* it none (ClickUp 14ynqxywpec's stall fix, first pass) let a single stubborn template |
| 132 |
* push one request to 82s, past what a browser or reverse proxy will wait for before |
| 133 |
* showing a 504 — even though the PHP process kept running and finished successfully |
| 134 |
* server-side. Measured recovery of a 14-page kit processed 12 pages in under a |
| 135 |
* second each once the batch had landed; 15s is generous headroom above that for a |
| 136 |
* whole slice of them, while staying "deliberately well under 30s" the same way |
| 137 |
* STAGE_BUDGET's own docblock reasons about it. |
| 138 |
*/ |
| 139 |
const FREE_ANSWER_BUDGET = 15; |
| 140 |
|
| 141 |
/** |
| 142 |
* When the current stage must stop taking new work. |
| 143 |
* |
| 144 |
* @var float |
| 145 |
*/ |
| 146 |
private $deadline = 0.0; |
| 147 |
|
| 148 |
/** |
| 149 |
* When the current stage must stop taking on even a FREE (batch-answered) template. |
| 150 |
* Armed once, the first time stage_content() finds it has any batch answers to spend |
| 151 |
* it on — see out_of_free_time(). |
| 152 |
* |
| 153 |
* @var float |
| 154 |
*/ |
| 155 |
private $free_deadline = 0.0; |
| 156 |
|
| 157 |
/** |
| 158 |
* Template ids already attempted in THIS process. |
| 159 |
* |
| 160 |
* Only meaningful while run() drains a stage in a loop. A template that failed is not |
| 161 |
* marked complete, so without this the next loop iteration attempts it again, spends |
| 162 |
* the item cap on it, and the templates after it are never reached — run() would stop |
| 163 |
* with later pages missing. Tracking attempts makes the drain terminate. |
| 164 |
* |
| 165 |
* It is per-process on purpose: a later run_stage() request, or a user pressing Retry, |
| 166 |
* starts with an empty list and so DOES re-attempt the failure. That is the difference |
| 167 |
* between "do not spin on it now" and "never try it again". |
| 168 |
* |
| 169 |
* @var array<string,bool> |
| 170 |
*/ |
| 171 |
private $attempted = array(); |
| 172 |
|
| 173 |
/** |
| 174 |
* Session id this run belongs to. |
| 175 |
* |
| 176 |
* @var string |
| 177 |
*/ |
| 178 |
private $session_id = ''; |
| 179 |
|
| 180 |
/** |
| 181 |
* Validated context. |
| 182 |
* |
| 183 |
* @var array |
| 184 |
*/ |
| 185 |
private $context = array(); |
| 186 |
|
| 187 |
/** |
| 188 |
* @param string $session_id Session id from Wdkit_Import_Session. |
| 189 |
*/ |
| 190 |
public function __construct( $session_id ) { |
| 191 |
$this->session_id = Wdkit_Import_Session::sanitize_id( $session_id ); |
| 192 |
|
| 193 |
/* Only the context is needed to build the importer, and this runs on every poll — |
| 194 |
* context() reads the base row without the per-step reconstruction get() does. */ |
| 195 |
$this->context = Wdkit_Import_Session::context( $this->session_id ); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Create a session for a context and return a runner bound to it. |
| 200 |
* |
| 201 |
* @param array $context Raw context — normalised here. |
| 202 |
* @param string $session_id Optional explicit id. |
| 203 |
* @return Wdkit_Import_Runner |
| 204 |
*/ |
| 205 |
public static function for_context( $context, $session_id = '' ) { |
| 206 |
$normalized = Wdkit_Import_Context::normalize( $context ); |
| 207 |
$session = Wdkit_Import_Session::create( $normalized, $session_id ); |
| 208 |
|
| 209 |
return new self( $session['session_id'] ); |
| 210 |
} |
| 211 |
|
| 212 |
/** @return string */ |
| 213 |
public function get_session_id() { |
| 214 |
return $this->session_id; |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Run every stage that has not already completed. |
| 219 |
* |
| 220 |
* Re-invoking after a failure or an interruption resumes: completed stages are skipped |
| 221 |
* because their step flags are already persisted, so nothing is imported twice. |
| 222 |
* |
| 223 |
* @return array{success:bool,session:array} |
| 224 |
*/ |
| 225 |
public function run() { |
| 226 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 227 |
|
| 228 |
if ( null === $session ) { |
| 229 |
return array( |
| 230 |
'success' => false, |
| 231 |
'session' => array(), |
| 232 |
); |
| 233 |
} |
| 234 |
|
| 235 |
$errors = Wdkit_Import_Context::validation_errors( $this->context ); |
| 236 |
|
| 237 |
if ( ! empty( $errors ) ) { |
| 238 |
Wdkit_Import_Session::fail( |
| 239 |
$this->session_id, |
| 240 |
array( |
| 241 |
'code' => 'invalid_context', |
| 242 |
'message' => implode( ' ', $errors ), |
| 243 |
'step' => '', |
| 244 |
'class' => 'non_retryable', |
| 245 |
'retryable' => false, |
| 246 |
'skippable' => false, |
| 247 |
'context' => array(), |
| 248 |
) |
| 249 |
); |
| 250 |
|
| 251 |
return array( |
| 252 |
'success' => false, |
| 253 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
Wdkit_Import_Session::start( $this->session_id ); |
| 258 |
|
| 259 |
$this->seed_ai_payloads(); |
| 260 |
|
| 261 |
foreach ( $this->stages() as $stage => $method ) { |
| 262 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $stage ) ) { |
| 263 |
continue; |
| 264 |
} |
| 265 |
|
| 266 |
Wdkit_Import_Session::set_stage( $this->session_id, $stage ); |
| 267 |
|
| 268 |
/** |
| 269 |
* Fires as a runner stage begins / ends / fails. |
| 270 |
* |
| 271 |
* Exists so an entry point can publish progress in its own vocabulary without |
| 272 |
* reaching into the runner. wdkit_handle_create_full_site() uses it to emit the |
| 273 |
* `wdkit_site_step` events it has always emitted. |
| 274 |
* |
| 275 |
* @param string $stage Stage name. |
| 276 |
* @param string $state 'start'|'done'|'fail'. |
| 277 |
* @param array $data Stage result or error record. |
| 278 |
* @param string $session_id Session id. |
| 279 |
*/ |
| 280 |
do_action( 'wdkit_import_runner_stage', $stage, 'start', array(), $this->session_id ); |
| 281 |
|
| 282 |
/* Stage timing. Wdkit_Import_Log::report() has always had a STAGES section and |
| 283 |
* it has always read zero on this path, because nothing emitted the entries it |
| 284 |
* reads — so a headless run could be measured per template but not per stage. |
| 285 |
* That blind spot is why "where did the other two minutes go" could not be |
| 286 |
* answered from the data. */ |
| 287 |
$stage_started = microtime( true ); |
| 288 |
|
| 289 |
try { |
| 290 |
/* run() performs a whole import in ONE call — that is its contract, and |
| 291 |
* wdkit_create_full_site() depends on it. So drain the stage here: keep |
| 292 |
* calling it while it reports work still queued. |
| 293 |
* |
| 294 |
* The slicing exists for run_stage(), where a browser request is bound by |
| 295 |
* max_execution_time. Letting the cap leak into run() made it import a |
| 296 |
* single template and report success, which is exactly what the unit suite |
| 297 |
* caught. No deadline is armed on this path, so only the item cap paces it. |
| 298 |
*/ |
| 299 |
$this->deadline = 0.0; |
| 300 |
|
| 301 |
$guard = 0; |
| 302 |
|
| 303 |
do { |
| 304 |
$result = $this->{$method}(); |
| 305 |
++$guard; |
| 306 |
|
| 307 |
if ( ! empty( $result['pending'] ) ) { |
| 308 |
do_action( 'wdkit_import_runner_stage', $stage, 'progress', $result, $this->session_id ); |
| 309 |
} |
| 310 |
} while ( ! empty( $result['pending'] ) && $guard < 500 ); |
| 311 |
|
| 312 |
/* A stage that finished but left individual items failed must NOT be marked |
| 313 |
* complete, or the next run skips the whole stage and those items are never |
| 314 |
* retried — which is the difference between a resumable importer and one |
| 315 |
* that merely does not crash twice. Recording the result and leaving the |
| 316 |
* stage open is safe because every item inside has its own guard, so a |
| 317 |
* re-entry re-does only what is still missing. */ |
| 318 |
if ( ! empty( $result['failed'] ) || $this->outstanding_failures( $stage ) > 0 ) { |
| 319 |
Wdkit_Import_Session::mark_step_failed( |
| 320 |
$this->session_id, |
| 321 |
$stage, |
| 322 |
array( |
| 323 |
'code' => 'stage_incomplete', |
| 324 |
'message' => __( 'Some items in this stage did not import.', 'wdesignkit' ), |
| 325 |
'step' => $stage, |
| 326 |
'class' => 'retryable', |
| 327 |
'retryable' => true, |
| 328 |
'skippable' => true, |
| 329 |
'context' => array( 'failed' => count( $result['failed'] ) ), |
| 330 |
) |
| 331 |
); |
| 332 |
|
| 333 |
Wdkit_Import_Log::add( |
| 334 |
'stage', |
| 335 |
array( |
| 336 |
'stage' => $stage . ' (partial)', |
| 337 |
'ms' => (int) round( ( microtime( true ) - $stage_started ) * 1000 ), |
| 338 |
) |
| 339 |
); |
| 340 |
|
| 341 |
do_action( 'wdkit_import_runner_stage', $stage, 'fail', $result, $this->session_id ); |
| 342 |
|
| 343 |
/* No stage-level result is written: every item inside already recorded its |
| 344 |
* own, and a stage summary that says "3 of 4" would go stale the moment |
| 345 |
* the fourth lands. */ |
| 346 |
continue; |
| 347 |
} |
| 348 |
|
| 349 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $stage, $result ); |
| 350 |
|
| 351 |
Wdkit_Import_Log::add( |
| 352 |
'stage', |
| 353 |
array( |
| 354 |
'stage' => $stage, |
| 355 |
'ms' => (int) round( ( microtime( true ) - $stage_started ) * 1000 ), |
| 356 |
) |
| 357 |
); |
| 358 |
|
| 359 |
do_action( 'wdkit_import_runner_stage', $stage, 'done', is_array( $result ) ? $result : array(), $this->session_id ); |
| 360 |
|
| 361 |
/* Stop here if this stage activated a plugin, and let the caller resume in a |
| 362 |
* fresh request. |
| 363 |
* |
| 364 |
* A plugin activated mid-request is only half there: WordPress loads it, but |
| 365 |
* every hook it registers on `init` and earlier has already fired. Elementor |
| 366 |
* is the case that proved it — activating it in stage 1 and then using |
| 367 |
* `Elementor\Plugin::$instance->templates_manager` in stage 3 of the same |
| 368 |
* request throws, because the templates manager is built on init and init is |
| 369 |
* long gone. An Elementor kit therefore imported all its pages and then failed |
| 370 |
* to set the front page, leaving the site 404ing on its own home page. |
| 371 |
* |
| 372 |
* The browser never hit this: it installs in one request and imports in |
| 373 |
* later ones, so the new plugin bootstraps normally in between. Yielding |
| 374 |
* here reproduces that, using the resume the session already supports — |
| 375 |
* every completed stage is skipped, so nothing is repeated. */ |
| 376 |
if ( self::STAGE_DEPENDENCIES === $stage && ! empty( $result['installed'] ) ) { |
| 377 |
return array( |
| 378 |
'success' => true, |
| 379 |
'restart' => true, |
| 380 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 381 |
); |
| 382 |
} |
| 383 |
} catch ( Wdkit_Import_Exception $e ) { |
| 384 |
$record = Wdkit_Import_Errors::to_record( $e, $stage ); |
| 385 |
|
| 386 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record ); |
| 387 |
|
| 388 |
do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id ); |
| 389 |
|
| 390 |
/* Skippable means this stage could not finish but the run still can. |
| 391 |
* Anything else stops here with the session left resumable. */ |
| 392 |
if ( ! $e->is_skippable() ) { |
| 393 |
Wdkit_Import_Session::fail( $this->session_id, $record ); |
| 394 |
|
| 395 |
return array( |
| 396 |
'success' => false, |
| 397 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 398 |
); |
| 399 |
} |
| 400 |
} catch ( Throwable $e ) { |
| 401 |
$record = Wdkit_Import_Errors::to_record( $e, $stage ); |
| 402 |
|
| 403 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record ); |
| 404 |
|
| 405 |
do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id ); |
| 406 |
|
| 407 |
Wdkit_Import_Session::fail( $this->session_id, $record ); |
| 408 |
|
| 409 |
return array( |
| 410 |
'success' => false, |
| 411 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 412 |
); |
| 413 |
} |
| 414 |
} |
| 415 |
|
| 416 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 417 |
|
| 418 |
/* A run that got through every stage but left items failed is `partial`, not |
| 419 |
* `completed` — the difference matters because a caller decides from this whether |
| 420 |
* to offer a retry. It is still a success: the site imported, some pages did not, |
| 421 |
* and re-running fixes exactly those. The browser reports the same way, with the |
| 422 |
* failed rows marked and a retry button beside them. */ |
| 423 |
if ( null !== $session && ! empty( $session['failed'] ) ) { |
| 424 |
Wdkit_Import_Session::update( |
| 425 |
$this->session_id, |
| 426 |
array( 'status' => 'partial' ) |
| 427 |
); |
| 428 |
|
| 429 |
return array( |
| 430 |
'success' => true, |
| 431 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 432 |
); |
| 433 |
} |
| 434 |
|
| 435 |
Wdkit_Import_Session::complete( $this->session_id ); |
| 436 |
|
| 437 |
return array( |
| 438 |
'success' => true, |
| 439 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 440 |
); |
| 441 |
} |
| 442 |
|
| 443 |
/** |
| 444 |
* Run one operation at most once per session. |
| 445 |
* |
| 446 |
* Stages 3 and 4 each perform several independent operations. Without a key per |
| 447 |
* operation, a stage that dies on its fourth call redoes the first three on resume — |
| 448 |
* harmless for idempotent writes like an option, wasteful for the rest, and wrong for |
| 449 |
* anything that appends. This makes each one resumable in its own right, which is what |
| 450 |
* PART 10 of the brief asks for. |
| 451 |
* |
| 452 |
* A failure is recorded against the operation's own key and re-thrown, so the stage |
| 453 |
* still decides whether the run continues. |
| 454 |
* |
| 455 |
* @param string $step Step key. |
| 456 |
* @param callable $operation The work. |
| 457 |
* @return mixed The operation's result, or the recorded result on a resume. |
| 458 |
*/ |
| 459 |
private function once( $step, $operation ) { |
| 460 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 461 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 462 |
|
| 463 |
return ( null !== $session && isset( $session['results'][ $step ] ) ) |
| 464 |
? $session['results'][ $step ] |
| 465 |
: null; |
| 466 |
} |
| 467 |
|
| 468 |
/* Timed, because the stage total on its own cannot be acted on. The setup stage is |
| 469 |
* five sub-steps and one of them dominated it - 129s of a 139s Elementor import - |
| 470 |
* with nothing in the log to say which. This is the same 'stage' entry shape, one |
| 471 |
* level down. */ |
| 472 |
$step_started = microtime( true ); |
| 473 |
|
| 474 |
try { |
| 475 |
$result = $operation(); |
| 476 |
} catch ( Throwable $e ) { |
| 477 |
Wdkit_Import_Log::add( |
| 478 |
'step', |
| 479 |
array( |
| 480 |
'step' => $step . ' (failed)', |
| 481 |
'ms' => (int) round( ( microtime( true ) - $step_started ) * 1000 ), |
| 482 |
) |
| 483 |
); |
| 484 |
|
| 485 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, Wdkit_Import_Errors::to_record( $e, $step ) ); |
| 486 |
|
| 487 |
throw $e; |
| 488 |
} |
| 489 |
|
| 490 |
Wdkit_Import_Log::add( |
| 491 |
'step', |
| 492 |
array( |
| 493 |
'step' => $step, |
| 494 |
'ms' => (int) round( ( microtime( true ) - $step_started ) * 1000 ), |
| 495 |
) |
| 496 |
); |
| 497 |
|
| 498 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $result ); |
| 499 |
|
| 500 |
return $result; |
| 501 |
} |
| 502 |
|
| 503 |
/** |
| 504 |
* Fold a freshly-supplied AI document into the stored context, then re-seed. |
| 505 |
* |
| 506 |
* ── Why this is needed ───────────────────────────────────────────────────── |
| 507 |
* |
| 508 |
* The wizard builds the session's context on its FIRST stage request |
| 509 |
* (install_plugin_theme). batch_ai_prefetch() in the browser has usually not resolved |
| 510 |
* by then, so that request carries no `ai_document`. It arrives on a later request - |
| 511 |
* importing_site_content - but by then __construct() has reloaded the frozen stage-1 |
| 512 |
* context and the new document would be dropped. Without this, every template falls |
| 513 |
* through to a per-template `ai/template_import` cloud call: 1 wasted batch + N blocking |
| 514 |
* calls, the exact regression this fixes. |
| 515 |
* |
| 516 |
* Merge, not replace: a document that is still filling in (some pages generating) must |
| 517 |
* not wipe pages an earlier request already carried, and a page already resolved to a |
| 518 |
* canonical payload wins over a raw one. |
| 519 |
* |
| 520 |
* @param array $document A document as Wdkit_Ai_Content::normalize_document() returns it. |
| 521 |
* @return void |
| 522 |
*/ |
| 523 |
public function adopt_ai_document( $document ) { |
| 524 |
/* `posts` and `products` count as content too. The guard used to test the two page |
| 525 |
* groups only, from when page copy was the only thing the browser sent - so a |
| 526 |
* document carrying the blog-post or product answer but no resolvable page copy |
| 527 |
* (an AI batch that partially failed, or a wireframe import that skips page copy |
| 528 |
* entirely) was thrown away at the door, and the posts fell back to the hardcoded |
| 529 |
* set while the products vanished. */ |
| 530 |
if ( ! is_array( $document ) ) { |
| 531 |
return; |
| 532 |
} |
| 533 |
|
| 534 |
$has_content = false; |
| 535 |
|
| 536 |
foreach ( array( 'pages', 'raw_pages', 'posts', 'products' ) as $group ) { |
| 537 |
if ( ! empty( $document[ $group ] ) ) { |
| 538 |
$has_content = true; |
| 539 |
break; |
| 540 |
} |
| 541 |
} |
| 542 |
|
| 543 |
if ( ! $has_content ) { |
| 544 |
return; |
| 545 |
} |
| 546 |
|
| 547 |
$current = ( isset( $this->context['ai_document'] ) && is_array( $this->context['ai_document'] ) ) |
| 548 |
? $this->context['ai_document'] |
| 549 |
: array(); |
| 550 |
|
| 551 |
$before = wp_json_encode( $current ); |
| 552 |
|
| 553 |
foreach ( array( 'pages', 'raw_pages', 'products', 'posts' ) as $group ) { |
| 554 |
if ( empty( $document[ $group ] ) || ! is_array( $document[ $group ] ) ) { |
| 555 |
continue; |
| 556 |
} |
| 557 |
|
| 558 |
if ( empty( $current[ $group ] ) || ! is_array( $current[ $group ] ) ) { |
| 559 |
$current[ $group ] = array(); |
| 560 |
} |
| 561 |
|
| 562 |
foreach ( $document[ $group ] as $key => $value ) { |
| 563 |
$have = isset( $current[ $group ][ $key ] ) ? $current[ $group ][ $key ] : null; |
| 564 |
|
| 565 |
/* Keep a page that is already a resolved canonical payload; otherwise take |
| 566 |
* the incoming one (raw entries have no `elements`, so they always refresh - |
| 567 |
* harmless, it is the same answer). */ |
| 568 |
if ( is_array( $have ) && ! empty( $have['elements'] ) ) { |
| 569 |
continue; |
| 570 |
} |
| 571 |
|
| 572 |
$current[ $group ][ $key ] = $value; |
| 573 |
} |
| 574 |
} |
| 575 |
|
| 576 |
foreach ( array( 'version', 'builder' ) as $key ) { |
| 577 |
if ( empty( $current[ $key ] ) && ! empty( $document[ $key ] ) ) { |
| 578 |
$current[ $key ] = $document[ $key ]; |
| 579 |
} |
| 580 |
} |
| 581 |
|
| 582 |
/* Taxonomy needs its own test, not the `empty()` one above. normalize_document() |
| 583 |
* ALWAYS emits `taxonomy` as array( 'categories' => [], 'tags' => [] ), and an array |
| 584 |
* with two keys is never empty() - so once a first request had stored that hollow |
| 585 |
* shape, every later document's real category and tag names were skipped as |
| 586 |
* "already set". Compare on the lists themselves. */ |
| 587 |
if ( self::taxonomy_is_empty( $current ) && ! self::taxonomy_is_empty( $document ) ) { |
| 588 |
$current['taxonomy'] = $document['taxonomy']; |
| 589 |
} |
| 590 |
|
| 591 |
if ( wp_json_encode( $current ) === $before ) { |
| 592 |
return; |
| 593 |
} |
| 594 |
|
| 595 |
$this->context['ai_document'] = $current; |
| 596 |
|
| 597 |
Wdkit_Import_Session::update( $this->session_id, array( 'context' => $this->context ) ); |
| 598 |
|
| 599 |
/* Now that the document is in the context, fan it out to per-template files for any |
| 600 |
* template it can already resolve canonically (the remote-caller shape). The browser |
| 601 |
* shape stays in raw_pages and is converted per template by the page importer. */ |
| 602 |
$this->seed_ai_payloads(); |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* Park the kit's `site_settings` block on the session, once, when the bundle is primed. |
| 607 |
* |
| 608 |
* The container width the kit ships lives in that block, and the globals step needs it — |
| 609 |
* but the two run in DIFFERENT stages, which on the wizard path means different HTTP |
| 610 |
* requests. `Wdkit_Page_Importer::bundle_site_settings()` is a static, so by the time |
| 611 |
* `setting_up_site` runs it is empty again and the step had nothing to apply. The |
| 612 |
* headless path never noticed because it runs all four stages in one process. |
| 613 |
* |
| 614 |
* Stored on the context rather than re-primed later so the globals step costs nothing and |
| 615 |
* a resumed run still has it. Written once — a second call with the same value does not |
| 616 |
* touch the option. |
| 617 |
* |
| 618 |
* @since 2.7.2 |
| 619 |
* |
| 620 |
* @return void |
| 621 |
*/ |
| 622 |
/** |
| 623 |
* Plugin-level settings the kit expects. Extracted so the content stage can run it too. |
| 624 |
* |
| 625 |
* @since 2.7.2 |
| 626 |
* |
| 627 |
* @param string $builder 'elementor'|'gutenberg'. |
| 628 |
* @return array |
| 629 |
*/ |
| 630 |
private function step_plugin_settings( $builder ) { |
| 631 |
return Wdkit_Import_Settings::apply_plugin( 'elementor' === $builder ? 'elementor' : '' ); |
| 632 |
} |
| 633 |
|
| 634 |
/** |
| 635 |
* The kit's palette, typography and site-level settings. |
| 636 |
* |
| 637 |
* @since 2.7.2 |
| 638 |
* |
| 639 |
* @param string $builder 'elementor'|'gutenberg'. |
| 640 |
* @return array |
| 641 |
*/ |
| 642 |
private function step_globals( $builder ) { |
| 643 |
/* The kit's own palette takes precedence; globals discovered on the decoded |
| 644 |
* templates fill any gaps. Using only the templates left the kit's extended |
| 645 |
* colours (C8, C12, C17...) and typography (T15...) undefined, so every page |
| 646 |
* referencing them rendered unstyled. */ |
| 647 |
$from_kit = Wdkit_Import_Globals::from_kit( |
| 648 |
! empty( $this->context['kit_global'] ) ? $this->context['kit_global'] : array(), |
| 649 |
$builder, |
| 650 |
! empty( $this->context['font_family'] ) ? $this->context['font_family'] : array(), |
| 651 |
! empty( $this->context['site_global'] ) ? $this->context['site_global'] : array(), |
| 652 |
$this->session_id |
| 653 |
); |
| 654 |
|
| 655 |
$from_templates = Wdkit_Import_Globals::collect( $this->decoded_templates(), $builder ); |
| 656 |
|
| 657 |
/* Site-level settings are the KIT's, not the globals screen's. |
| 658 |
* |
| 659 |
* `context['site_global']` is the colour/typography preset the user confirmed on the |
| 660 |
* globals screen; it has never carried a container width. The width lives in each |
| 661 |
* template's own `site_settings` block, which is what the browser path forwards |
| 662 |
* (`update_site_globals(content.site_settings)`). Passing the preset here instead |
| 663 |
* meant the write was handed a payload with no container keys in it at all, and |
| 664 |
* `tpgb_global_options.globalContainer` was overwritten with an empty array — so |
| 665 |
* Gutenberg imports fell back to Nexter's default 1140px instead of the kit's 1240px. |
| 666 |
* |
| 667 |
* Context first: the content stage parked it there (remember_kit_site_settings) |
| 668 |
* because the live static does not survive into another stage's own request on the |
| 669 |
* wizard path. The preset stays as the last fallback. */ |
| 670 |
$site_data = ( ! empty( $this->context['kit_site_settings'] ) && is_array( $this->context['kit_site_settings'] ) ) |
| 671 |
? $this->context['kit_site_settings'] |
| 672 |
: Wdkit_Page_Importer::bundle_site_settings(); |
| 673 |
|
| 674 |
if ( empty( $site_data ) && ! empty( $this->context['site_global'] ) ) { |
| 675 |
$site_data = $this->context['site_global']; |
| 676 |
} |
| 677 |
|
| 678 |
return Wdkit_Import_Globals::apply( |
| 679 |
Wdkit_Import_Globals::merge_globals( $from_kit, $from_templates, $builder ), |
| 680 |
$builder, |
| 681 |
is_array( $site_data ) ? $site_data : array() |
| 682 |
); |
| 683 |
} |
| 684 |
|
| 685 |
/** |
| 686 |
* Run the settings that belong BEFORE the kit's pages are imported. |
| 687 |
* |
| 688 |
* Latest applies plugin settings, the global palette and the theme settings first and |
| 689 |
* imports the pages after; the runner had them the other way round, so the wizard listed |
| 690 |
* "Global / Plugin / Theme Settings" below the page rows instead of above them — and, |
| 691 |
* more than cosmetically, every page was imported before the palette it references |
| 692 |
* existed. These three depend on nothing the content stage produces, so running them at |
| 693 |
* the top of it restores latest's order for both the UI and the work itself. |
| 694 |
* |
| 695 |
* `site_settings` (which assigns the front page) and `widgets` are NOT hoisted: they need |
| 696 |
* the pages to exist, and they stay in `setting_up_site` where they always were. |
| 697 |
* |
| 698 |
* Every step is `once()`-guarded and memoised, so the later `setting_up_site` call finds |
| 699 |
* them complete and returns the stored result instead of repeating the work. Only the |
| 700 |
* first lane to arrive runs them; the rest see a completed step. |
| 701 |
* |
| 702 |
* @since 2.7.2 |
| 703 |
* |
| 704 |
* @param string $builder 'elementor'|'gutenberg'. |
| 705 |
* @return void |
| 706 |
*/ |
| 707 |
private function settings_before_content( $builder ) { |
| 708 |
/* once() is memoisation, NOT mutual exclusion — it checks is_step_complete() and |
| 709 |
* nothing else, which was sufficient while these steps only ever ran from |
| 710 |
* setting_up_site, a stage the browser drives with a single request. The content |
| 711 |
* stage runs RUNNER_LANES requests at once, so calling them from here without a lock |
| 712 |
* had all four lanes enter the same step before any of them had finished it: the run |
| 713 |
* logged `globals` four times and `theme_settings` three. One claim, and the lanes |
| 714 |
* that lose it simply get on with importing templates. |
| 715 |
* |
| 716 |
* Losing the claim costs them nothing. These steps are ordered ahead of the templates |
| 717 |
* for the operator's benefit — it is the order every previous release used and the |
| 718 |
* order the progress list reads in — not because a template import reads them: the |
| 719 |
* palette is resolved by the browser at render time from plus-global.css, which is |
| 720 |
* why the previous arrangement (globals AFTER every page) produced correct pages too. |
| 721 |
* So a lane that proceeds while a sibling is still applying them is not importing |
| 722 |
* anything differently. */ |
| 723 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'theme_settings' ) ) { |
| 724 |
return; |
| 725 |
} |
| 726 |
|
| 727 |
$lock = 'settings_before_content'; |
| 728 |
|
| 729 |
if ( '' !== $this->session_id && ! Wdkit_Import_Session::claim( $this->session_id, $lock ) ) { |
| 730 |
return; |
| 731 |
} |
| 732 |
|
| 733 |
try { |
| 734 |
$this->once( |
| 735 |
'plugin_settings', |
| 736 |
function () use ( $builder ) { |
| 737 |
return $this->step_plugin_settings( $builder ); |
| 738 |
} |
| 739 |
); |
| 740 |
|
| 741 |
$this->once( |
| 742 |
'globals', |
| 743 |
function () use ( $builder ) { |
| 744 |
return $this->step_globals( $builder ); |
| 745 |
} |
| 746 |
); |
| 747 |
|
| 748 |
$this->once( 'theme_settings', array( 'Wdkit_Import_Settings', 'apply_theme' ) ); |
| 749 |
} catch ( Throwable $e ) { |
| 750 |
/* A settings step that fails must not take the content stage down with it — the |
| 751 |
* pages are the import. once() has already recorded the failure against the step, |
| 752 |
* so `setting_up_site` still reports it and Retry still reaches it. */ |
| 753 |
Wdkit_Import_Log::add( 'step', array( 'step' => 'settings_before_content (deferred)' ) ); |
| 754 |
} finally { |
| 755 |
if ( '' !== $this->session_id ) { |
| 756 |
Wdkit_Import_Session::release( $this->session_id, $lock ); |
| 757 |
} |
| 758 |
} |
| 759 |
} |
| 760 |
|
| 761 |
private function remember_kit_site_settings() { |
| 762 |
if ( ! class_exists( 'Wdkit_Page_Importer' ) || ! method_exists( 'Wdkit_Page_Importer', 'bundle_site_settings' ) ) { |
| 763 |
return; |
| 764 |
} |
| 765 |
|
| 766 |
$settings = Wdkit_Page_Importer::bundle_site_settings(); |
| 767 |
|
| 768 |
if ( empty( $settings ) || ! is_array( $settings ) ) { |
| 769 |
return; |
| 770 |
} |
| 771 |
|
| 772 |
$current = ( isset( $this->context['kit_site_settings'] ) && is_array( $this->context['kit_site_settings'] ) ) |
| 773 |
? $this->context['kit_site_settings'] |
| 774 |
: array(); |
| 775 |
|
| 776 |
if ( wp_json_encode( $current ) === wp_json_encode( $settings ) ) { |
| 777 |
return; |
| 778 |
} |
| 779 |
|
| 780 |
$this->context['kit_site_settings'] = $settings; |
| 781 |
|
| 782 |
if ( '' !== $this->session_id ) { |
| 783 |
Wdkit_Import_Session::update( $this->session_id, array( 'context' => $this->context ) ); |
| 784 |
} |
| 785 |
} |
| 786 |
|
| 787 |
/** |
| 788 |
* Does this document carry no usable category or tag names? |
| 789 |
* |
| 790 |
* True for a missing `taxonomy`, and equally for the hollow |
| 791 |
* `array( 'categories' => array(), 'tags' => array() )` that normalize_document() always |
| 792 |
* emits — which is the case `empty()` gets wrong, because a two-key array is not empty. |
| 793 |
* |
| 794 |
* @since 2.7.2 |
| 795 |
* |
| 796 |
* @param array $document Normalised document. |
| 797 |
* @return bool |
| 798 |
*/ |
| 799 |
private static function taxonomy_is_empty( $document ) { |
| 800 |
if ( empty( $document['taxonomy'] ) || ! is_array( $document['taxonomy'] ) ) { |
| 801 |
return true; |
| 802 |
} |
| 803 |
|
| 804 |
foreach ( array( 'categories', 'tags' ) as $key ) { |
| 805 |
if ( ! empty( $document['taxonomy'][ $key ] ) ) { |
| 806 |
return false; |
| 807 |
} |
| 808 |
} |
| 809 |
|
| 810 |
return true; |
| 811 |
} |
| 812 |
|
| 813 |
/** |
| 814 |
* Write the context's AI document out as per-template payload files. |
| 815 |
* |
| 816 |
* The page importer already loads one payload per template from the session store — |
| 817 |
* that path was built for the case where the browser generates and PHP applies. A |
| 818 |
* document simply fans out into the same files, so a remote caller supplying one |
| 819 |
* whole-site payload and a caller supplying per-template payloads reach an identical |
| 820 |
* merge. Nothing downstream needs to know which happened. |
| 821 |
* |
| 822 |
* Idempotent by construction: has_payload() means a resume does not rewrite files, so |
| 823 |
* an interrupted run keeps whatever it already had on disk. |
| 824 |
* |
| 825 |
* @return array{written:int,skipped:int} |
| 826 |
*/ |
| 827 |
private function seed_ai_payloads() { |
| 828 |
$written = 0; |
| 829 |
$skipped = 0; |
| 830 |
|
| 831 |
$document = ! empty( $this->context['ai_document'] ) ? $this->context['ai_document'] : array(); |
| 832 |
|
| 833 |
if ( empty( $document['pages'] ) || ! is_array( $document['pages'] ) ) { |
| 834 |
return array( |
| 835 |
'written' => 0, |
| 836 |
'skipped' => 0, |
| 837 |
); |
| 838 |
} |
| 839 |
|
| 840 |
foreach ( array_keys( $document['pages'] ) as $template_id ) { |
| 841 |
if ( Wdkit_Ai_Content::has_payload( $this->session_id, $template_id ) ) { |
| 842 |
++$skipped; |
| 843 |
continue; |
| 844 |
} |
| 845 |
|
| 846 |
$payload = Wdkit_Ai_Content::page_payload( $document, $template_id ); |
| 847 |
|
| 848 |
if ( null === $payload ) { |
| 849 |
++$skipped; |
| 850 |
continue; |
| 851 |
} |
| 852 |
|
| 853 |
if ( Wdkit_Ai_Content::save_payload( $this->session_id, $template_id, $payload ) ) { |
| 854 |
++$written; |
| 855 |
} else { |
| 856 |
++$skipped; |
| 857 |
} |
| 858 |
} |
| 859 |
|
| 860 |
return array( |
| 861 |
'written' => $written, |
| 862 |
'skipped' => $skipped, |
| 863 |
); |
| 864 |
} |
| 865 |
|
| 866 |
/** |
| 867 |
* Move the session to its final state once every stage has completed. |
| 868 |
* |
| 869 |
* Called after any stage run — including a no-op re-entry — because a resume finishes |
| 870 |
* by re-entering stages that were already done, and the run has to be allowed to end |
| 871 |
* on that path too. |
| 872 |
* |
| 873 |
* `partial` rather than `completed` when items are still failed: the site imported, some |
| 874 |
* pages did not, and re-running fixes exactly those. That distinction is what the |
| 875 |
* wizard reads to decide whether to offer Retry. |
| 876 |
* |
| 877 |
* @return void |
| 878 |
*/ |
| 879 |
private function settle_status() { |
| 880 |
/* The last stage having completed is what ends a run — NOT every stage being clean. |
| 881 |
* A partial run deliberately leaves the content stage open so its failed templates |
| 882 |
* can be retried, so requiring all four would leave such a run stuck at `running` |
| 883 |
* until the shutdown handler mislabelled it `interrupted`. */ |
| 884 |
if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, self::STAGE_FINALIZE ) ) { |
| 885 |
return; |
| 886 |
} |
| 887 |
|
| 888 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 889 |
|
| 890 |
if ( null === $session ) { |
| 891 |
return; |
| 892 |
} |
| 893 |
|
| 894 |
/* mark_step_complete() removes a step from `failed` when it succeeds, so anything |
| 895 |
* still listed genuinely did not land — no filtering needed, and none wanted: a |
| 896 |
* stage that really failed should hold the run at `partial`. */ |
| 897 |
if ( ! empty( $session['failed'] ) ) { |
| 898 |
Wdkit_Import_Session::update( $this->session_id, array( 'status' => 'partial' ) ); |
| 899 |
|
| 900 |
return; |
| 901 |
} |
| 902 |
|
| 903 |
Wdkit_Import_Session::complete( $this->session_id ); |
| 904 |
} |
| 905 |
|
| 906 |
/** |
| 907 |
* Item-level failures still outstanding for a stage. |
| 908 |
* |
| 909 |
* A stage's own return value is not enough to decide whether it finished. Both drivers |
| 910 |
* call a stage repeatedly — run() in a drain loop, run_stage() once per request — and |
| 911 |
* each call only reports ITS OWN failures. So a later clean call masked an earlier |
| 912 |
* failure and the stage was marked complete with a page still missing; the resume then |
| 913 |
* skipped the whole stage. The session is the only place that remembers every failure, |
| 914 |
* so ask it. |
| 915 |
* |
| 916 |
* @param string $stage Stage name. |
| 917 |
* @return int |
| 918 |
*/ |
| 919 |
private function outstanding_failures( $stage ) { |
| 920 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 921 |
|
| 922 |
if ( null === $session || empty( $session['failed'] ) ) { |
| 923 |
return 0; |
| 924 |
} |
| 925 |
|
| 926 |
/* Which step prefixes belong to which stage. Only the content stage owns per-item |
| 927 |
* steps; the others are single operations already covered by their own records. */ |
| 928 |
$prefixes = ( self::STAGE_CONTENT === $stage ) |
| 929 |
? array( 'page_', 'post_', 'product_' ) |
| 930 |
: array(); |
| 931 |
|
| 932 |
if ( empty( $prefixes ) ) { |
| 933 |
return 0; |
| 934 |
} |
| 935 |
|
| 936 |
$count = 0; |
| 937 |
|
| 938 |
foreach ( array_keys( $session['failed'] ) as $step ) { |
| 939 |
foreach ( $prefixes as $prefix ) { |
| 940 |
if ( 0 === strpos( (string) $step, $prefix ) ) { |
| 941 |
++$count; |
| 942 |
break; |
| 943 |
} |
| 944 |
} |
| 945 |
} |
| 946 |
|
| 947 |
return $count; |
| 948 |
} |
| 949 |
|
| 950 |
/** |
| 951 |
* Has this stage used up its slice? |
| 952 |
* |
| 953 |
* Checked BETWEEN items, never inside one, so an item is always either fully done or |
| 954 |
* not started — which is what keeps the session records truthful. |
| 955 |
* |
| 956 |
* @return bool |
| 957 |
*/ |
| 958 |
private function out_of_time() { |
| 959 |
return ( $this->deadline > 0.0 ) && ( microtime( true ) >= $this->deadline ); |
| 960 |
} |
| 961 |
|
| 962 |
/** |
| 963 |
* Has this stage used up the separate, more generous slice a FREE (batch-answered) |
| 964 |
* template gets? Armed lazily, on the first template stage_content() finds a batch |
| 965 |
* answer for — arming it any earlier would count the batch call's own network wait |
| 966 |
* against it, which is exactly the budget out_of_time() already exists to bound |
| 967 |
* against a template that still needs one. |
| 968 |
* |
| 969 |
* Unarmed entirely when $deadline itself is — run()'s "drain this stage to completion |
| 970 |
* in one PHP process" path (no browser polling, no HTTP round trip per slice to |
| 971 |
* protect) sets `$this->deadline = 0.0` for exactly that reason, and a free template |
| 972 |
* deserves the same "nothing here is bounding it" treatment an ordinary one already |
| 973 |
* gets on that path. Free_deadline is armed once per run_stage() call (reset there |
| 974 |
* alongside $deadline), so this stays scoped to one request the same way out_of_time() |
| 975 |
* is. |
| 976 |
* |
| 977 |
* @return bool |
| 978 |
*/ |
| 979 |
private function out_of_free_time() { |
| 980 |
if ( $this->deadline <= 0.0 ) { |
| 981 |
return false; |
| 982 |
} |
| 983 |
|
| 984 |
if ( $this->free_deadline <= 0.0 ) { |
| 985 |
$this->free_deadline = microtime( true ) + self::FREE_ANSWER_BUDGET; |
| 986 |
|
| 987 |
return false; |
| 988 |
} |
| 989 |
|
| 990 |
return microtime( true ) >= $this->free_deadline; |
| 991 |
} |
| 992 |
|
| 993 |
/** |
| 994 |
* Ordered stage => method map. |
| 995 |
* |
| 996 |
* @return array<string,string> |
| 997 |
*/ |
| 998 |
private function stages() { |
| 999 |
return array( |
| 1000 |
self::STAGE_DEPENDENCIES => 'stage_dependencies', |
| 1001 |
self::STAGE_CONTENT => 'stage_content', |
| 1002 |
self::STAGE_SETUP => 'stage_setup', |
| 1003 |
self::STAGE_FINALIZE => 'stage_finalize', |
| 1004 |
); |
| 1005 |
} |
| 1006 |
|
| 1007 |
/** |
| 1008 |
* Run ONE stage and return its outcome. |
| 1009 |
* |
| 1010 |
* This is what the Kit Import wizard drives. The wizard has always advanced a stage at |
| 1011 |
* a time — import_function() has four branches and calls one per screen update — so |
| 1012 |
* running one stage per request keeps its existing progress UX working without any |
| 1013 |
* polling: the browser is the scheduler, exactly as before. |
| 1014 |
* |
| 1015 |
* Re-entering a completed stage is a no-op that reports success, which is what makes a |
| 1016 |
* browser refresh mid-import harmless. |
| 1017 |
* |
| 1018 |
* @param string $stage Stage name. |
| 1019 |
* @return array{success:bool,stage:string,complete:bool,result:mixed,error:array,session:array} |
| 1020 |
*/ |
| 1021 |
public function run_stage( $stage ) { |
| 1022 |
$stage = (string) $stage; |
| 1023 |
$stages = $this->stages(); |
| 1024 |
|
| 1025 |
if ( ! isset( $stages[ $stage ] ) ) { |
| 1026 |
return array( |
| 1027 |
'success' => false, |
| 1028 |
'stage' => $stage, |
| 1029 |
'complete' => false, |
| 1030 |
'result' => null, |
| 1031 |
'error' => array( |
| 1032 |
'code' => 'unknown_stage', |
| 1033 |
'message' => __( 'Unknown import stage.', 'wdesignkit' ), |
| 1034 |
'retryable' => false, |
| 1035 |
), |
| 1036 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1037 |
); |
| 1038 |
} |
| 1039 |
|
| 1040 |
$errors = Wdkit_Import_Context::validation_errors( $this->context ); |
| 1041 |
|
| 1042 |
if ( ! empty( $errors ) ) { |
| 1043 |
$record = array( |
| 1044 |
'code' => 'invalid_context', |
| 1045 |
'message' => implode( ' ', $errors ), |
| 1046 |
'step' => $stage, |
| 1047 |
'class' => 'non_retryable', |
| 1048 |
'retryable' => false, |
| 1049 |
'skippable' => false, |
| 1050 |
'context' => array(), |
| 1051 |
); |
| 1052 |
|
| 1053 |
Wdkit_Import_Session::fail( $this->session_id, $record ); |
| 1054 |
|
| 1055 |
return array( |
| 1056 |
'success' => false, |
| 1057 |
'stage' => $stage, |
| 1058 |
'complete' => false, |
| 1059 |
'result' => null, |
| 1060 |
'error' => $record, |
| 1061 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1062 |
); |
| 1063 |
} |
| 1064 |
|
| 1065 |
/* Already done on a previous attempt — a refresh, or a double-fired effect. Still |
| 1066 |
* settle the status: on a resume the later stages are already complete, so without |
| 1067 |
* this the run never leaves `running` and the shutdown handler marks it |
| 1068 |
* `interrupted`. Caught by resuming a partial import through the wizard. */ |
| 1069 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $stage ) ) { |
| 1070 |
$this->settle_status(); |
| 1071 |
|
| 1072 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 1073 |
|
| 1074 |
return array( |
| 1075 |
'success' => true, |
| 1076 |
'stage' => $stage, |
| 1077 |
'complete' => true, |
| 1078 |
'result' => isset( $session['results'][ $stage ] ) ? $session['results'][ $stage ] : null, |
| 1079 |
'error' => array(), |
| 1080 |
'session' => $session, |
| 1081 |
); |
| 1082 |
} |
| 1083 |
|
| 1084 |
Wdkit_Import_Session::start( $this->session_id ); |
| 1085 |
$this->seed_ai_payloads(); |
| 1086 |
Wdkit_Import_Session::set_stage( $this->session_id, $stage ); |
| 1087 |
|
| 1088 |
/* One slice per request. set_time_limit() is belt-and-braces: many hosts ignore it, |
| 1089 |
* and the budget is what actually keeps this inside the limit. */ |
| 1090 |
$this->deadline = microtime( true ) + self::STAGE_BUDGET; |
| 1091 |
$this->free_deadline = 0.0; |
| 1092 |
|
| 1093 |
if ( function_exists( 'set_time_limit' ) ) { |
| 1094 |
@set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1095 |
} |
| 1096 |
|
| 1097 |
do_action( 'wdkit_import_runner_stage', $stage, 'start', array(), $this->session_id ); |
| 1098 |
|
| 1099 |
$method = $stages[ $stage ]; |
| 1100 |
|
| 1101 |
try { |
| 1102 |
$result = $this->{$method}(); |
| 1103 |
} catch ( Throwable $e ) { |
| 1104 |
$record = Wdkit_Import_Errors::to_record( $e, $stage ); |
| 1105 |
|
| 1106 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $stage, $record ); |
| 1107 |
|
| 1108 |
/* Disarm before returning — see the note below. */ |
| 1109 |
Wdkit_Import_Session::unwatch(); |
| 1110 |
|
| 1111 |
do_action( 'wdkit_import_runner_stage', $stage, 'fail', $record, $this->session_id ); |
| 1112 |
|
| 1113 |
return array( |
| 1114 |
'success' => false, |
| 1115 |
'stage' => $stage, |
| 1116 |
'complete' => false, |
| 1117 |
'result' => null, |
| 1118 |
'error' => $record, |
| 1119 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1120 |
); |
| 1121 |
} |
| 1122 |
|
| 1123 |
/* ── Disarm the crash watcher ────────────────────────────────────── |
| 1124 |
* |
| 1125 |
* start() arms on_shutdown(), which marks a session that is still `running` when the |
| 1126 |
* request ends as `interrupted` and records a retryable failure against it. That is |
| 1127 |
* right for run(), where one request performs all four stages and a request ending |
| 1128 |
* early really is a crash. |
| 1129 |
* |
| 1130 |
* It is wrong here. The wizard runs ONE stage per request, so every intermediate |
| 1131 |
* request ends with the session legitimately still `running` — and was being |
| 1132 |
* reported as interrupted, which surfaced in the UI as a failed stage even though |
| 1133 |
* the stage had completed. Observed in the browser: `install_plugin_theme:done` |
| 1134 |
* fired server-side while the row went red. |
| 1135 |
* |
| 1136 |
* The watcher stays armed for the duration of the stage, so a genuine fatal inside |
| 1137 |
* the stage is still caught; it is only released once the stage has returned. */ |
| 1138 |
Wdkit_Import_Session::unwatch(); |
| 1139 |
|
| 1140 |
/* Out of time with work still queued: report progress and ask to be called again. |
| 1141 |
* Not an error and not complete — the browser re-requests the same stage. */ |
| 1142 |
if ( ! empty( $result['pending'] ) ) { |
| 1143 |
do_action( 'wdkit_import_runner_stage', $stage, 'progress', $result, $this->session_id ); |
| 1144 |
|
| 1145 |
Wdkit_Import_Session::unwatch(); |
| 1146 |
|
| 1147 |
return array( |
| 1148 |
'success' => true, |
| 1149 |
'stage' => $stage, |
| 1150 |
'complete' => false, |
| 1151 |
'pending' => (int) $result['pending'], |
| 1152 |
'result' => $result, |
| 1153 |
'error' => array(), |
| 1154 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1155 |
); |
| 1156 |
} |
| 1157 |
|
| 1158 |
/* Same rule as run(): a stage with failed items stays OPEN so those items can be |
| 1159 |
* retried, but the stage itself still reports success because the run can go on. |
| 1160 |
* Asked of the session, so a failure from an earlier slice is not forgotten. */ |
| 1161 |
if ( ! empty( $result['failed'] ) || $this->outstanding_failures( $stage ) > 0 ) { |
| 1162 |
Wdkit_Import_Session::mark_step_failed( |
| 1163 |
$this->session_id, |
| 1164 |
$stage, |
| 1165 |
array( |
| 1166 |
'code' => 'stage_incomplete', |
| 1167 |
'message' => __( 'Some items in this stage did not import.', 'wdesignkit' ), |
| 1168 |
'step' => $stage, |
| 1169 |
'class' => 'retryable', |
| 1170 |
'retryable' => true, |
| 1171 |
'skippable' => true, |
| 1172 |
'context' => array( 'failed' => count( $result['failed'] ) ), |
| 1173 |
) |
| 1174 |
); |
| 1175 |
|
| 1176 |
do_action( 'wdkit_import_runner_stage', $stage, 'fail', $result, $this->session_id ); |
| 1177 |
|
| 1178 |
return array( |
| 1179 |
'success' => true, |
| 1180 |
'stage' => $stage, |
| 1181 |
'complete' => false, |
| 1182 |
'result' => $result, |
| 1183 |
'error' => array(), |
| 1184 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1185 |
); |
| 1186 |
} |
| 1187 |
|
| 1188 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $stage, $result ); |
| 1189 |
|
| 1190 |
do_action( 'wdkit_import_runner_stage', $stage, 'done', is_array( $result ) ? $result : array(), $this->session_id ); |
| 1191 |
|
| 1192 |
$this->settle_status(); |
| 1193 |
|
| 1194 |
return array( |
| 1195 |
'success' => true, |
| 1196 |
'stage' => $stage, |
| 1197 |
'complete' => true, |
| 1198 |
'result' => $result, |
| 1199 |
'error' => array(), |
| 1200 |
'session' => Wdkit_Import_Session::get( $this->session_id ), |
| 1201 |
); |
| 1202 |
} |
| 1203 |
|
| 1204 |
/* |
| 1205 |
|-------------------------------------------------------------------------- |
| 1206 |
| Stage 1 — dependencies |
| 1207 |
|-------------------------------------------------------------------------- |
| 1208 |
*/ |
| 1209 |
|
| 1210 |
/** |
| 1211 |
* Install the plugins and theme the kit declares. |
| 1212 |
* |
| 1213 |
* The list comes from the kit, never from the context — see the note in |
| 1214 |
* Wdkit_Import_Context. Reuses Wdkit_Depends_Installer, the same class the |
| 1215 |
* `install_plugins_depends` AJAX action calls. |
| 1216 |
* |
| 1217 |
* @return array |
| 1218 |
*/ |
| 1219 |
private function stage_dependencies() { |
| 1220 |
/* Reset first, before anything is created. That ordering is what makes an imported |
| 1221 |
* header/footer the active one: Nexter's renderer has no priority mechanism, so the |
| 1222 |
* only thing that stops an existing header rendering alongside the new one is having |
| 1223 |
* been disabled BEFORE the new one is inserted. The wizard achieves the same by |
| 1224 |
* running its reset in the stage ahead of import_kit(). Off unless asked. */ |
| 1225 |
$reset = $this->once( |
| 1226 |
'reset_site', |
| 1227 |
function () { |
| 1228 |
return Wdkit_Import_Reset::run( $this->context ); |
| 1229 |
} |
| 1230 |
); |
| 1231 |
|
| 1232 |
$dependencies = $this->kit_dependencies(); |
| 1233 |
|
| 1234 |
$installed = ! empty( $dependencies ) |
| 1235 |
? Wdkit_Import_Dependencies::install( $dependencies ) |
| 1236 |
: array( |
| 1237 |
'installed' => array(), |
| 1238 |
'already_installed' => array(), |
| 1239 |
'skipped' => array(), |
| 1240 |
'failed' => array(), |
| 1241 |
); |
| 1242 |
|
| 1243 |
/* Enable the kit's widgets HERE, in stage 1, not in stage_setup where the old |
| 1244 |
* `widgets` step lives — by then every page is already saved, and Elementor's |
| 1245 |
* Document::save() has silently dropped every tp-* node whose widget the site did |
| 1246 |
* not have registered. Stage 1 is a hard barrier before any content stage lane |
| 1247 |
* runs, and it is after the dependency install that just activated The Plus Addons. |
| 1248 |
* Best-effort: a kit still imports if this fails, the server's per-template |
| 1249 |
* `unregistered` report says which widgets each page lost. */ |
| 1250 |
$widgets = $this->once( 'kit_widgets', array( $this, 'prime_and_enable_widgets' ) ); |
| 1251 |
|
| 1252 |
return array( 'reset' => $reset, 'widgets' => $widgets ) + $installed; |
| 1253 |
} |
| 1254 |
|
| 1255 |
/** |
| 1256 |
* Prime the kit bundle and turn on every widget/block its content uses. |
| 1257 |
* |
| 1258 |
* Never throws — wrapped so once() records it as a completed step regardless, because a |
| 1259 |
* widget-enable failure must not block the import. |
| 1260 |
* |
| 1261 |
* @return array |
| 1262 |
*/ |
| 1263 |
public function prime_and_enable_widgets() { |
| 1264 |
$builder = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor'; |
| 1265 |
|
| 1266 |
try { |
| 1267 |
Wdkit_Page_Importer::prime_bundle( |
| 1268 |
$this->context, |
| 1269 |
$builder, |
| 1270 |
! empty( $this->context['templates'] ) ? $this->context['templates'] : array() |
| 1271 |
); |
| 1272 |
|
| 1273 |
$this->remember_kit_site_settings(); |
| 1274 |
|
| 1275 |
if ( 'gutenberg' === $builder ) { |
| 1276 |
$blocks = Wdkit_Page_Importer::bundle_block_names(); |
| 1277 |
|
| 1278 |
if ( empty( $blocks ) || ! class_exists( 'Wdkit_Import_Dependencies' ) ) { |
| 1279 |
return array( 'enabled' => 0, 'builder' => $builder ); |
| 1280 |
} |
| 1281 |
|
| 1282 |
$res = Wdkit_Import_Dependencies::enable_blocks( $blocks ); |
| 1283 |
|
| 1284 |
return array( 'enabled' => isset( $res['enabled'] ) ? (int) $res['enabled'] : 0, 'requested' => count( $blocks ), 'builder' => 'gutenberg' ); |
| 1285 |
} |
| 1286 |
|
| 1287 |
$manifest = Wdkit_Page_Importer::bundle_widget_manifest(); |
| 1288 |
|
| 1289 |
if ( ( empty( $manifest['widgets'] ) && empty( $manifest['extensions'] ) ) || ! class_exists( 'Wdkit_Import_Dependencies' ) ) { |
| 1290 |
return array( 'enabled' => 0, 'builder' => $builder ); |
| 1291 |
} |
| 1292 |
|
| 1293 |
$res = Wdkit_Import_Dependencies::enable_widgets( $manifest['widgets'], $manifest['extensions'] ); |
| 1294 |
|
| 1295 |
if ( class_exists( 'Wdkit_Import_Log' ) ) { |
| 1296 |
Wdkit_Import_Log::add( |
| 1297 |
'widgets', |
| 1298 |
array( |
| 1299 |
'widgets' => count( $manifest['widgets'] ), |
| 1300 |
'extensions' => count( $manifest['extensions'] ), |
| 1301 |
'ok' => ! empty( $res['success'] ) ? 1 : 0, |
| 1302 |
) |
| 1303 |
); |
| 1304 |
} |
| 1305 |
|
| 1306 |
return array( |
| 1307 |
'enabled' => ! empty( $res['success'] ), |
| 1308 |
'widgets' => count( $manifest['widgets'] ), |
| 1309 |
'extensions' => count( $manifest['extensions'] ), |
| 1310 |
'builder' => 'elementor', |
| 1311 |
); |
| 1312 |
} catch ( Throwable $e ) { |
| 1313 |
return array( 'enabled' => 0, 'error' => $e->getMessage() ); |
| 1314 |
} |
| 1315 |
} |
| 1316 |
|
| 1317 |
/** |
| 1318 |
* The kit's own declared dependencies, resolved through the catalogue. |
| 1319 |
* |
| 1320 |
* `plugins_id` on each template is the only accepted source — see |
| 1321 |
* Wdkit_Import_Dependencies. An empty result means nothing gets installed, which is |
| 1322 |
* the safe failure: an import that installs nothing is recoverable, one that installs |
| 1323 |
* the wrong thing is not. |
| 1324 |
* |
| 1325 |
* @return array[] |
| 1326 |
*/ |
| 1327 |
private function kit_dependencies() { |
| 1328 |
if ( empty( $this->context['kit_id'] ) ) { |
| 1329 |
return array(); |
| 1330 |
} |
| 1331 |
|
| 1332 |
$templates = ! empty( $this->context['templates'] ) ? $this->context['templates'] : array(); |
| 1333 |
$catalogue = $this->plugin_catalogue(); |
| 1334 |
|
| 1335 |
$dependencies = Wdkit_Import_Dependencies::resolve( $templates, $catalogue ); |
| 1336 |
|
| 1337 |
/* The theme, when the wizard's toggle asked for it. The browser does exactly this - |
| 1338 |
* `if (site_obj?.theme_setting) temp_plugin.push(theme_obj)` in import_loader.js - and |
| 1339 |
* without it the Nexter theme was never installed and never listed among the |
| 1340 |
* dependencies, which is what "nexter theme miss in import" was. |
| 1341 |
* |
| 1342 |
* The descriptor is fixed here rather than taken from the caller. That keeps the property |
| 1343 |
* the context docblock is about: a payload can ask for "the theme" but cannot name a |
| 1344 |
* package to install. Values match theme_obj in import_loader.js. */ |
| 1345 |
if ( ! empty( $this->context['theme_setting'] ) ) { |
| 1346 |
$already = false; |
| 1347 |
|
| 1348 |
foreach ( $dependencies as $dependency ) { |
| 1349 |
if ( isset( $dependency['type'] ) && 'theme' === $dependency['type'] ) { |
| 1350 |
$already = true; |
| 1351 |
break; |
| 1352 |
} |
| 1353 |
} |
| 1354 |
|
| 1355 |
if ( ! $already ) { |
| 1356 |
$dependencies[] = array( |
| 1357 |
'p_id' => 3001, |
| 1358 |
'original_slug' => 'nexter', |
| 1359 |
'plugin_name' => 'Nexter', |
| 1360 |
'freepro' => '0', |
| 1361 |
'type' => 'theme', |
| 1362 |
); |
| 1363 |
} |
| 1364 |
} |
| 1365 |
|
| 1366 |
/* The builder the kit is written for. |
| 1367 |
* |
| 1368 |
* A kit's templates list the addons they use but never the builder itself — the |
| 1369 |
* wizard never needed it, because the builder was already active in the browser |
| 1370 |
* running the import. Headless there is no such guarantee: an Elementor kit |
| 1371 |
* imported its fourteen templates onto a site with Elementor installed but |
| 1372 |
* inactive, and every page rendered blank. The content was all there; nothing was |
| 1373 |
* there to render it. |
| 1374 |
* |
| 1375 |
* Gutenberg is skipped: it is WordPress core, so there is nothing to install. The |
| 1376 |
* descriptor comes from the catalogue rather than being hardcoded here, so the |
| 1377 |
* slug and file path stay whatever the catalogue says they are. */ |
| 1378 |
$builder_ids = array( |
| 1379 |
'elementor' => 1001, |
| 1380 |
); |
| 1381 |
|
| 1382 |
$kit_builder = isset( $this->context['builder'] ) ? (string) $this->context['builder'] : ''; |
| 1383 |
|
| 1384 |
if ( isset( $builder_ids[ $kit_builder ] ) ) { |
| 1385 |
$want = (string) $builder_ids[ $kit_builder ]; |
| 1386 |
$have = false; |
| 1387 |
|
| 1388 |
foreach ( $dependencies as $dependency ) { |
| 1389 |
if ( isset( $dependency['p_id'] ) && (string) $dependency['p_id'] === $want ) { |
| 1390 |
$have = true; |
| 1391 |
break; |
| 1392 |
} |
| 1393 |
} |
| 1394 |
|
| 1395 |
if ( ! $have ) { |
| 1396 |
$entry = Wdkit_Import_Dependencies::builder_entry( $catalogue, $want ); |
| 1397 |
|
| 1398 |
if ( ! empty( $entry ) ) { |
| 1399 |
$dependencies[] = $entry; |
| 1400 |
} |
| 1401 |
} |
| 1402 |
} |
| 1403 |
|
| 1404 |
/* Nexter Extension, when the kit ships theme-builder templates. `nxt_builder` is that |
| 1405 |
* plugin's post type - without it those templates exist as rows nothing renders. The |
| 1406 |
* browser applies the same rule (`if ("nxt_builder" == wp_post_type) push(extension_obj)` |
| 1407 |
* in template_card.js); here it is derived from the kit's own template list rather than |
| 1408 |
* taken from the caller, so it needs no trust. Values match that object. */ |
| 1409 |
$needs_extension = false; |
| 1410 |
|
| 1411 |
foreach ( $templates as $template ) { |
| 1412 |
if ( is_array( $template ) && isset( $template['wp_post_type'] ) && 'nxt_builder' === $template['wp_post_type'] ) { |
| 1413 |
$needs_extension = true; |
| 1414 |
break; |
| 1415 |
} |
| 1416 |
} |
| 1417 |
|
| 1418 |
if ( $needs_extension ) { |
| 1419 |
$have_extension = false; |
| 1420 |
|
| 1421 |
foreach ( $dependencies as $dependency ) { |
| 1422 |
if ( isset( $dependency['original_slug'] ) && 'nexter-extension' === $dependency['original_slug'] ) { |
| 1423 |
$have_extension = true; |
| 1424 |
break; |
| 1425 |
} |
| 1426 |
} |
| 1427 |
|
| 1428 |
if ( ! $have_extension ) { |
| 1429 |
$dependencies[] = array( |
| 1430 |
'p_id' => 2004, |
| 1431 |
'plugin_name' => 'Nexter Extension – Site Enhancements Toolkit', |
| 1432 |
'plugin_slug' => 'nexter-extension/nexter-extension.php', |
| 1433 |
'original_slug' => 'nexter-extension', |
| 1434 |
'freepro' => '0', |
| 1435 |
'type' => 'plugin', |
| 1436 |
); |
| 1437 |
} |
| 1438 |
} |
| 1439 |
|
| 1440 |
/* Nexter Blocks, when this run will import blog posts. |
| 1441 |
* |
| 1442 |
* The posts do not come from the site's own kit: Wdkit_Import_Posts falls back to a |
| 1443 |
* FIXED Gutenberg post kit (POST_KIT_ID) whose templates are `tpgb/*` blocks, |
| 1444 |
* whatever builder the chosen kit uses. Nothing else pulls that plugin in, so on an |
| 1445 |
* Elementor kit every imported post rendered as raw fallback markup - centred text, |
| 1446 |
* bare list bullets, and the post kit's own demo copy still showing where a block |
| 1447 |
* should have rendered the AI's (ClickUp 14ynqxyykcn). |
| 1448 |
* |
| 1449 |
* Gated on the same two conditions the post import itself applies, so a run that |
| 1450 |
* will not create posts does not install a plugin for them: the blog_post answer, |
| 1451 |
* and the "site already has posts" limit. */ |
| 1452 |
$wants_posts = ! empty( $this->context['blog_post'] ) |
| 1453 |
&& class_exists( 'Wdkit_Import_Posts' ) |
| 1454 |
&& count( get_posts( array( 'post_type' => 'post', 'post_status' => 'any', 'numberposts' => Wdkit_Import_Posts::EXISTING_POST_LIMIT + 1, 'fields' => 'ids' ) ) ) <= Wdkit_Import_Posts::EXISTING_POST_LIMIT; |
| 1455 |
|
| 1456 |
if ( $wants_posts ) { |
| 1457 |
$have_blocks = false; |
| 1458 |
|
| 1459 |
foreach ( $dependencies as $dependency ) { |
| 1460 |
if ( isset( $dependency['original_slug'] ) && 'the-plus-addons-for-block-editor' === $dependency['original_slug'] ) { |
| 1461 |
$have_blocks = true; |
| 1462 |
break; |
| 1463 |
} |
| 1464 |
} |
| 1465 |
|
| 1466 |
if ( ! $have_blocks ) { |
| 1467 |
/* From the catalogue when it lists it, so the p_id stays whatever the cloud |
| 1468 |
* says; the fixed descriptor below is the fallback, and free plugins install |
| 1469 |
* from their slug anyway. */ |
| 1470 |
$entry = null; |
| 1471 |
|
| 1472 |
foreach ( (array) $catalogue as $candidate ) { |
| 1473 |
if ( is_array( $candidate ) && isset( $candidate['original_slug'] ) && 'the-plus-addons-for-block-editor' === $candidate['original_slug'] ) { |
| 1474 |
$entry = $candidate; |
| 1475 |
break; |
| 1476 |
} |
| 1477 |
} |
| 1478 |
|
| 1479 |
$dependencies[] = ( null !== $entry ) ? $entry : array( |
| 1480 |
'p_id' => 1002, |
| 1481 |
'plugin_name' => 'Nexter Blocks', |
| 1482 |
'plugin_slug' => 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php', |
| 1483 |
'original_slug' => 'the-plus-addons-for-block-editor', |
| 1484 |
'freepro' => '0', |
| 1485 |
'type' => 'plugin', |
| 1486 |
); |
| 1487 |
} |
| 1488 |
} |
| 1489 |
|
| 1490 |
/* The wizard's feature switches - eCommerce, Dynamic Content, Performance, |
| 1491 |
* Security, Extras. The browser path pushes these onto the same install list |
| 1492 |
* (`site_obj.plugin_setting` -> props.new_plugins in import_loader.js); the runner |
| 1493 |
* never did, so a visitor who switched on Security and Performance watched the |
| 1494 |
* progress list install the kit's own four dependencies and nothing else |
| 1495 |
* (ClickUp 14ynqxyykan). |
| 1496 |
* |
| 1497 |
* The descriptors are fixed here, exactly like the theme and the builder above: the |
| 1498 |
* context carries switch NAMES from a closed list, never a slug, so a payload can |
| 1499 |
* ask for "security" but cannot name a package. Values match new_plugins in |
| 1500 |
* import_temp_main.js. */ |
| 1501 |
$feature_plugins = array( |
| 1502 |
'ecommerce' => array( |
| 1503 |
'p_id' => 1012, |
| 1504 |
'plugin_name' => 'WooCommerce', |
| 1505 |
'plugin_slug' => 'woocommerce/woocommerce.php', |
| 1506 |
'original_slug' => 'woocommerce', |
| 1507 |
'freepro' => '0', |
| 1508 |
'type' => 'plugin', |
| 1509 |
), |
| 1510 |
'dynamic_content' => array( |
| 1511 |
'p_id' => 2001, |
| 1512 |
'plugin_name' => 'Advanced Custom Fields', |
| 1513 |
'plugin_slug' => 'advanced-custom-fields/acf.php', |
| 1514 |
'original_slug' => 'advanced-custom-fields', |
| 1515 |
'freepro' => '0', |
| 1516 |
'type' => 'plugin', |
| 1517 |
), |
| 1518 |
'performance' => array( |
| 1519 |
'p_id' => 2003, |
| 1520 |
'plugin_name' => 'WP-Optimize - Clean, Compress, Cache', |
| 1521 |
'plugin_slug' => 'wp-optimize/wp-optimize.php', |
| 1522 |
'original_slug' => 'wp-optimize', |
| 1523 |
'freepro' => '0', |
| 1524 |
'type' => 'plugin', |
| 1525 |
), |
| 1526 |
'security' => array( |
| 1527 |
'p_id' => 2002, |
| 1528 |
'plugin_name' => 'All-In-One Security (AIOS)', |
| 1529 |
'plugin_slug' => 'all-in-one-wp-security-and-firewall/wp-security.php', |
| 1530 |
'original_slug' => 'all-in-one-wp-security-and-firewall', |
| 1531 |
'freepro' => '0', |
| 1532 |
'type' => 'plugin', |
| 1533 |
), |
| 1534 |
'extras' => array( |
| 1535 |
'p_id' => 2005, |
| 1536 |
'plugin_name' => 'Rank Math SEO', |
| 1537 |
'plugin_slug' => 'seo-by-rank-math/rank-math.php', |
| 1538 |
'original_slug' => 'seo-by-rank-math', |
| 1539 |
'freepro' => '0', |
| 1540 |
'type' => 'plugin', |
| 1541 |
), |
| 1542 |
); |
| 1543 |
|
| 1544 |
$features = ( ! empty( $this->context['features'] ) && is_array( $this->context['features'] ) ) |
| 1545 |
? $this->context['features'] |
| 1546 |
: array(); |
| 1547 |
|
| 1548 |
foreach ( $features as $feature ) { |
| 1549 |
if ( ! isset( $feature_plugins[ $feature ] ) ) { |
| 1550 |
continue; |
| 1551 |
} |
| 1552 |
|
| 1553 |
$slug = $feature_plugins[ $feature ]['original_slug']; |
| 1554 |
$have = false; |
| 1555 |
|
| 1556 |
foreach ( $dependencies as $dependency ) { |
| 1557 |
if ( isset( $dependency['original_slug'] ) && $slug === $dependency['original_slug'] ) { |
| 1558 |
$have = true; |
| 1559 |
break; |
| 1560 |
} |
| 1561 |
} |
| 1562 |
|
| 1563 |
if ( ! $have ) { |
| 1564 |
$dependencies[] = $feature_plugins[ $feature ]; |
| 1565 |
} |
| 1566 |
} |
| 1567 |
|
| 1568 |
/** |
| 1569 |
* Filter the resolved dependency list for a PHP-run import. |
| 1570 |
* |
| 1571 |
* Runs after resolution, so a filter can remove or reorder but the list still |
| 1572 |
* originates from the kit's own metadata. |
| 1573 |
* |
| 1574 |
* @param array[] $dependencies Resolved dependencies. |
| 1575 |
* @param string $kit_id Kit id. |
| 1576 |
* @param array $context Validated context. |
| 1577 |
*/ |
| 1578 |
$dependencies = apply_filters( 'wdkit_import_runner_dependencies', $dependencies, $this->context['kit_id'], $this->context ); |
| 1579 |
|
| 1580 |
return is_array( $dependencies ) ? $dependencies : array(); |
| 1581 |
} |
| 1582 |
|
| 1583 |
/** |
| 1584 |
* The plugin catalogue the kit's `plugins_id` values resolve against. |
| 1585 |
* |
| 1586 |
* Carried on the context by whoever built the session — the same list the browser gets |
| 1587 |
* as `wdkit_meta.plugin`. Exposed as a filter so a caller can supply it without the |
| 1588 |
* runner making its own cloud call mid-import. |
| 1589 |
* |
| 1590 |
* @return array |
| 1591 |
*/ |
| 1592 |
private function plugin_catalogue() { |
| 1593 |
$catalogue = ! empty( $this->context['plugin_catalogue'] ) ? $this->context['plugin_catalogue'] : array(); |
| 1594 |
|
| 1595 |
/* Nobody supplied one, which is every caller except the wizard. Fetch it, because |
| 1596 |
* an empty catalogue means resolve() cannot look up a single `plugins_id` and the |
| 1597 |
* kit's own addons are silently never installed. */ |
| 1598 |
if ( empty( $catalogue ) && class_exists( 'Wdkit_Import_Dependencies' ) ) { |
| 1599 |
$catalogue = Wdkit_Import_Dependencies::catalogue(); |
| 1600 |
} |
| 1601 |
|
| 1602 |
/** |
| 1603 |
* Filter the plugin catalogue used for dependency resolution. |
| 1604 |
* |
| 1605 |
* @param array $catalogue Catalogue entries. |
| 1606 |
* @param array $context Validated context. |
| 1607 |
*/ |
| 1608 |
$catalogue = apply_filters( 'wdkit_import_runner_plugin_catalogue', $catalogue, $this->context ); |
| 1609 |
|
| 1610 |
return is_array( $catalogue ) ? $catalogue : array(); |
| 1611 |
} |
| 1612 |
|
| 1613 |
/* |
| 1614 |
|-------------------------------------------------------------------------- |
| 1615 |
| Stage 2 — content |
| 1616 |
|-------------------------------------------------------------------------- |
| 1617 |
*/ |
| 1618 |
|
| 1619 |
/** |
| 1620 |
* Fetch, prepare and insert every template in the kit. |
| 1621 |
* |
| 1622 |
* Each template is independent: one that fails is recorded and skipped so a single bad |
| 1623 |
* page cannot cost the whole run. |
| 1624 |
* |
| 1625 |
* @return array |
| 1626 |
*/ |
| 1627 |
private function stage_content() { |
| 1628 |
$templates = ! empty( $this->context['templates'] ) ? $this->context['templates'] : array(); |
| 1629 |
|
| 1630 |
/* Taxonomy first: a post template that carries categories needs its terms to exist |
| 1631 |
* before the post is inserted, which is the order the browser uses too |
| 1632 |
* (import_post_taxonomy runs before import_post_json). Idempotent, so a resume |
| 1633 |
* simply re-resolves the same ids. */ |
| 1634 |
$taxonomy = array(); |
| 1635 |
|
| 1636 |
if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'taxonomy' ) ) { |
| 1637 |
$names = Wdkit_Import_Taxonomy::collect( $this->kit_taxonomy_source() ); |
| 1638 |
$taxonomy = Wdkit_Import_Taxonomy::ensure( $names['categories'], $names['tags'] ); |
| 1639 |
|
| 1640 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, 'taxonomy', $taxonomy ); |
| 1641 |
} |
| 1642 |
|
| 1643 |
/* The whole kit in one cloud round trip, before the first template is touched. |
| 1644 |
* Idempotent and best-effort: on a miss every template falls back to its own |
| 1645 |
* `import_template` request, which is what this stage used to do for all of them. */ |
| 1646 |
$builder_name = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor'; |
| 1647 |
|
| 1648 |
Wdkit_Page_Importer::prime_bundle( $this->context, $builder_name, $templates ); |
| 1649 |
|
| 1650 |
/* Post templates are pulled out and handled by Wdkit_Import_Posts, which needs to |
| 1651 |
* resolve taxonomy names to ids and pick a featured image before the insert. They |
| 1652 |
* still go through this same Wdkit_Page_Importer — see that class for why blog posts |
| 1653 |
* are not a separate content pipeline. */ |
| 1654 |
$post_templates = Wdkit_Import_Posts::post_templates( $templates ); |
| 1655 |
$post_ids = array(); |
| 1656 |
|
| 1657 |
foreach ( $post_templates as $post_template ) { |
| 1658 |
$post_ids[ (string) $post_template['id'] ] = true; |
| 1659 |
} |
| 1660 |
|
| 1661 |
/* Every page listed before anything is imported, as the plugin wizard does - and before |
| 1662 |
* the AI batch below, which can take most of this stage on its own. */ |
| 1663 |
$this->report_page_progress( $templates, $post_ids, array(), array() ); |
| 1664 |
|
| 1665 |
// Make sure all required widgets are enabled and registered before templates are imported and saved. |
| 1666 |
$this->prime_and_enable_widgets(); |
| 1667 |
|
| 1668 |
/* Every template's AI copy in one batched cloud round trip, instead of one |
| 1669 |
* `ai/template_import` call per template inside the foreach below. This is what |
| 1670 |
* turns 14-19 sequential 2-55s calls into a single request bounded by the slowest |
| 1671 |
* page — the sequential version was the bulk of a 2-3 minute import. Idempotent and |
| 1672 |
* best-effort per prime_ai_batch()'s own contract: a miss just leaves that one |
| 1673 |
* template on its original per-template call. The session id lets it skip any |
| 1674 |
* template a previous slice already got AI copy for — see its own docblock for why |
| 1675 |
* that is not optional on a resumable, multi-request run. |
| 1676 |
* |
| 1677 |
* Claimed, not called unconditionally: the browser opens RUNNER_LANES requests in |
| 1678 |
* the same tick, and self::$ai_batch_cache is a per-PROCESS static — nothing tells |
| 1679 |
* a sibling lane this one is already three seconds into the same 25-50s call, so |
| 1680 |
* without a claim every lane placed its own identical request for the same 14 |
| 1681 |
* pages, in parallel, each paying the full network cost (measured: two lanes' own |
| 1682 |
* ai_batch log lines a second apart, both 50s+, for the same kit the first lane |
| 1683 |
* had already answered). A lane that loses the claim leaves its own cache empty |
| 1684 |
* for this slice — exactly the "batch never ran" case prime_ai_batch()'s own |
| 1685 |
* callers already handle by falling back to the normal out_of_time()-gated |
| 1686 |
* per-template path below — and picks up whatever the claiming lane saved |
| 1687 |
* (has_payload()) on its next poll, instead of duplicating the fetch. */ |
| 1688 |
if ( Wdkit_Import_Session::claim( $this->session_id, 'ai_batch_prime' ) ) { |
| 1689 |
try { |
| 1690 |
Wdkit_Page_Importer::prime_ai_batch( $this->context, $builder_name, $templates, $this->session_id ); |
| 1691 |
} finally { |
| 1692 |
Wdkit_Import_Session::release( $this->session_id, 'ai_batch_prime' ); |
| 1693 |
} |
| 1694 |
} |
| 1695 |
|
| 1696 |
$this->remember_kit_site_settings(); |
| 1697 |
|
| 1698 |
/* Palette, plugin and theme settings first — latest's order, and the order the pages |
| 1699 |
* actually need. See settings_before_content(). */ |
| 1700 |
$this->settings_before_content( |
| 1701 |
( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor' |
| 1702 |
); |
| 1703 |
|
| 1704 |
$importer = new Wdkit_Page_Importer( $this->context, $this->session_id ); |
| 1705 |
|
| 1706 |
$pages = array(); |
| 1707 |
$failed = array(); |
| 1708 |
|
| 1709 |
|
| 1710 |
$pending = 0; |
| 1711 |
$taken = 0; |
| 1712 |
|
| 1713 |
/* Pages imported in THIS request, as opposed to $pages which accumulates everything |
| 1714 |
* completed so far. The browser runs its global-reference rewrite over this list, and |
| 1715 |
* that transform is NOT idempotent — re-running it on an already-rewritten page |
| 1716 |
* strips it further each time (observed: 227KB -> 212KB -> 207KB -> 205KB across |
| 1717 |
* slices). It must therefore see each page exactly once. */ |
| 1718 |
$created = array(); |
| 1719 |
|
| 1720 |
foreach ( $templates as $template ) { |
| 1721 |
if ( isset( $post_ids[ (string) $template['id'] ] ) ) { |
| 1722 |
continue; |
| 1723 |
} |
| 1724 |
|
| 1725 |
/* Already tried in this process and it did not land — leave it for an explicit |
| 1726 |
* retry rather than re-attempting it now and starving the templates after it. */ |
| 1727 |
if ( isset( $this->attempted[ (string) $template['id'] ] ) ) { |
| 1728 |
continue; |
| 1729 |
} |
| 1730 |
|
| 1731 |
/* Out of retries across the whole run, not just this process. Counting it as |
| 1732 |
* pending here would ask the browser to call the stage again for a step that can |
| 1733 |
* only fail the same way, which is the loop that reached 907 attempts. Skipping it |
| 1734 |
* instead lets the stage settle so the failure reaches the screen. */ |
| 1735 |
if ( Wdkit_Import_Session::is_step_exhausted( $this->session_id, 'page_' . $template['id'] ) ) { |
| 1736 |
continue; |
| 1737 |
} |
| 1738 |
|
| 1739 |
/* Slice spent — count what is left and hand control back. Checked between |
| 1740 |
* templates, never inside one, so a template is always all-or-nothing. |
| 1741 |
* |
| 1742 |
* A template prime_ai_batch() already has an answer for gets FREE_ANSWER_BUDGET |
| 1743 |
* instead of STAGE_BUDGET: that page costs this request a merge and a DB write, |
| 1744 |
* not the network wait out_of_time() exists to bound, so gating it identically |
| 1745 |
* to a page that still needs a fresh cloud call was the actual cause of a real |
| 1746 |
* stall — the batch call alone can run past the whole stage budget (bounded by |
| 1747 |
* its slowest page, by design), so every page reported pending before any of |
| 1748 |
* them were imported, nothing was ever saved, and the identical batch call |
| 1749 |
* repeated forever. See has_batched_answer()'s own docblock. |
| 1750 |
* |
| 1751 |
* It is not UNBOUNDED, though — that was this fix's own first pass, and it let |
| 1752 |
* one stubborn template push a single request to 82s, past what a browser or |
| 1753 |
* reverse proxy will wait for before showing a 504 (the import kept running |
| 1754 |
* server-side and finished anyway, but the wizard had already shown an error). |
| 1755 |
* out_of_free_time() gives free templates their own, separate, still-bounded |
| 1756 |
* slice instead of no limit at all. */ |
| 1757 |
$has_free_answer = Wdkit_Page_Importer::has_batched_answer( $template['id'], $this->session_id ); |
| 1758 |
|
| 1759 |
if ( $has_free_answer ? $this->out_of_free_time() : ( $this->out_of_time() || $taken >= self::STAGE_ITEM_CAP ) ) { |
| 1760 |
if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'page_' . $template['id'] ) ) { |
| 1761 |
++$pending; |
| 1762 |
} |
| 1763 |
|
| 1764 |
continue; |
| 1765 |
} |
| 1766 |
|
| 1767 |
$step = 'page_' . $template['id']; |
| 1768 |
|
| 1769 |
/* Already imported on a previous attempt. This is the idempotency guarantee: |
| 1770 |
* a retry after page 6 fails must not re-import — or re-charge for — pages 1-5. */ |
| 1771 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 1772 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 1773 |
|
| 1774 |
if ( ! empty( $session['results'][ $step ] ) ) { |
| 1775 |
$pages[] = $session['results'][ $step ]; |
| 1776 |
} |
| 1777 |
|
| 1778 |
continue; |
| 1779 |
} |
| 1780 |
|
| 1781 |
/* Another concurrent request is already on this template. Count it as outstanding so |
| 1782 |
* this lane keeps asking, but do not import it a second time. */ |
| 1783 |
if ( ! Wdkit_Import_Session::claim( $this->session_id, $step ) ) { |
| 1784 |
++$pending; |
| 1785 |
|
| 1786 |
continue; |
| 1787 |
} |
| 1788 |
|
| 1789 |
/* The is_step_complete() check above and this claim are not one atomic step: |
| 1790 |
* another lane can complete the template in the gap, and a stale-claim takeover |
| 1791 |
* can hand this lane a step a now-gone lane finished cleanly but never released. |
| 1792 |
* Re-check on the step's own row before paying to import it again. */ |
| 1793 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 1794 |
Wdkit_Import_Session::release( $this->session_id, $step ); |
| 1795 |
|
| 1796 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 1797 |
|
| 1798 |
if ( ! empty( $session['results'][ $step ] ) ) { |
| 1799 |
$pages[] = $session['results'][ $step ]; |
| 1800 |
} |
| 1801 |
|
| 1802 |
continue; |
| 1803 |
} |
| 1804 |
|
| 1805 |
++$taken; |
| 1806 |
|
| 1807 |
$this->attempted[ (string) $template['id'] ] = true; |
| 1808 |
|
| 1809 |
try { |
| 1810 |
$page = $importer->import( $template ); |
| 1811 |
|
| 1812 |
$pages[] = $page; |
| 1813 |
$created[] = $page; |
| 1814 |
|
| 1815 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $page ); |
| 1816 |
} catch ( Wdkit_Import_Exception $e ) { |
| 1817 |
$record = Wdkit_Import_Errors::to_record( $e, $step ); |
| 1818 |
|
| 1819 |
if ( $e->is_skippable() ) { |
| 1820 |
/* This one template does not exist on the site and never will - not a |
| 1821 |
* transient failure to retry, not a reason to stall the step or show an |
| 1822 |
* error. Marking it complete (not failed) is what stops it being retried |
| 1823 |
* on every later slice; leaving it out of $pages/$created/$failed is what |
| 1824 |
* keeps a page nothing was written for out of the nav menu, front-page |
| 1825 |
* detection and media sweep, which all key off a real post id. This is |
| 1826 |
* the runner's version of the browser importer writing nothing for the |
| 1827 |
* same case (class-api.php:8911 / :9071) - both end with the kit's other |
| 1828 |
* templates imported and this one simply absent. */ |
| 1829 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, $record ); |
| 1830 |
} else { |
| 1831 |
$failed[] = $record; |
| 1832 |
|
| 1833 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, $record ); |
| 1834 |
} |
| 1835 |
} catch ( Throwable $e ) { |
| 1836 |
$record = Wdkit_Import_Errors::to_record( $e, $step ); |
| 1837 |
|
| 1838 |
$failed[] = $record; |
| 1839 |
|
| 1840 |
Wdkit_Import_Session::mark_step_failed( $this->session_id, $step, $record ); |
| 1841 |
} |
| 1842 |
|
| 1843 |
/* Held only for the duration of the import itself. A completed step is protected by |
| 1844 |
* is_step_complete() from here on, and a failed one must be free for the retry. */ |
| 1845 |
Wdkit_Import_Session::release( $this->session_id, $step ); |
| 1846 |
|
| 1847 |
$this->report_page_progress( $templates, $post_ids, $pages, $failed ); |
| 1848 |
} |
| 1849 |
|
| 1850 |
/* Deferred media is NOT queued from here. Each page's still-remote URLs travel in its |
| 1851 |
* own step result (`image_urls`, recorded by Wdkit_Page_Importer::import()), and |
| 1852 |
* stage_finalize queues them all at once - see schedule_media_sweep(). */ |
| 1853 |
|
| 1854 |
$document = ! empty( $this->context['ai_document'] ) ? $this->context['ai_document'] : array(); |
| 1855 |
|
| 1856 |
/* Blog posts after pages: a post's featured image comes from the same pool the page |
| 1857 |
* images were drawn from, and the reuse penalty only spreads sensibly once the pages |
| 1858 |
* have had their pick. Each post is its own resumable step. */ |
| 1859 |
/* Posts and products only once every page has landed: they are cheaper, and running |
| 1860 |
* them now would spend the slice the remaining pages need. */ |
| 1861 |
if ( $pending > 0 ) { |
| 1862 |
return array( |
| 1863 |
'pages' => $pages, |
| 1864 |
'created' => $created, |
| 1865 |
'failed' => $failed, |
| 1866 |
'taxonomy' => $taxonomy, |
| 1867 |
'pending' => $pending, |
| 1868 |
); |
| 1869 |
} |
| 1870 |
|
| 1871 |
$posts = Wdkit_Import_Posts::import( $post_templates, $this->context, $this->session_id, $document ); |
| 1872 |
|
| 1873 |
/* Products last: they can reference categories created above, and a shop page |
| 1874 |
* imported as a template needs to exist before the shop option is set in stage 3. */ |
| 1875 |
/* A website / sandbox job carries no AI products (the plugin's wizard generates them |
| 1876 |
* in the browser), so ask for them here rather than ship the demo catalogue. */ |
| 1877 |
if ( ! empty( $this->context['ecommerce'] ) |
| 1878 |
&& 'ai_import' === ( isset( $this->context['import_type'] ) ? $this->context['import_type'] : '' ) |
| 1879 |
&& empty( $this->context['products'] ) |
| 1880 |
&& empty( $document['products'] ) |
| 1881 |
) { |
| 1882 |
$document['products'] = Wdkit_Import_Products::generate_ai_products( $this->context, $this->session_id ); |
| 1883 |
} |
| 1884 |
|
| 1885 |
$products = Wdkit_Import_Products::import( |
| 1886 |
Wdkit_Import_Products::apply_ai_document( |
| 1887 |
! empty( $this->context['products'] ) ? $this->context['products'] : array(), |
| 1888 |
$document |
| 1889 |
), |
| 1890 |
isset( $this->context['kit_id'] ) ? $this->context['kit_id'] : '', |
| 1891 |
$this->session_id, |
| 1892 |
array( |
| 1893 |
'update_existing' => ! empty( $this->context['update_existing_products'] ), |
| 1894 |
|
| 1895 |
/* Only a shop import falls back to the demo catalogue — see default_products(). |
| 1896 |
* A remote caller that passes its own `products` never reaches the fallback |
| 1897 |
* anyway, so setting this costs it nothing. */ |
| 1898 |
'use_defaults' => ! empty( $this->context['ecommerce'] ), |
| 1899 |
) |
| 1900 |
); |
| 1901 |
|
| 1902 |
/* Several lanes reach the posts/products tail in the same slice once every page has |
| 1903 |
* landed. Each post/product now takes its own claim (see those loops), so a lane |
| 1904 |
* that finds an item already claimed skips it and reports it as still outstanding — |
| 1905 |
* the same contract $pending gives the page loop, so the stage stays OPEN until the |
| 1906 |
* lane that owns each item has finished it. Without this the first lane to return |
| 1907 |
* would mark the stage complete while a sibling was still mid-post. */ |
| 1908 |
$tail_pending = (int) ( isset( $posts['incomplete'] ) ? $posts['incomplete'] : 0 ) |
| 1909 |
+ (int) ( isset( $products['incomplete'] ) ? $products['incomplete'] : 0 ); |
| 1910 |
|
| 1911 |
if ( $tail_pending > 0 ) { |
| 1912 |
return array( |
| 1913 |
'pages' => $pages, |
| 1914 |
'created' => $created, |
| 1915 |
'failed' => $failed, |
| 1916 |
'taxonomy' => $taxonomy, |
| 1917 |
'posts' => $posts, |
| 1918 |
'products' => $products, |
| 1919 |
'pending' => $tail_pending, |
| 1920 |
); |
| 1921 |
} |
| 1922 |
|
| 1923 |
return array( |
| 1924 |
'pages' => $pages, |
| 1925 |
'created' => $created, |
| 1926 |
'failed' => $failed, |
| 1927 |
'taxonomy' => $taxonomy, |
| 1928 |
'posts' => $posts, |
| 1929 |
'products' => $products, |
| 1930 |
); |
| 1931 |
} |
| 1932 |
|
| 1933 |
/** |
| 1934 |
* Tell stage listeners how far the page list has got, after each page. |
| 1935 |
* |
| 1936 |
* The whole content stage runs in one request on a website-queued job, so its only |
| 1937 |
* report used to come when every page was done - the website's build screen showed an |
| 1938 |
* empty "Importing pages" step and then a finished list. The plugin wizard lists every |
| 1939 |
* page up front and ticks them off as they land; this carries the same information: |
| 1940 |
* the pages done so far, and the titles still to come. |
| 1941 |
* |
| 1942 |
* @param array $templates All templates for the run. |
| 1943 |
* @param array $post_ids Template ids imported as blog posts, keyed by id. |
| 1944 |
* @param array $pages Page records imported so far. |
| 1945 |
* @param array $failed Failure records so far. |
| 1946 |
* @return void |
| 1947 |
*/ |
| 1948 |
private function report_page_progress( $templates, $post_ids, $pages, $failed ) { |
| 1949 |
$handled = array(); |
| 1950 |
|
| 1951 |
foreach ( array_merge( (array) $pages, (array) $failed ) as $record ) { |
| 1952 |
if ( ! is_array( $record ) ) { |
| 1953 |
continue; |
| 1954 |
} |
| 1955 |
|
| 1956 |
if ( ! empty( $record['template_id'] ) ) { |
| 1957 |
$handled[ (string) $record['template_id'] ] = true; |
| 1958 |
} elseif ( ! empty( $record['step'] ) && 0 === strpos( (string) $record['step'], 'page_' ) ) { |
| 1959 |
$handled[ substr( (string) $record['step'], 5 ) ] = true; |
| 1960 |
} |
| 1961 |
} |
| 1962 |
|
| 1963 |
$upcoming = array(); |
| 1964 |
|
| 1965 |
foreach ( (array) $templates as $template ) { |
| 1966 |
$id = isset( $template['id'] ) ? (string) $template['id'] : ''; |
| 1967 |
|
| 1968 |
if ( '' === $id || isset( $post_ids[ $id ] ) || isset( $handled[ $id ] ) |
| 1969 |
|| Wdkit_Import_Session::is_step_complete( $this->session_id, 'page_' . $id ) ) { |
| 1970 |
continue; |
| 1971 |
} |
| 1972 |
|
| 1973 |
$upcoming[] = isset( $template['title'] ) ? trim( explode( '|', (string) $template['title'] )[0] ) : $id; |
| 1974 |
} |
| 1975 |
|
| 1976 |
do_action( |
| 1977 |
'wdkit_import_runner_stage', |
| 1978 |
self::STAGE_CONTENT, |
| 1979 |
'progress', |
| 1980 |
array( |
| 1981 |
'pages' => $pages, |
| 1982 |
'failed' => $failed, |
| 1983 |
'pending' => count( $upcoming ), |
| 1984 |
'upcoming' => $upcoming, |
| 1985 |
), |
| 1986 |
$this->session_id |
| 1987 |
); |
| 1988 |
} |
| 1989 |
|
| 1990 |
/** |
| 1991 |
* Hand every imported page's still-remote media to the background sweep, in one go. |
| 1992 |
* |
| 1993 |
* This used to run once per content slice, and the content stage runs RUNNER_LANES |
| 1994 |
* requests in parallel. wp_schedule_single_event() is a read-modify-write of the single |
| 1995 |
* `cron` option, so a sibling lane that had read that option a moment earlier - to queue |
| 1996 |
* its own thumbnail regeneration, say - wrote it back without the event just added. |
| 1997 |
* Measured on a Taj Bakery import: About Us was queued with its tp-video-player's mp4 |
| 1998 |
* (`media_sweep scheduled:1` in the debug log), the event was gone from the cron array |
| 1999 |
* without ever running, and the video stayed on etemplates.wdesignkit.com with a |
| 2000 |
* blanked id. The browser importer never met this because it queues everything from one |
| 2001 |
* request at the end of the run - which is what this now does too. |
| 2002 |
* |
| 2003 |
* Called from stage_finalize after the navigation and demo-link rewrites, the last |
| 2004 |
* writers of page content, so a cron tick that sideloads straight away cannot have its |
| 2005 |
* rewrite overwritten by a stale read-modify-write from those steps. Only URLs still in |
| 2006 |
* the stored content are queued: the stage-3 attachment sweep may already have |
| 2007 |
* localised some, and handing those over again would download them a second time. |
| 2008 |
* |
| 2009 |
* Best-effort by design. A page whose media could not be queued renders from the source |
| 2010 |
* CDN, which is what it did before deferral existed - so a failure here costs a slower |
| 2011 |
* first paint, never a broken page. |
| 2012 |
* |
| 2013 |
* @since 2.7.2 |
| 2014 |
* |
| 2015 |
* @param array $records Page step results for the whole run (page_records()). |
| 2016 |
* @return array { scheduled: int, pages: array[], order: int[] } - `pages` and `order` |
| 2017 |
* are what the wizard's success screen drains itself. |
| 2018 |
*/ |
| 2019 |
private function schedule_media_sweep( $records ) { |
| 2020 |
$none = array( 'scheduled' => 0, 'pages' => array(), 'order' => array() ); |
| 2021 |
|
| 2022 |
if ( empty( $records ) || ! is_array( $records ) ) { |
| 2023 |
return $none; |
| 2024 |
} |
| 2025 |
|
| 2026 |
if ( ! class_exists( 'Wdkit_Api_Call' ) || ! method_exists( 'Wdkit_Api_Call', 'wdkit_schedule_media_sync_for' ) ) { |
| 2027 |
return $none; |
| 2028 |
} |
| 2029 |
|
| 2030 |
$builder = ( isset( $this->context['builder'] ) && 'gutenberg' === $this->context['builder'] ) ? 'gutenberg' : 'elementor'; |
| 2031 |
$pages = array(); |
| 2032 |
|
| 2033 |
foreach ( $records as $page ) { |
| 2034 |
if ( empty( $page['post_id'] ) || empty( $page['image_urls'] ) || ! is_array( $page['image_urls'] ) ) { |
| 2035 |
continue; |
| 2036 |
} |
| 2037 |
|
| 2038 |
$post_id = (int) $page['post_id']; |
| 2039 |
$stored = str_replace( '\\/', '/', (string) get_post_meta( $post_id, '_elementor_data', true ) ) |
| 2040 |
. (string) get_post_field( 'post_content', $post_id ); |
| 2041 |
|
| 2042 |
$remote = array(); |
| 2043 |
|
| 2044 |
foreach ( $page['image_urls'] as $url ) { |
| 2045 |
if ( is_string( $url ) && '' !== $url |
| 2046 |
&& ( false !== strpos( $stored, $url ) || false !== strpos( $stored, str_replace( '&', '&', $url ) ) ) ) { |
| 2047 |
$remote[] = $url; |
| 2048 |
} |
| 2049 |
} |
| 2050 |
|
| 2051 |
if ( empty( $remote ) ) { |
| 2052 |
continue; |
| 2053 |
} |
| 2054 |
|
| 2055 |
$pages[] = array( |
| 2056 |
'post_id' => $post_id, |
| 2057 |
'builder' => $builder, |
| 2058 |
'image_urls' => array_values( array_unique( $remote ) ), |
| 2059 |
); |
| 2060 |
} |
| 2061 |
|
| 2062 |
if ( empty( $pages ) ) { |
| 2063 |
return $none; |
| 2064 |
} |
| 2065 |
|
| 2066 |
$result = Wdkit_Api_Call::get_instance()->wdkit_schedule_media_sync_for( $pages ); |
| 2067 |
|
| 2068 |
$scheduled = ( is_array( $result ) && isset( $result['scheduled'] ) ) ? (int) $result['scheduled'] : 0; |
| 2069 |
|
| 2070 |
Wdkit_Import_Log::add( |
| 2071 |
'media_sweep', |
| 2072 |
array( |
| 2073 |
'pages' => count( $pages ), |
| 2074 |
'scheduled' => $scheduled, |
| 2075 |
) |
| 2076 |
); |
| 2077 |
|
| 2078 |
return array( |
| 2079 |
'scheduled' => $scheduled, |
| 2080 |
'pages' => $scheduled ? $pages : array(), |
| 2081 |
'order' => ( is_array( $result ) && ! empty( $result['order'] ) ) ? array_map( 'intval', (array) $result['order'] ) : array(), |
| 2082 |
); |
| 2083 |
} |
| 2084 |
|
| 2085 |
/** |
| 2086 |
* Where taxonomy names come from. |
| 2087 |
* |
| 2088 |
* The kit's own template payloads carry them; a context may also supply them for a |
| 2089 |
* post-only import. Both are name lists, and Wdkit_Import_Taxonomy re-cleans whatever |
| 2090 |
* arrives, so this only has to gather. |
| 2091 |
* |
| 2092 |
* @return array[] |
| 2093 |
*/ |
| 2094 |
private function kit_taxonomy_source() { |
| 2095 |
$sources = array(); |
| 2096 |
|
| 2097 |
foreach ( $this->page_records() as $page ) { |
| 2098 |
if ( ! empty( $page['categories'] ) || ! empty( $page['tags'] ) ) { |
| 2099 |
$sources[] = $page; |
| 2100 |
} |
| 2101 |
} |
| 2102 |
|
| 2103 |
if ( ! empty( $this->context['categories'] ) || ! empty( $this->context['tags'] ) ) { |
| 2104 |
$sources[] = array( |
| 2105 |
'categories' => ! empty( $this->context['categories'] ) ? $this->context['categories'] : array(), |
| 2106 |
'tags' => ! empty( $this->context['tags'] ) ? $this->context['tags'] : array(), |
| 2107 |
); |
| 2108 |
} |
| 2109 |
|
| 2110 |
/* And the AI document's own taxonomy list. Wdkit_Import_Posts ensures these again |
| 2111 |
* before it inserts — deliberately, since it needs the resolved ids — but having |
| 2112 |
* them here means a kit with no post templates still gets its terms. */ |
| 2113 |
if ( ! empty( $this->context['ai_document']['taxonomy'] ) ) { |
| 2114 |
$sources[] = $this->context['ai_document']['taxonomy']; |
| 2115 |
} |
| 2116 |
|
| 2117 |
return $sources; |
| 2118 |
} |
| 2119 |
|
| 2120 |
/* |
| 2121 |
|-------------------------------------------------------------------------- |
| 2122 |
| Stage 3 — site setup |
| 2123 |
|-------------------------------------------------------------------------- |
| 2124 |
*/ |
| 2125 |
|
| 2126 |
/** |
| 2127 |
* Apply the site identity the context carries. |
| 2128 |
* |
| 2129 |
* Only ever writes `blogname` and `blogdescription`. A context cannot reach any other |
| 2130 |
* option — that restriction is the point, not an oversight. |
| 2131 |
* |
| 2132 |
* @return array |
| 2133 |
*/ |
| 2134 |
private function stage_setup() { |
| 2135 |
$builder = ! empty( $this->context['builder'] ) ? $this->context['builder'] : 'elementor'; |
| 2136 |
$pages = $this->page_records(); |
| 2137 |
|
| 2138 |
/* Order matters and mirrors the browser: plugin settings before globals (Elementor's |
| 2139 |
* container experiment has to be on before the kit CSS is regenerated), then |
| 2140 |
* globals, then theme, then the site options that point the front page at an |
| 2141 |
* imported page, then widgets. */ |
| 2142 |
/* ── This stage is sliced, for the same reason stage 2 is ────────────────── |
| 2143 |
* |
| 2144 |
* These five sub-steps used to run in a single request with no time budget. On a |
| 2145 |
* live host that is a 504 waiting to happen, and it did: a 19-template Elementor kit |
| 2146 |
* imported all of its content and then died here with |
| 2147 |
* "admin-ajax.php 504", because Elementor's files_manager->clear_cache() and the |
| 2148 |
* theme-builder remap both grow with the kit and the gateway gave up first. |
| 2149 |
* |
| 2150 |
* A 504 kills the process outright - no shutdown handler, no error record - so the |
| 2151 |
* browser could not even say what went wrong. Each sub-step is already memoised by |
| 2152 |
* once(), so all that was missing was permission to stop between them and be called |
| 2153 |
* again. `remaining` is reported as `pending`, which is the signal the browser |
| 2154 |
* already acts on for the content stage. */ |
| 2155 |
$order = array( 'plugin_settings', 'globals', 'theme_settings', 'site_settings', 'widgets' ); |
| 2156 |
$remaining = 0; |
| 2157 |
|
| 2158 |
foreach ( $order as $step ) { |
| 2159 |
if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 2160 |
++$remaining; |
| 2161 |
} |
| 2162 |
} |
| 2163 |
|
| 2164 |
$out = array( |
| 2165 |
'plugin_settings' => null, |
| 2166 |
'globals' => null, |
| 2167 |
'theme_settings' => null, |
| 2168 |
'site_settings' => null, |
| 2169 |
'widgets' => null, |
| 2170 |
); |
| 2171 |
|
| 2172 |
$plugin = $this->once( |
| 2173 |
'plugin_settings', |
| 2174 |
function () use ( $builder ) { |
| 2175 |
return $this->step_plugin_settings( $builder ); |
| 2176 |
} |
| 2177 |
); |
| 2178 |
|
| 2179 |
if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'globals' ) ) { |
| 2180 |
return $this->setup_slice( $out, array( 'plugin_settings' => $plugin ), $order ); |
| 2181 |
} |
| 2182 |
|
| 2183 |
$globals = $this->once( |
| 2184 |
'globals', |
| 2185 |
function () use ( $builder ) { |
| 2186 |
return $this->step_globals( $builder ); |
| 2187 |
} |
| 2188 |
); |
| 2189 |
|
| 2190 |
if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'theme_settings' ) ) { |
| 2191 |
return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals ), $order ); |
| 2192 |
} |
| 2193 |
|
| 2194 |
$theme = $this->once( 'theme_settings', array( 'Wdkit_Import_Settings', 'apply_theme' ) ); |
| 2195 |
|
| 2196 |
if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'site_settings' ) ) { |
| 2197 |
return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals, 'theme_settings' => $theme ), $order ); |
| 2198 |
} |
| 2199 |
|
| 2200 |
$site = $this->once( |
| 2201 |
'site_settings', |
| 2202 |
function () use ( $pages ) { |
| 2203 |
return Wdkit_Import_Settings::apply_site( $this->context, array(), $pages ); |
| 2204 |
} |
| 2205 |
); |
| 2206 |
|
| 2207 |
if ( $this->out_of_time() && ! Wdkit_Import_Session::is_step_complete( $this->session_id, 'widgets' ) ) { |
| 2208 |
return $this->setup_slice( $out, array( 'plugin_settings' => $plugin, 'globals' => $globals, 'theme_settings' => $theme, 'site_settings' => $site ), $order ); |
| 2209 |
} |
| 2210 |
|
| 2211 |
$widgets = $this->once( 'widgets', array( $this, 'enable_kit_widgets' ) ); |
| 2212 |
|
| 2213 |
return array( |
| 2214 |
'plugin_settings' => $plugin, |
| 2215 |
'globals' => $globals, |
| 2216 |
'theme_settings' => $theme, |
| 2217 |
'site_settings' => $site, |
| 2218 |
'widgets' => $widgets, |
| 2219 |
); |
| 2220 |
} |
| 2221 |
|
| 2222 |
/** |
| 2223 |
* A partial setting_up_site result: what this request finished, and how much is left. |
| 2224 |
* |
| 2225 |
* `pending` is what makes the browser call the stage again, and every finished sub-step is |
| 2226 |
* recorded by once(), so the next request resumes rather than repeats. |
| 2227 |
* |
| 2228 |
* @param array $shape Full result shape with null placeholders. |
| 2229 |
* @param array $done Sub-step results this request produced. |
| 2230 |
* @param string[] $order Sub-step names, in order. |
| 2231 |
* @return array |
| 2232 |
*/ |
| 2233 |
private function setup_slice( $shape, $done, $order ) { |
| 2234 |
$left = 0; |
| 2235 |
|
| 2236 |
foreach ( $order as $step ) { |
| 2237 |
if ( ! Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 2238 |
++$left; |
| 2239 |
} |
| 2240 |
} |
| 2241 |
|
| 2242 |
if ( class_exists( 'Wdkit_Import_Log' ) ) { |
| 2243 |
Wdkit_Import_Log::add( |
| 2244 |
'setup_slice', |
| 2245 |
array( |
| 2246 |
'completed' => implode( ',', array_keys( $done ) ), |
| 2247 |
'pending' => $left, |
| 2248 |
) |
| 2249 |
); |
| 2250 |
} |
| 2251 |
|
| 2252 |
return array_merge( $shape, $done, array( 'pending' => $left ) ); |
| 2253 |
} |
| 2254 |
|
| 2255 |
/** |
| 2256 |
* Per-page results recorded during the content stage. |
| 2257 |
* |
| 2258 |
* @return array[] |
| 2259 |
*/ |
| 2260 |
private function page_records() { |
| 2261 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2262 |
|
| 2263 |
if ( null === $session || empty( $session['results'] ) ) { |
| 2264 |
return array(); |
| 2265 |
} |
| 2266 |
|
| 2267 |
$pages = array(); |
| 2268 |
|
| 2269 |
foreach ( $session['results'] as $step => $result ) { |
| 2270 |
if ( 0 === strpos( (string) $step, 'page_' ) && is_array( $result ) ) { |
| 2271 |
$pages[] = $result; |
| 2272 |
} |
| 2273 |
} |
| 2274 |
|
| 2275 |
return $pages; |
| 2276 |
} |
| 2277 |
|
| 2278 |
/** |
| 2279 |
* Ids of the blog posts this run created (or adopted on a resume). |
| 2280 |
* |
| 2281 |
* @return int[] |
| 2282 |
*/ |
| 2283 |
private function blog_post_ids() { |
| 2284 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2285 |
|
| 2286 |
if ( null === $session || empty( $session['results'] ) ) { |
| 2287 |
return array(); |
| 2288 |
} |
| 2289 |
|
| 2290 |
$ids = array(); |
| 2291 |
|
| 2292 |
foreach ( $session['results'] as $step => $result ) { |
| 2293 |
if ( 0 === strpos( (string) $step, 'post_' ) && is_array( $result ) && ! empty( $result['post_id'] ) ) { |
| 2294 |
$ids[] = (int) $result['post_id']; |
| 2295 |
} |
| 2296 |
} |
| 2297 |
|
| 2298 |
return $ids; |
| 2299 |
} |
| 2300 |
|
| 2301 |
/** |
| 2302 |
* Decoded template payloads kept from the content stage, for globals collection. |
| 2303 |
* |
| 2304 |
* @return array[] |
| 2305 |
*/ |
| 2306 |
private function decoded_templates() { |
| 2307 |
$decoded = array(); |
| 2308 |
|
| 2309 |
foreach ( $this->page_records() as $page ) { |
| 2310 |
if ( ! empty( $page['global_data'] ) ) { |
| 2311 |
$decoded[] = array( 'global_data' => $page['global_data'] ); |
| 2312 |
} |
| 2313 |
} |
| 2314 |
|
| 2315 |
return $decoded; |
| 2316 |
} |
| 2317 |
|
| 2318 |
/** |
| 2319 |
* Enable the widgets and extensions the imported content actually uses. |
| 2320 |
* |
| 2321 |
* The list is collected from the decoded templates recorded during the content stage — |
| 2322 |
* derived from kit content, never accepted as input. Reuses the extracted |
| 2323 |
* `enable_template_widgets` logic. |
| 2324 |
* |
| 2325 |
* @return array |
| 2326 |
*/ |
| 2327 |
public function enable_kit_widgets() { |
| 2328 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2329 |
|
| 2330 |
if ( null === $session || empty( $session['results'] ) ) { |
| 2331 |
return array( 'enabled' => false ); |
| 2332 |
} |
| 2333 |
|
| 2334 |
$widget_lists = array(); |
| 2335 |
$block_names = array(); |
| 2336 |
$elementor_ids = array(); |
| 2337 |
|
| 2338 |
foreach ( $session['results'] as $step => $result ) { |
| 2339 |
if ( 0 !== strpos( (string) $step, 'page_' ) || ! is_array( $result ) ) { |
| 2340 |
continue; |
| 2341 |
} |
| 2342 |
|
| 2343 |
if ( ! empty( $result['widget_list'] ) ) { |
| 2344 |
$widget_lists[] = array( 'widget_list' => $result['widget_list'] ); |
| 2345 |
} |
| 2346 |
|
| 2347 |
/* Gutenberg blocks are named in the markup, not in `widget_list` - that field and |
| 2348 |
* `widgetType` are Elementor's. Read them back off the post that was just created, |
| 2349 |
* which is also what makes this correct on a resume: a request that imported no |
| 2350 |
* pages still sees every page the run created. */ |
| 2351 |
if ( 'gutenberg' === $this->context['builder'] && ! empty( $result['post_id'] ) ) { |
| 2352 |
$block_names = array_merge( |
| 2353 |
$block_names, |
| 2354 |
Wdkit_Import_Dependencies::block_names_in( get_post_field( 'post_content', (int) $result['post_id'] ) ) |
| 2355 |
); |
| 2356 |
} elseif ( ! empty( $result['post_id'] ) ) { |
| 2357 |
$elementor_ids[] = (int) $result['post_id']; |
| 2358 |
} |
| 2359 |
} |
| 2360 |
|
| 2361 |
/* The bundle engine does not report a per-page `widget_list`, so the loop above |
| 2362 |
* usually collects nothing for an Elementor kit. This step is a second pass behind |
| 2363 |
* stage 1's kit_widgets - anything already registered when Document::save() ran was |
| 2364 |
* kept, and re-enabling it is idempotent; anything that was NOT is gone from the row |
| 2365 |
* and cannot be recovered here, which is why the real work happens in stage 1. Read |
| 2366 |
* the widget types straight off what landed, so a resume that imported nothing this |
| 2367 |
* request still sees every page. */ |
| 2368 |
if ( empty( $widget_lists ) && ! empty( $elementor_ids ) ) { |
| 2369 |
$types = array(); |
| 2370 |
|
| 2371 |
foreach ( array_unique( $elementor_ids ) as $pid ) { |
| 2372 |
$data = get_post_meta( $pid, '_elementor_data', true ); |
| 2373 |
$data = is_string( $data ) ? $data : (string) wp_json_encode( $data ); |
| 2374 |
|
| 2375 |
if ( preg_match_all( '/"widgetType"\s*:\s*"([^"]+)"/', $data, $m ) ) { |
| 2376 |
$types = array_merge( $types, $m[1] ); |
| 2377 |
} |
| 2378 |
} |
| 2379 |
|
| 2380 |
$types = array_values( array_unique( array_filter( $types, 'is_string' ) ) ); |
| 2381 |
|
| 2382 |
if ( ! empty( $types ) ) { |
| 2383 |
$widget_lists[] = array( 'widget_list' => $types ); |
| 2384 |
} |
| 2385 |
} |
| 2386 |
|
| 2387 |
$blocks = array(); |
| 2388 |
|
| 2389 |
if ( ! empty( $block_names ) ) { |
| 2390 |
$blocks = Wdkit_Import_Dependencies::enable_blocks( array_unique( $block_names ) ); |
| 2391 |
} |
| 2392 |
|
| 2393 |
if ( empty( $widget_lists ) ) { |
| 2394 |
return array( |
| 2395 |
'enabled' => ! empty( $blocks['success'] ), |
| 2396 |
'blocks' => $blocks, |
| 2397 |
); |
| 2398 |
} |
| 2399 |
|
| 2400 |
$collected = Wdkit_Import_Dependencies::collect_widgets( $widget_lists ); |
| 2401 |
|
| 2402 |
if ( empty( $collected['widgets'] ) && empty( $collected['extensions'] ) ) { |
| 2403 |
return array( |
| 2404 |
'enabled' => ! empty( $blocks['success'] ), |
| 2405 |
'blocks' => $blocks, |
| 2406 |
); |
| 2407 |
} |
| 2408 |
|
| 2409 |
$response = Wdkit_Import_Dependencies::enable_widgets( $collected['widgets'], $collected['extensions'] ); |
| 2410 |
|
| 2411 |
return array( |
| 2412 |
'enabled' => ! empty( $response['success'] ), |
| 2413 |
'widgets' => count( $collected['widgets'] ), |
| 2414 |
'blocks' => $blocks, |
| 2415 |
); |
| 2416 |
} |
| 2417 |
|
| 2418 |
/* |
| 2419 |
|-------------------------------------------------------------------------- |
| 2420 |
| Stage 4 — finalize |
| 2421 |
|-------------------------------------------------------------------------- |
| 2422 |
*/ |
| 2423 |
|
| 2424 |
/** |
| 2425 |
* Close the run out and drop the AI payloads. |
| 2426 |
* |
| 2427 |
* The payload files are only needed while the merge is running; leaving them behind |
| 2428 |
* would accumulate template JSON in uploads indefinitely. |
| 2429 |
* |
| 2430 |
* @return array |
| 2431 |
*/ |
| 2432 |
/** |
| 2433 |
* Gives every post with Nexter Blocks css a fresh version, so plus-global.css is re-fetched. |
| 2434 |
* |
| 2435 |
* @return int Number of posts updated. |
| 2436 |
*/ |
| 2437 |
public function bump_plus_css_versions() { |
| 2438 |
global $wpdb; |
| 2439 |
|
| 2440 |
$post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", '_block_css' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 2441 |
$version = time(); |
| 2442 |
$count = 0; |
| 2443 |
|
| 2444 |
/* A post with no version of its own falls back to this option - and it is what |
| 2445 |
* Nexter Blocks' own "clear cache" bumps. */ |
| 2446 |
if ( false !== get_option( 'tpgb_backend_cache_at' ) ) { |
| 2447 |
update_option( 'tpgb_backend_cache_at', $version, false ); |
| 2448 |
} |
| 2449 |
|
| 2450 |
foreach ( $post_ids as $post_id ) { |
| 2451 |
$meta = get_post_meta( (int) $post_id, '_block_css', true ); |
| 2452 |
if ( ! is_array( $meta ) ) { |
| 2453 |
continue; |
| 2454 |
} |
| 2455 |
$meta['version'] = $version; |
| 2456 |
update_post_meta( (int) $post_id, '_block_css', $meta ); |
| 2457 |
++$count; |
| 2458 |
} |
| 2459 |
|
| 2460 |
return $count; |
| 2461 |
} |
| 2462 |
|
| 2463 |
private function stage_finalize() { |
| 2464 |
$builder = ! empty( $this->context['builder'] ) ? $this->context['builder'] : 'elementor'; |
| 2465 |
$pages = $this->page_records(); |
| 2466 |
|
| 2467 |
/* Runs here, not in stage 2: the map is only complete once every page exists, and a |
| 2468 |
* partial map would leave later pages pointing at nothing. */ |
| 2469 |
$navigation = $this->once( |
| 2470 |
'navigation', |
| 2471 |
function () use ( $pages, $builder ) { |
| 2472 |
return Wdkit_Import_Navigation::rewrite( |
| 2473 |
Wdkit_Import_Navigation::build_map( $pages ), |
| 2474 |
$pages, |
| 2475 |
$builder |
| 2476 |
); |
| 2477 |
} |
| 2478 |
); |
| 2479 |
|
| 2480 |
/* Same reason and the same moment: the kit's links are absolute urls into the demo |
| 2481 |
* site it was captured from, and only once every page exists is there a local |
| 2482 |
* permalink to point each one at. Runs for both builders - the browser's version of |
| 2483 |
* this is Elementor-shaped and never fired for a gutenberg kit, which is why an |
| 2484 |
* imported site's whole menu still navigated to gtemplates.wdesignkit.com. |
| 2485 |
* |
| 2486 |
* Chunked, unlike `navigation` above: this is one wp_update_post() and one |
| 2487 |
* get_post_meta()/update_post_meta() pair PER PAGE, with no upper bound - a 50+ page |
| 2488 |
* kit could run this in one call for as long as PHP lets it, the same shape as the |
| 2489 |
* already-known prime_ai_batch() P0 (ClickUp 14ynqxyxffw). finalize_demo_links() |
| 2490 |
* checks out_of_time() between pages and reports back the same way stage_content() |
| 2491 |
* reports an unfinished page loop - `pending` > 0 keeps this stage OPEN so the |
| 2492 |
* wizard calls it again, picking up only the pages `demo_link_<id>` has not already |
| 2493 |
* marked done. */ |
| 2494 |
$links = $this->finalize_demo_links( $pages ); |
| 2495 |
|
| 2496 |
if ( ! empty( $links['pending'] ) ) { |
| 2497 |
return array( |
| 2498 |
'navigation' => $navigation, |
| 2499 |
'demo_links' => $links, |
| 2500 |
'pending' => $links['pending'], |
| 2501 |
); |
| 2502 |
} |
| 2503 |
|
| 2504 |
/* Nexter Blocks versions the shared plus-global.css by each post's `_block_css` |
| 2505 |
* version, so a post that existed before this import keeps its old ?ver= and the |
| 2506 |
* browser serves it the stale global css (ClickUp 14ynqxyykcn). Bumping the version |
| 2507 |
* only changes that query string - the same thing Nexter Blocks does on post save. */ |
| 2508 |
$this->once( 'plus_css_version', array( $this, 'bump_plus_css_versions' ) ); |
| 2509 |
|
| 2510 |
/* Queue the deferred media now, from this one request, after the two rewrites above - |
| 2511 |
* the last steps that write page content. See schedule_media_sweep() for why this is |
| 2512 |
* not done from the parallel content lanes. The pages come back to the wizard, whose |
| 2513 |
* success screen fetches them straight away instead of waiting for wp-cron. */ |
| 2514 |
$media = $this->once( |
| 2515 |
'media_sweep', |
| 2516 |
function () use ( $pages ) { |
| 2517 |
return $this->schedule_media_sweep( $pages ); |
| 2518 |
} |
| 2519 |
); |
| 2520 |
|
| 2521 |
/* Same lost-update exposure for the thumbnail pass the content lanes queue: make sure |
| 2522 |
* it is still scheduled while any prefetched attachment is waiting on it. */ |
| 2523 |
if ( class_exists( 'Wdkit_Import_Media' ) ) { |
| 2524 |
Wdkit_Import_Media::ensure_thumbnail_regeneration(); |
| 2525 |
} |
| 2526 |
|
| 2527 |
/* Declare the global variables the page CSS is about to reference. Every page refers |
| 2528 |
* to the palette and typography as var(--tpgb-C12) / var(--tpgb-T15-font-size), and |
| 2529 |
* those live in one shared file; without it the pages are correct and entirely |
| 2530 |
* unstyled. Written before the per-page CSS and before the front page is warmed, so |
| 2531 |
* nothing renders against undefined variables. |
| 2532 |
* |
| 2533 |
* On the browser path the editor pass that runs after this writes the same file from |
| 2534 |
* its own generator, which also emits the dark-mode aliases onto the active theme's |
| 2535 |
* palette. That pass therefore supersedes this one, deliberately: this exists for the |
| 2536 |
* case where no browser is involved. */ |
| 2537 |
$globals = ( 'gutenberg' === $builder && class_exists( 'Wdkit_Import_Css' ) ) |
| 2538 |
? $this->once( 'global_css', array( 'Wdkit_Import_Css', 'globals' ) ) |
| 2539 |
: array( 'available' => false, 'written' => false ); |
| 2540 |
|
| 2541 |
/* Build every imported post's block CSS here, in PHP. The browser's fallback is a |
| 2542 |
* hidden editor iframe per page at ~8s each; this is ~0.015s each. Reported back so the |
| 2543 |
* browser can skip that pass when it worked. */ |
| 2544 |
/* The blog posts too: they come from the post kit and are built from the same |
| 2545 |
* blocks, and were the one set of imported content whose CSS was left for the |
| 2546 |
* visitor's first view to build. |
| 2547 |
* |
| 2548 |
* Chunked the same way as the demo-link rewrite above: ~15ms/page on its own, but |
| 2549 |
* Wdkit_Import_Css::generate() looped every post with no out_of_time() check either |
| 2550 |
* (same ClickUp 14ynqxyxffw pass), which adds up across a 100+ page kit on top of |
| 2551 |
* whatever the rewrite above already spent. finalize_css() calls the same per-post |
| 2552 |
* Wdkit_Import_Css::rebuild() the unbounded generate() loop did, just budgeted. */ |
| 2553 |
$css = $this->finalize_css( $builder, $pages ); |
| 2554 |
|
| 2555 |
if ( ! empty( $css['pending'] ) ) { |
| 2556 |
return array( |
| 2557 |
'navigation' => $navigation, |
| 2558 |
'demo_links' => $links, |
| 2559 |
'media' => $media, |
| 2560 |
'global_css' => $globals, |
| 2561 |
'css' => $css, |
| 2562 |
'pending' => $css['pending'], |
| 2563 |
); |
| 2564 |
} |
| 2565 |
|
| 2566 |
/* One render per imported page, so the user's first look at each one is not the |
| 2567 |
* request that builds its block-asset bundle. |
| 2568 |
* |
| 2569 |
* Nexter Blocks builds a page's `theplus-post-{id}.min.css` bundle DURING the page's |
| 2570 |
* first front-end render, and until it has, it serves that render's block CSS as |
| 2571 |
* late, async footer `rel="preload"` links. So every imported page - not just the |
| 2572 |
* front page - showed unstyled on its first visit and correct after a refresh. Only a |
| 2573 |
* real render records what the bundle needs (`_block_css['blocks']`/`updated_at`), so |
| 2574 |
* the fix is to render each page once before the user does. |
| 2575 |
* |
| 2576 |
* The wizard does that from the success screen with these URLs (see |
| 2577 |
* warm_pages_now() in import_loader.js): the visitor's own browser can always |
| 2578 |
* reach the site, which a loopback from here cannot be relied on to. The cron event |
| 2579 |
* below stays as the fallback for a run with no browser behind it. |
| 2580 |
* |
| 2581 |
* Deferred to its own request rather than done here, and that is not a tidiness |
| 2582 |
* preference — warming asks the site to render its own front page over HTTP, and |
| 2583 |
* doing that from inside a request that is already holding a PHP worker deadlocks |
| 2584 |
* on any host with a small worker pool. Measured on a Local install: both the |
| 2585 |
* site-URL attempt and the 127.0.0.1 fallback timed out at 15s each, so a step |
| 2586 |
* whose entire purpose is to save the visitor one slow page load was costing 30 |
| 2587 |
* seconds of the import and warming nothing at all. |
| 2588 |
* |
| 2589 |
* A one-off cron event runs it after this request has let go of its worker, where |
| 2590 |
* it can actually succeed. Nothing waits on it: if it never fires, the first |
| 2591 |
* visitor pays what they would have paid anyway. |
| 2592 |
* |
| 2593 |
* A minute out, not seconds: the success screen warms the same pages straight away, |
| 2594 |
* and this is only for a run with no browser behind it. Warming a page that is |
| 2595 |
* already built is a normal page view, so the late pass costs nothing. */ |
| 2596 |
$warm_pages = ( 'gutenberg' === $builder ) ? $this->warm_pages( $pages ) : array(); |
| 2597 |
$warm_urls = wp_list_pluck( $warm_pages, 'url' ); |
| 2598 |
|
| 2599 |
if ( ! empty( $css['generated'] ) && ! wp_next_scheduled( 'wdkit_warm_imported_front', array( $warm_urls ) ) ) { |
| 2600 |
wp_schedule_single_event( time() + 60, 'wdkit_warm_imported_front', array( $warm_urls ) ); |
| 2601 |
} |
| 2602 |
|
| 2603 |
Wdkit_Ai_Content::clear_payloads( $this->session_id ); |
| 2604 |
|
| 2605 |
/* No lane can still be working by now, and a stale claim row would outlive the run. */ |
| 2606 |
Wdkit_Import_Session::release_all( $this->session_id ); |
| 2607 |
|
| 2608 |
/** |
| 2609 |
* Fires when a PHP-run import has finished its last stage. |
| 2610 |
* |
| 2611 |
* @param string $session_id Session id. |
| 2612 |
* @param array $context Validated context. |
| 2613 |
*/ |
| 2614 |
do_action( 'wdkit_import_runner_finalized', $this->session_id, $this->context ); |
| 2615 |
|
| 2616 |
/* Cleanup is last and is gated on allow_destructive_cleanup, which defaults to |
| 2617 |
* false. With the flag off both calls report why they did nothing; with it on, the |
| 2618 |
* orphan sweep still refuses any id the session cannot prove this run created. */ |
| 2619 |
$cleanup = $this->once( |
| 2620 |
'cleanup', |
| 2621 |
function () { |
| 2622 |
return array( |
| 2623 |
'sample_post' => Wdkit_Import_Cleanup::remove_sample_post( $this->context ), |
| 2624 |
'orphans' => Wdkit_Import_Cleanup::remove_orphaned_posts( |
| 2625 |
$this->orphaned_post_ids(), |
| 2626 |
$this->context, |
| 2627 |
$this->session_id |
| 2628 |
), |
| 2629 |
); |
| 2630 |
} |
| 2631 |
); |
| 2632 |
|
| 2633 |
return array( |
| 2634 |
'finalized' => true, |
| 2635 |
'navigation' => $navigation, |
| 2636 |
'demo_links' => $links, |
| 2637 |
'cleanup' => $cleanup, |
| 2638 |
'css' => $css, |
| 2639 |
'global_css' => $globals, |
| 2640 |
'media' => $media, |
| 2641 |
'warm_pages' => $warm_pages, |
| 2642 |
); |
| 2643 |
} |
| 2644 |
|
| 2645 |
/** |
| 2646 |
* Demo-link rewrite, budgeted one page at a time. |
| 2647 |
* |
| 2648 |
* Wdkit_Import_Navigation::build_url_rewrite_map() (cheap - array bookkeeping over |
| 2649 |
* templates/page_records) runs in full every call; the per-page write, |
| 2650 |
* apply_url_rewrite_to_post(), is what this bounds. Each page gets its own |
| 2651 |
* `demo_link_<id>` step, exactly the way stage_content() tracks `page_<id>`, so a |
| 2652 |
* resumed call skips every page already rewritten and only pays for what is left. |
| 2653 |
* |
| 2654 |
* @since 2.7.3 |
| 2655 |
* |
| 2656 |
* @param array[] $pages Page step results (page_records()). |
| 2657 |
* @return array{scanned:int,rewritten:int,links:int,scrubbed:int,pending?:int} |
| 2658 |
*/ |
| 2659 |
private function finalize_demo_links( $pages ) { |
| 2660 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'demo_links' ) ) { |
| 2661 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2662 |
|
| 2663 |
return ( null !== $session && ! empty( $session['results']['demo_links'] ) ) |
| 2664 |
? $session['results']['demo_links'] |
| 2665 |
: array( |
| 2666 |
'scanned' => 0, |
| 2667 |
'rewritten' => 0, |
| 2668 |
'links' => 0, |
| 2669 |
'scrubbed' => 0, |
| 2670 |
); |
| 2671 |
} |
| 2672 |
|
| 2673 |
$map = Wdkit_Import_Navigation::build_url_rewrite_map( |
| 2674 |
isset( $this->context['templates'] ) ? $this->context['templates'] : array(), |
| 2675 |
$pages |
| 2676 |
); |
| 2677 |
|
| 2678 |
/* Running total across slices - not itself a gate, just where a resumed call reads |
| 2679 |
* back what earlier slices already rewrote. Never read through is_step_complete(): |
| 2680 |
* that would make the SECOND slice look "done" the moment the first one wrote it. */ |
| 2681 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2682 |
$progress = ( null !== $session && ! empty( $session['results']['demo_links_progress'] ) && is_array( $session['results']['demo_links_progress'] ) ) |
| 2683 |
? $session['results']['demo_links_progress'] |
| 2684 |
: array( 'rewritten' => 0, 'scrubbed' => 0 ); |
| 2685 |
|
| 2686 |
$pending = 0; |
| 2687 |
|
| 2688 |
foreach ( $map['post_ids'] as $post_id ) { |
| 2689 |
$step = 'demo_link_' . $post_id; |
| 2690 |
|
| 2691 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 2692 |
continue; |
| 2693 |
} |
| 2694 |
|
| 2695 |
/* Checked between pages, never inside one - same rule stage_content() applies to |
| 2696 |
* its own per-template loop, and for the same reason: a page's rewrite is always |
| 2697 |
* all-or-nothing, never left half written. */ |
| 2698 |
if ( $this->out_of_time() ) { |
| 2699 |
++$pending; |
| 2700 |
continue; |
| 2701 |
} |
| 2702 |
|
| 2703 |
$applied = Wdkit_Import_Navigation::apply_url_rewrite_to_post( $post_id, $map['search'], $map['replace'] ); |
| 2704 |
|
| 2705 |
$progress['scrubbed'] += $applied['scrubbed']; |
| 2706 |
|
| 2707 |
if ( $applied['rewritten'] ) { |
| 2708 |
++$progress['rewritten']; |
| 2709 |
} |
| 2710 |
|
| 2711 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, true ); |
| 2712 |
} |
| 2713 |
|
| 2714 |
if ( $pending > 0 ) { |
| 2715 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, 'demo_links_progress', $progress ); |
| 2716 |
|
| 2717 |
return array( |
| 2718 |
'scanned' => count( $map['post_ids'] ), |
| 2719 |
'rewritten' => $progress['rewritten'], |
| 2720 |
'links' => $map['links'], |
| 2721 |
'scrubbed' => $progress['scrubbed'], |
| 2722 |
'pending' => $pending, |
| 2723 |
); |
| 2724 |
} |
| 2725 |
|
| 2726 |
$result = array( |
| 2727 |
'scanned' => count( $map['post_ids'] ), |
| 2728 |
'rewritten' => $progress['rewritten'], |
| 2729 |
'links' => $map['links'], |
| 2730 |
'scrubbed' => $progress['scrubbed'], |
| 2731 |
); |
| 2732 |
|
| 2733 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, 'demo_links', $result ); |
| 2734 |
|
| 2735 |
return $result; |
| 2736 |
} |
| 2737 |
|
| 2738 |
/** |
| 2739 |
* Per-post block CSS, budgeted one page at a time. |
| 2740 |
* |
| 2741 |
* Same shape as finalize_demo_links(): Wdkit_Import_Css::generate() used to loop every |
| 2742 |
* imported page and blog post with no out_of_time() check (ClickUp 14ynqxyxffw). This |
| 2743 |
* calls the same per-post Wdkit_Import_Css::rebuild() that loop called, through a |
| 2744 |
* `css_gen_<id>` step per post so a resumed call only rebuilds what is left. |
| 2745 |
* |
| 2746 |
* @since 2.7.3 |
| 2747 |
* |
| 2748 |
* @param string $builder 'elementor'|'gutenberg'. |
| 2749 |
* @param array[] $pages Page step results (page_records()). |
| 2750 |
* @return array{available:bool,generated:int,pending?:int} |
| 2751 |
*/ |
| 2752 |
private function finalize_css( $builder, $pages ) { |
| 2753 |
if ( ! class_exists( 'Wdkit_Import_Css' ) ) { |
| 2754 |
return array( |
| 2755 |
'available' => false, |
| 2756 |
'generated' => 0, |
| 2757 |
); |
| 2758 |
} |
| 2759 |
|
| 2760 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, 'finalize_css' ) ) { |
| 2761 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2762 |
|
| 2763 |
return ( null !== $session && ! empty( $session['results']['finalize_css'] ) ) |
| 2764 |
? $session['results']['finalize_css'] |
| 2765 |
: array( |
| 2766 |
'available' => true, |
| 2767 |
'generated' => 0, |
| 2768 |
); |
| 2769 |
} |
| 2770 |
|
| 2771 |
/* For Gutenberg builder kits, both pages and blog posts need CSS generation. |
| 2772 |
* For Elementor builder kits, the pages are Elementor, but blog posts from post kit 19866 |
| 2773 |
* are ALWAYS Gutenberg blocks, so their CSS must still be generated! */ |
| 2774 |
$post_ids = ( 'gutenberg' === $builder ) |
| 2775 |
? array_merge( wp_list_pluck( $pages, 'post_id' ), $this->blog_post_ids() ) |
| 2776 |
: $this->blog_post_ids(); |
| 2777 |
|
| 2778 |
$post_ids = array_values( |
| 2779 |
array_unique( |
| 2780 |
array_filter( |
| 2781 |
array_map( 'intval', (array) $post_ids ) |
| 2782 |
) |
| 2783 |
) |
| 2784 |
); |
| 2785 |
|
| 2786 |
if ( empty( $post_ids ) ) { |
| 2787 |
return array( |
| 2788 |
'available' => true, |
| 2789 |
'generated' => 0, |
| 2790 |
); |
| 2791 |
} |
| 2792 |
|
| 2793 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2794 |
$progress = ( null !== $session && ! empty( $session['results']['finalize_css_progress'] ) && is_array( $session['results']['finalize_css_progress'] ) ) |
| 2795 |
? $session['results']['finalize_css_progress'] |
| 2796 |
: array( 'generated' => 0, 'version' => time() ); |
| 2797 |
|
| 2798 |
$pending = 0; |
| 2799 |
|
| 2800 |
foreach ( $post_ids as $post_id ) { |
| 2801 |
$step = 'css_gen_' . $post_id; |
| 2802 |
|
| 2803 |
if ( Wdkit_Import_Session::is_step_complete( $this->session_id, $step ) ) { |
| 2804 |
continue; |
| 2805 |
} |
| 2806 |
|
| 2807 |
if ( $this->out_of_time() ) { |
| 2808 |
++$pending; |
| 2809 |
continue; |
| 2810 |
} |
| 2811 |
|
| 2812 |
if ( Wdkit_Import_Css::rebuild( $post_id, $progress['version'] ) ) { |
| 2813 |
++$progress['generated']; |
| 2814 |
} |
| 2815 |
|
| 2816 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, $step, true ); |
| 2817 |
} |
| 2818 |
|
| 2819 |
if ( $pending > 0 ) { |
| 2820 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, 'finalize_css_progress', $progress ); |
| 2821 |
|
| 2822 |
return array( |
| 2823 |
'available' => true, |
| 2824 |
'generated' => $progress['generated'], |
| 2825 |
'pending' => $pending, |
| 2826 |
); |
| 2827 |
} |
| 2828 |
|
| 2829 |
$result = array( |
| 2830 |
'available' => true, |
| 2831 |
'generated' => $progress['generated'], |
| 2832 |
); |
| 2833 |
|
| 2834 |
Wdkit_Import_Session::mark_step_complete( $this->session_id, 'finalize_css', $result ); |
| 2835 |
|
| 2836 |
if ( class_exists( 'Wdkit_Import_Log' ) ) { |
| 2837 |
Wdkit_Import_Log::add( 'css', $result ); |
| 2838 |
} |
| 2839 |
|
| 2840 |
return $result; |
| 2841 |
} |
| 2842 |
|
| 2843 |
/** |
| 2844 |
* Everything this run imported that a visitor can open, home first. |
| 2845 |
* |
| 2846 |
* Theme-builder templates (headers, footers) are not viewable on their own; they are |
| 2847 |
* built as part of the pages that render them. The post id travels with each URL so the |
| 2848 |
* wizard can warm a page again once its own deferred media has been rewritten. |
| 2849 |
* |
| 2850 |
* @param array $pages Page step results. |
| 2851 |
* @return array[] { post_id: int, url: string } |
| 2852 |
*/ |
| 2853 |
private function warm_pages( $pages ) { |
| 2854 |
$front = (int) get_option( 'page_on_front' ); |
| 2855 |
$list = array( |
| 2856 |
home_url( '/' ) => array( |
| 2857 |
'post_id' => $front, |
| 2858 |
'url' => home_url( '/' ), |
| 2859 |
), |
| 2860 |
); |
| 2861 |
|
| 2862 |
foreach ( array_merge( wp_list_pluck( $pages, 'post_id' ), $this->blog_post_ids() ) as $post_id ) { |
| 2863 |
$post_id = (int) $post_id; |
| 2864 |
|
| 2865 |
if ( $post_id <= 0 || $post_id === $front || 'publish' !== get_post_status( $post_id ) || ! is_post_type_viewable( get_post_type( $post_id ) ) || 'nxt_builder' === get_post_type( $post_id ) ) { |
| 2866 |
continue; |
| 2867 |
} |
| 2868 |
|
| 2869 |
$url = get_permalink( $post_id ); |
| 2870 |
|
| 2871 |
if ( is_string( $url ) && '' !== $url && ! isset( $list[ $url ] ) ) { |
| 2872 |
$list[ $url ] = array( |
| 2873 |
'post_id' => $post_id, |
| 2874 |
'url' => $url, |
| 2875 |
); |
| 2876 |
} |
| 2877 |
} |
| 2878 |
|
| 2879 |
return array_values( $list ); |
| 2880 |
} |
| 2881 |
|
| 2882 |
/** |
| 2883 |
* Posts this run created that no longer belong to the finished site. |
| 2884 |
* |
| 2885 |
* The wizard's equivalent is the retry pass: templates that failed are re-imported and |
| 2886 |
* the posts from the abandoned attempt are removed. Here that means any page step which |
| 2887 |
* recorded a post and then failed on a later attempt — i.e. a post id present in |
| 2888 |
* `results` whose step is also present in `failed`. |
| 2889 |
* |
| 2890 |
* Returns an empty list when there is nothing to reconcile, which is the normal case. |
| 2891 |
* |
| 2892 |
* @return int[] |
| 2893 |
*/ |
| 2894 |
private function orphaned_post_ids() { |
| 2895 |
$session = Wdkit_Import_Session::get( $this->session_id ); |
| 2896 |
|
| 2897 |
if ( null === $session || empty( $session['failed'] ) || empty( $session['results'] ) ) { |
| 2898 |
return array(); |
| 2899 |
} |
| 2900 |
|
| 2901 |
$orphans = array(); |
| 2902 |
|
| 2903 |
foreach ( array_keys( $session['failed'] ) as $step ) { |
| 2904 |
$step = (string) $step; |
| 2905 |
|
| 2906 |
if ( 0 !== strpos( $step, 'page_' ) && 0 !== strpos( $step, 'post_' ) ) { |
| 2907 |
continue; |
| 2908 |
} |
| 2909 |
|
| 2910 |
/* A step that later completed is no longer failed — mark_step_complete() |
| 2911 |
* removes it — so anything still here failed on its last attempt. */ |
| 2912 |
if ( empty( $session['results'][ $step ]['post_id'] ) ) { |
| 2913 |
continue; |
| 2914 |
} |
| 2915 |
|
| 2916 |
$orphans[] = (int) $session['results'][ $step ]['post_id']; |
| 2917 |
} |
| 2918 |
|
| 2919 |
return $orphans; |
| 2920 |
} |
| 2921 |
} |
| 2922 |
} |
| 2923 |
|