PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / import / class-wdkit-import-posts.php

class-wdkit-import-posts.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/import/class-wdkit-import-posts.php

1,076 lines 38.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Blog post import.
4 *
5 * ── The trace, because this one is counter-intuitive ────────────────────────
6 *
7 * Blog posts are NOT a separate content system. `import_dummy_content()` in
8 * import_loader.js fetches a fixed post kit — `post_kit_id = 19866` — through the ordinary
9 * `kit_template` action, and every post is created by the ordinary `import_page_section`
10 * action with `wp_post_type = post`. The post *body* therefore comes from the template and
11 * goes through exactly the same AI merge, image substitution and global-colour pass a page
12 * does. There is no post-specific content pipeline to reproduce.
13 *
14 * What `wkit_generate_post_data` actually returns is much smaller than the name suggests.
15 * Tracing the handler (ai/post/generate → `response`, a JSON *string*) and its only consumer:
16 *
17 * { posts: [ { id, title, category: [names], tags: [names] } ],
18 * categories: [ names ],
19 * tags: [ names ] }
20 *
21 * and `import_post_json()` uses precisely four things from it:
22 *
23 * 1. `posts[].id` — matched against `temp.id`, so the post TEMPLATE ID is the join key
24 * 2. `posts[].title` — overwrites the template's title
25 * 3. `posts[].category` / `posts[].tags` — names, mapped to term ids through the ids
26 * `import_taxonomy` returned, then passed as `category_list` / `tag_list`
27 * 4. nothing else
28 *
29 * Notably absent, and therefore absent here: post content, excerpt, author, status, slug,
30 * date, and any meta. `img_url` on each record is used only by the non-AI path; under
31 * `ai_import` the featured image is picked from the user's chosen images by aspect ratio
32 * (7/4) with the same 0.5 reuse penalty the body images use.
33 *
34 * ── What this class adds ───────────────────────────────────────────────────
35 *
36 * The browser's only duplicate protection is a count gate: skip the whole blog import if the
37 * site already has more than four published Gutenberg posts. That is reproduced as a gate,
38 * but it is not what makes this resumable. Each post gets
39 *
40 * session step post_<template_id>
41 * post meta _wdkit_import_source = <kit_id>:post:<template_id>
42 *
43 * so a retry resumes at the first post that did not land, and a *lost* session still refuses
44 * to create a second copy because the marker is in the database.
45 *
46 * @package Wdesignkit
47 * @since 2.6.5
48 */
49
50 if ( ! defined( 'ABSPATH' ) ) {
51 exit;
52 }
53
54 if ( ! class_exists( 'Wdkit_Import_Posts' ) ) {
55
56 /**
57 * Blog post import.
58 */
59 class Wdkit_Import_Posts {
60
61 /**
62 * The post kit the browser imports blog posts from. Hardcoded there
63 * (`import_post_json`: `var post_kit_id = 19866`), so hardcoded here — inventing a
64 * different source would not be reproducing the existing behaviour.
65 */
66 const POST_KIT_ID = 19866;
67
68 /**
69 * Marks a template that came from the fixed Gutenberg post kit rather than from the kit
70 * being imported, so import() knows to run it under `gutenberg`.
71 *
72 * @var string
73 */
74 const POST_KIT_FLAG = '_wdkit_from_post_kit';
75
76 /**
77 * Same meta key products use. The post type distinguishes them, and the marker itself
78 * carries `post:` so the two namespaces cannot collide on a numeric id.
79 */
80 const SOURCE_META = '_wdkit_import_source';
81
82 /**
83 * The browser's gate: more than this many existing published posts and the blog import
84 * is skipped entirely.
85 */
86 const EXISTING_POST_LIMIT = 4;
87
88 /**
89 * Featured-image aspect ratio, from `import_featured_img()`.
90 */
91 const FEATURED_RATIO_W = 7;
92 const FEATURED_RATIO_H = 4;
93
94 /**
95 * Import blog posts.
96 *
97 * @param array $templates Post template records {id, title, wp_post_type}.
98 * @param array $context Validated import context.
99 * @param string $session_id Session id.
100 * @param array $document Canonical AI document (may be empty).
101 * @return array{status:string,created:array,existing:array,failed:array,taxonomy?:array,reason?:string}
102 */
103 public static function import( $templates, $context, $session_id = '', $document = array() ) {
104 $result = array(
105 'status' => 'ok',
106 'created' => array(),
107 'existing' => array(),
108 'failed' => array(),
109 );
110
111 $templates = self::post_templates( $templates );
112
113 if ( empty( $templates ) ) {
114 $result['status'] = 'nothing_to_do';
115
116 return $result;
117 }
118
119 if ( empty( $context['blog_post'] ) ) {
120 $result['status'] = 'skipped';
121 $result['reason'] = 'blog_post_disabled';
122
123 return $result;
124 }
125
126 /* The browser's gate, reproduced. It is checked before anything is created but after
127 * the marker lookups below would have run anyway, so a resume of a run that started
128 * under the limit is not blocked by the posts that run itself created. */
129 if ( self::existing_post_count( $session_id ) > self::EXISTING_POST_LIMIT ) {
130 $result['status'] = 'skipped';
131 $result['reason'] = 'site_already_has_posts';
132
133 return $result;
134 }
135
136 /* Same rule the post context below applies: an AI import, unless "AI blog: No". */
137 $is_ai = 'ai_import' === ( $context['import_type'] ?? '' ) && ! ( isset( $context['ai_blog'] ) && ! $context['ai_blog'] )
138 && empty( $context['wirefram_import'] );
139
140 $document = is_array( $document ) ? $document : array();
141
142 /* No records: under AI import, generate business-specific post data. Under normal import, use defaults. */
143 if ( empty( $document['posts'] ) ) {
144 $document = $is_ai
145 ? self::generate_ai_document( $context, $session_id )
146 : self::default_document();
147 }
148
149 $records = self::index_records( $document );
150
151 /* Terms first, exactly as import_post_taxonomy() runs before import_post_json().
152 * Idempotent — Wdkit_Import_Taxonomy::ensure() resolves existing terms rather than
153 * duplicating them — so a resume simply re-reads the same ids. */
154 $taxonomy = Wdkit_Import_Taxonomy::ensure(
155 self::taxonomy_names( $document, $records, 'category' ),
156 self::taxonomy_names( $document, $records, 'tags' )
157 );
158
159 $result['taxonomy'] = $taxonomy;
160
161 /* ensure() already returns name => term_id maps, which is exactly the lookup the
162 * name-to-id mapping below needs. */
163 $category_ids = ( ! empty( $taxonomy['categories'] ) && is_array( $taxonomy['categories'] ) ) ? $taxonomy['categories'] : array();
164 $tag_ids = ( ! empty( $taxonomy['tags'] ) && is_array( $taxonomy['tags'] ) ) ? $taxonomy['tags'] : array();
165
166 $kit_id = isset( $context['kit_id'] ) ? $context['kit_id'] : '';
167
168 /* Every post's featured image, decided up front over the WHOLE list in its fixed
169 * order. Several lanes run this loop at once, each on a different subset of posts,
170 * and each used to age its own copy of the pool - so every lane gave its first post
171 * the same best-fit image, and a Taj Bakery import put one landscape photo on five of
172 * six posts. Planning over all of them from one pool reproduces the browser's
173 * one-after-another spread exactly, whichever lane ends up importing which post.
174 * Pure arithmetic on the pool; nothing is downloaded here. */
175 $featured_plan = array();
176 $plan_pool = self::image_pool( $context );
177
178 foreach ( $templates as $template ) {
179 $featured_plan[ self::source_id( $template ) ] = self::pick_featured_image( $context, $plan_pool );
180 }
181 /* Posts from the fixed post kit are Gutenberg whatever the kit being imported is —
182 * see the note in post_templates(). The importer reads its builder off the context,
183 * so the context it gets here is the one that matches the CONTENT, not the kit. */
184 $post_context = $context;
185
186 if ( ! empty( $templates[0][ self::POST_KIT_FLAG ] ) ) {
187 $post_context['builder'] = 'gutenberg';
188 }
189
190 /* "AI blog: No" - the posts still import, with the post kit's own copy. */
191 if ( isset( $context['ai_blog'] ) && ! $context['ai_blog'] ) {
192 $post_context['import_type'] = 'normal_import';
193 }
194
195 /* All posts' AI copy in one batched call instead of one call per post. Claimed so
196 * sibling lanes do not each place the same request; a lane that loses the claim
197 * finds the answers through the saved batch transients. */
198 if ( 'ai_import' === ( $post_context['import_type'] ?? '' ) && ! empty( $templates[0][ self::POST_KIT_FLAG ] )
199 && '' !== $session_id && Wdkit_Import_Session::claim( $session_id, 'ai_batch_posts' ) ) {
200 try {
201 $batch_context = $post_context;
202 $batch_context['kit_id'] = (string) self::POST_KIT_ID;
203
204 Wdkit_Page_Importer::prime_ai_batch_for_kit( $batch_context, 'gutenberg', $templates, $session_id );
205 } finally {
206 Wdkit_Import_Session::release( $session_id, 'ai_batch_posts' );
207 }
208 }
209
210 $importer = new Wdkit_Page_Importer( $post_context, $session_id );
211
212 /* Posts still outstanding for another lane. The runner adds this to $pending so the
213 * content stage stays open until every post has landed, not just those this lane
214 * picked up — the same guarantee the page loop gives. */
215 $incomplete = 0;
216
217 /* One query for the whole batch instead of one per post inside the loop below - see
218 * ClickUp 14ynqxywncd. Safe to compute once per lane: this is a plain read against
219 * markers this function itself derives from $templates, not something the claim
220 * below coordinates, so two lanes each building their own copy just means two queries
221 * instead of count($templates). */
222 $existing_by_marker = self::find_existing_by_markers(
223 array_map(
224 function ( $template ) use ( $kit_id ) {
225 return self::marker( $kit_id, self::source_id( $template ) );
226 },
227 $templates
228 )
229 );
230
231 foreach ( $templates as $template ) {
232 $source_id = self::source_id( $template );
233 $marker = self::marker( $kit_id, $source_id );
234 $step = 'post_' . $source_id;
235
236 if ( '' !== $session_id && Wdkit_Import_Session::is_step_complete( $session_id, $step ) ) {
237 $result['existing'][] = $source_id;
238 continue;
239 }
240
241 /* One lane per post, the way stage_content() claims each page. Several lanes
242 * reach this tail in the same slice once the pages are done, and two of them
243 * calling documents->create() for the same post is how a blog post duplicated
244 * under RUNNER_LANES > 1. */
245 if ( '' !== $session_id && ! Wdkit_Import_Session::claim( $session_id, $step ) ) {
246 ++$incomplete;
247 continue;
248 }
249
250 try {
251 /* The gap between the checks above and winning the claim is not zero, and a
252 * stale-claim takeover can hand us a post a now-gone lane already finished. */
253 if ( '' !== $session_id && Wdkit_Import_Session::is_step_complete( $session_id, $step ) ) {
254 $result['existing'][] = $source_id;
255 continue;
256 }
257
258 $existing_id = isset( $existing_by_marker[ $marker ] ) ? (int) $existing_by_marker[ $marker ] : 0;
259
260 if ( $existing_id > 0 ) {
261 $result['existing'][] = $source_id;
262
263 if ( '' !== $session_id ) {
264 Wdkit_Import_Session::mark_step_complete(
265 $session_id,
266 $step,
267 array(
268 'source_id' => $source_id,
269 'post_id' => $existing_id,
270 'reused' => true,
271 )
272 );
273 }
274
275 continue;
276 }
277
278 $record = isset( $records[ $source_id ] ) ? $records[ $source_id ] : array();
279 $wireframe = ! empty( $context['wirefram_import'] );
280 $overrides = self::overrides(
281 $record,
282 $category_ids,
283 $tag_ids,
284 isset( $featured_plan[ $source_id ] ) ? $featured_plan[ $source_id ] : '',
285 $wireframe
286 );
287
288 try {
289 $page = $importer->import( $template, $overrides );
290
291 update_post_meta( $page['post_id'], self::SOURCE_META, $marker );
292
293 $page['source_id'] = $source_id;
294
295 $result['created'][] = $page;
296
297 if ( '' !== $session_id ) {
298 Wdkit_Import_Session::mark_step_complete( $session_id, $step, $page );
299 }
300 } catch ( Wdkit_Import_Exception $e ) {
301 $failure = Wdkit_Import_Errors::to_record( $e, $step );
302
303 if ( $e->is_skippable() ) {
304 /* Same case the page loop in class-wdkit-import-runner.php skips
305 * silently - a mismatched catalogue record for this one post, nothing
306 * wrong with the rest of the kit. This loop has its own step key
307 * ('post_' rather than 'page_') and its own try/catch, so the runner's
308 * fix does not reach it; it needs the same one here. */
309 if ( '' !== $session_id ) {
310 Wdkit_Import_Session::mark_step_complete( $session_id, $step, $failure );
311 }
312
313 continue;
314 }
315
316 $result['failed'][] = $failure;
317
318 if ( '' !== $session_id ) {
319 Wdkit_Import_Session::mark_step_failed( $session_id, $step, $failure );
320 }
321 } catch ( Throwable $e ) {
322 $failure = Wdkit_Import_Errors::to_record( $e, $step );
323
324 $result['failed'][] = $failure;
325
326 if ( '' !== $session_id ) {
327 Wdkit_Import_Session::mark_step_failed( $session_id, $step, $failure );
328 }
329 }
330 } finally {
331 if ( '' !== $session_id ) {
332 Wdkit_Import_Session::release( $session_id, $step );
333 }
334 }
335 }
336
337 $result['incomplete'] = $incomplete;
338
339 return $result;
340 }
341
342 /**
343 * The post details a normal (non-AI) import uses.
344 *
345 * `import_dummy_content()` has two branches. Under `ai_import` it calls
346 * `wkit_generate_post_data` and uses the answer. Under a normal import it uses a HARDCODED
347 * dataset - titles, categories, tags and a featured image per post - keyed by the post
348 * kit's own template ids.
349 *
350 * The runner only ever received the AI document, which on a normal import is empty. So
351 * every post was created with the template's own title and nothing else: "g blog post 1",
352 * no category, no tag, no featured image, where the browser produced "The Inspiring Story
353 * Behind Our Brand" with an image. Same content, same ids, ported rather than invented.
354 *
355 * @since 2.6.5
356 *
357 * @return array{posts:array,categories:array,tags:array}
358 */
359 /**
360 * Generate AI post records (titles, categories, tags) tailored to the visitor's business.
361 *
362 * Attempts the cloud API `ai/post/generate` first, caching in a session transient.
363 * Falls back to business-tailored records from site_name/site_type so posts NEVER
364 * get generic dummy handyman titles under AI import.
365 *
366 * @since 2.7.3
367 *
368 * @param array $context Import context.
369 * @param string $session_id Session id.
370 * @return array{posts:array,categories:array,tags:array}
371 */
372 public static function generate_ai_document( $context, $session_id = '' ) {
373 if ( '' !== $session_id ) {
374 $cached = get_transient( 'wdkit_ai_posts_' . $session_id );
375 if ( is_array( $cached ) && ! empty( $cached['posts'] ) ) {
376 return $cached;
377 }
378 }
379
380 $site_info = ( ! empty( $context['site_info'] ) && is_array( $context['site_info'] ) ) ? $context['site_info'] : array();
381 $site_name = ! empty( $site_info['site_name'] ) ? (string) $site_info['site_name'] : ( isset( $context['site_name'] ) ? (string) $context['site_name'] : '' );
382 $site_type = ! empty( $context['site_type'] ) ? (string) $context['site_type'] : '';
383 $site_desc = ! empty( $context['site_description'] ) ? (string) $context['site_description'] : '';
384
385 $doc = null;
386
387 // Try the cloud endpoint if we have business context
388 if ( '' !== $site_type || '' !== $site_name ) {
389 $token = function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '';
390
391 $base_url = defined( 'WDKIT_SERVER_API_URL' ) ? WDKIT_SERVER_API_URL : 'https://api.wdesignkit.com/';
392 $endpoint = trailingslashit( $base_url ) . 'api/wp/ai/post/generate';
393
394 $body = array(
395 'site_type' => $site_type ? $site_type : $site_name,
396 'site_title' => $site_name ? $site_name : $site_type,
397 'site_desc' => $site_desc,
398 'builder' => 'gutenberg',
399 'token' => $token,
400 );
401
402 /* A sandbox has no account token; the cloud accepts this site's poll token instead. */
403 if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) {
404 $body = wdkit_kit_import_with_site_identity( $body );
405 }
406
407 $response = wp_remote_post(
408 $endpoint,
409 array(
410 'timeout' => 30,
411 'body' => $body,
412 )
413 );
414
415 if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
416 $raw = wp_remote_retrieve_body( $response );
417 $json = json_decode( $raw, true );
418 if ( is_array( $json ) && ! empty( $json['success'] ) && ! empty( $json['response'] ) ) {
419 $res_text = is_string( $json['response'] ) ? $json['response'] : wp_json_encode( $json['response'] );
420 $res_text = preg_replace( '/^```(?:json)?\s*/i', '', trim( $res_text ) );
421 $res_text = preg_replace( '/\s*```$/', '', $res_text );
422 $parsed = json_decode( $res_text, true );
423
424 if ( is_array( $parsed ) && ! empty( $parsed['posts'] ) && is_array( $parsed['posts'] ) ) {
425 $doc = array(
426 'posts' => array(),
427 'categories' => ! empty( $parsed['categories'] ) && is_array( $parsed['categories'] ) ? $parsed['categories'] : array(),
428 'tags' => ! empty( $parsed['tags'] ) && is_array( $parsed['tags'] ) ? $parsed['tags'] : array(),
429 );
430
431 $known_ids = array( 19873, 19874, 19875, 19876, 19877, 19878 );
432
433 foreach ( array_values( $parsed['posts'] ) as $idx => $p ) {
434 if ( is_array( $p ) ) {
435 if ( empty( $p['id'] ) && isset( $known_ids[ $idx ] ) ) {
436 $p['id'] = $known_ids[ $idx ];
437 }
438 $normalized = class_exists( 'Wdkit_Ai_Content' ) ? Wdkit_Ai_Content::normalize_post( $p ) : $p;
439 if ( ! empty( $normalized ) ) {
440 $doc['posts'][] = $normalized;
441 }
442 }
443 }
444 }
445 }
446 }
447 }
448
449 // If cloud call succeeded with posts, cache and return
450 if ( is_array( $doc ) && ! empty( $doc['posts'] ) ) {
451 if ( '' !== $session_id ) {
452 set_transient( 'wdkit_ai_posts_' . $session_id, $doc, HOUR_IN_SECONDS );
453 }
454 return $doc;
455 }
456
457 // Otherwise, generate intelligent business-specific fallback records
458 $fallback = self::fallback_ai_document( $site_name, $site_type );
459 if ( '' !== $session_id ) {
460 set_transient( 'wdkit_ai_posts_' . $session_id, $fallback, HOUR_IN_SECONDS );
461 }
462
463 return $fallback;
464 }
465
466 /**
467 * Intelligent business-tailored fallback blog posts when the cloud generator is unreachable.
468 *
469 * @since 2.7.3
470 *
471 * @param string $site_name Business name.
472 * @param string $site_type Business category.
473 * @return array{posts:array,categories:array,tags:array}
474 */
475 private static function fallback_ai_document( $site_name, $site_type ) {
476 $name = ! empty( $site_name ) ? $site_name : ( ! empty( $site_type ) ? $site_type : 'Our Brand' );
477 $type = ! empty( $site_type ) ? $site_type : $name;
478
479 return array(
480 'posts' => array(
481 array(
482 'id' => 19873,
483 /* translators: %s: business name. */
484 'title' => sprintf( __( 'The Inspiring Story Behind %s', 'wdesignkit' ), $name ),
485 /* translators: %s: business name. */
486 'category' => array( sprintf( __( 'About %s', 'wdesignkit' ), $name ) ),
487 'tags' => array( strtolower( $type ), 'story', 'mission', 'about us' ),
488 ),
489 array(
490 'id' => 19874,
491 /* translators: %s: business type, e.g. Bakery. */
492 'title' => sprintf( __( 'Common Challenges in %s & How We Solve Them', 'wdesignkit' ), $type ),
493 /* translators: %s: business type, e.g. Bakery. */
494 'category' => array( sprintf( __( '%s Insights', 'wdesignkit' ), $type ) ),
495 'tags' => array( strtolower( $type ), 'solutions', 'guide', 'industry' ),
496 ),
497 array(
498 'id' => 19875,
499 /* translators: %s: business name. */
500 'title' => sprintf( __( 'Our Quality Process: What Makes %s Stand Out', 'wdesignkit' ), $name ),
501 'category' => array( __( 'Process & Quality', 'wdesignkit' ) ),
502 'tags' => array( strtolower( $type ), 'craftsmanship', 'quality', 'experience' ),
503 ),
504 array(
505 'id' => 19876,
506 /* translators: %s: business name. */
507 'title' => sprintf( __( 'Celebrating Our Community: Customer Stories at %s', 'wdesignkit' ), $name ),
508 'category' => array( __( 'Customer Stories', 'wdesignkit' ) ),
509 'tags' => array( strtolower( $type ), 'community', 'reviews', 'testimonials' ),
510 ),
511 array(
512 'id' => 19877,
513 /* translators: %s: business type, e.g. Bakery. */
514 'title' => sprintf( __( 'Choosing the Best %s Solutions for Your Needs', 'wdesignkit' ), $type ),
515 'category' => array( __( 'Tips & Advice', 'wdesignkit' ) ),
516 'tags' => array( strtolower( $type ), 'tips', 'guide', 'recommendations' ),
517 ),
518 array(
519 'id' => 19878,
520 /* translators: %s: business name. */
521 'title' => sprintf( __( 'Meet the Dedicated Team Behind %s', 'wdesignkit' ), $name ),
522 'category' => array( __( 'Team & Culture', 'wdesignkit' ) ),
523 'tags' => array( strtolower( $type ), 'team', 'culture', 'behind the scenes' ),
524 ),
525 ),
526 'categories' => array(
527 sprintf( __( 'About %s', 'wdesignkit' ), $name ),
528 sprintf( __( '%s Insights', 'wdesignkit' ), $type ),
529 __( 'Process & Quality', 'wdesignkit' ),
530 __( 'Customer Stories', 'wdesignkit' ),
531 __( 'Tips & Advice', 'wdesignkit' ),
532 __( 'Team & Culture', 'wdesignkit' ),
533 ),
534 'tags' => array(
535 strtolower( $type ),
536 'story',
537 'mission',
538 'solutions',
539 'guide',
540 'craftsmanship',
541 'quality',
542 'community',
543 'tips',
544 'team',
545 ),
546 );
547 }
548
549 public static function default_document() {
550 $base = defined( 'WDKIT_SERVER_API_URL' ) ? WDKIT_SERVER_API_URL : '';
551 $img = function ( $n ) use ( $base ) {
552 return $base . 'images/plugins/import-template/listing/post-' . $n . '.png';
553 };
554
555 return array(
556 'posts' => array(
557 array(
558 'id' => 19873,
559 'title' => 'The Inspiring Story Behind Our Brand',
560 'img_url' => $img( 1 ),
561 'category' => array( 'Company Story' ),
562 'tags' => array( 'brand story', 'mission', 'handcrafted', 'unique' ),
563 ),
564 array(
565 'id' => 19874,
566 'title' => 'Overcoming Common Challenges in the Industry',
567 'img_url' => $img( 2 ),
568 'category' => array( 'Industry Problems & Solutions' ),
569 'tags' => array( 'industry challenges', 'solutions', 'customer service', 'trends' ),
570 ),
571 array(
572 'id' => 19875,
573 'title' => 'How Our Process or Experience Ensures Success',
574 'img_url' => $img( 3 ),
575 'category' => array( 'Process or Experience' ),
576 'tags' => array( 'customer experience', 'craftsmanship', 'process' ),
577 ),
578 array(
579 'id' => 19876,
580 'title' => 'Celebrating Our Customers: Success Stories With Our Brand',
581 'img_url' => $img( 4 ),
582 'category' => array( 'Success Stories & Achievements' ),
583 'tags' => array( 'customer success', 'testimonials', 'milestones', 'achievements' ),
584 ),
585 array(
586 'id' => 19877,
587 'title' => 'Choosing the Perfect Solution for every Problem',
588 'img_url' => $img( 5 ),
589 'category' => array( 'Choosing the Right Solution' ),
590 'tags' => array( 'event planning', 'style guide' ),
591 ),
592 array(
593 'id' => 19878,
594 'title' => 'Meet Our Team: The Heart of Our Brand',
595 'img_url' => $img( 6 ),
596 'category' => array( 'Culture & Team' ),
597 'tags' => array( 'team spirit', 'company culture', 'values', 'behind the scenes' ),
598 ),
599 ),
600 'categories' => array(
601 'Company Story',
602 'Industry Problems & Solutions',
603 'Process or Experience',
604 'Success Stories & Achievements',
605 'Choosing the Right Solution',
606 'Culture & Team',
607 'Customer Experience',
608 'Craftsmanship',
609 ),
610 'tags' => array(
611 'brand story', 'mission', 'handcrafted', 'unique', 'industry challenges', 'solutions',
612 'customer service', 'trends', 'customer experience', 'craftsmanship', 'process',
613 'customer success', 'testimonials', 'milestones', 'achievements', 'event planning',
614 'style guide', 'team spirit', 'company culture', 'values', 'behind the scenes',
615 ),
616 );
617 }
618
619 /**
620 * Keep only the templates that create posts.
621 *
622 * `wp_post_type` is the same field the page importer already reads, so a kit that
623 * happens to ship post templates alongside pages is handled without a second list.
624 *
625 * @param mixed $templates Template records.
626 * @return array[]
627 */
628 public static function post_templates( $templates ) {
629 $posts = array();
630
631 foreach ( (array) $templates as $template ) {
632 if ( is_array( $template ) && isset( $template['wp_post_type'] ) && 'post' === $template['wp_post_type'] ) {
633 $posts[] = $template;
634 }
635 }
636
637 /* A website kit does not contain blog posts. The browser fetches a SEPARATE, fixed
638 * post kit for them - `var post_kit_id = 19866` in import_post_json() - and imports
639 * each of its templates as a post.
640 *
641 * Filtering the current kit's own template list, which is all this used to do,
642 * therefore found nothing: the Zion kit is nine pages and four theme-builder
643 * templates, zero of type `post`. So the blog import reported "nothing to do" and
644 * silently produced no posts at all, on every kit. POST_KIT_ID was declared here and
645 * never used - the constant was right, nothing called it. */
646 if ( empty( $posts ) ) {
647 $posts = self::fetch_post_kit_templates();
648
649 /* Tag them, because their BUILDER is not the kit's.
650 *
651 * The post kit is a fixed Gutenberg kit — fetch_post_kit_templates() asks for it
652 * as `builder => gutenberg` — but the page importer takes its builder from the
653 * run's context, which on an Elementor kit is `elementor`. Every post was then
654 * refused by the content/builder guard with `builder_content_mismatch` and the
655 * blog came out empty, on every Elementor AI import. The browser never had this
656 * problem because import_post_json() posts `builder: 'gutenberg'` for the post
657 * kit regardless of the kit being imported.
658 *
659 * The stub carries no `file_type` to detect this from — that only arrives with
660 * the content — so the fact has to be carried from the one place that knows it. */
661 foreach ( $posts as $i => $post ) {
662 $posts[ $i ][ self::POST_KIT_FLAG ] = true;
663 }
664 }
665
666 return $posts;
667 }
668
669 /**
670 * The fixed post kit's template list, from the cloud.
671 *
672 * Same request the browser makes - `kit_template` with the post kit's id - through
673 * WDesignKit_Data_Query directly, so no AJAX round trip is needed.
674 *
675 * @since 2.6.5
676 *
677 * @return array[] Template records shaped for the importer.
678 */
679 private static function fetch_post_kit_templates() {
680 if ( ! class_exists( 'WDesignKit_Data_Query' ) ) {
681 return array();
682 }
683
684 $response = WDesignKit_Data_Query::get_data(
685 'kit_template',
686 array(
687 'template_id' => self::POST_KIT_ID,
688 'builder' => 'gutenberg',
689 'editor' => 'gutenberg',
690 )
691 );
692
693 /* A cloud call that times out hands back a WP_Error, and in PHP 8 even empty()
694 * on an object that is not ArrayAccess is a fatal "Cannot use object of type
695 * WP_Error as array". Thrown from here it escaped the per-template try/catch and
696 * failed the whole content stage - the kit's own pages were already in, so the
697 * run showed "Importing Site Content" red purely because the optional blog-post
698 * kit could not be reached. Checked before the subscripts, and a miss simply
699 * means no blog posts. */
700 if ( ! is_array( $response ) ) {
701 return array();
702 }
703
704 if ( empty( $response['success'] ) || empty( $response['template'] ) || ! is_array( $response['template'] ) ) {
705 return array();
706 }
707
708 $out = array();
709
710 foreach ( $response['template'] as $template ) {
711 if ( ! is_array( $template ) || empty( $template['id'] ) ) {
712 continue;
713 }
714
715 /* Only what the importer needs, rebuilt rather than passed through, so a change
716 * in the cloud's response shape cannot introduce fields nothing validated. */
717 $out[] = array(
718 'id' => (string) $template['id'],
719 'title' => isset( $template['title'] ) ? (string) $template['title'] : '',
720 'type' => 'page',
721 'wp_post_type' => 'post',
722 );
723 }
724
725 return $out;
726 }
727
728 /**
729 * The overrides the page importer applies on top of the template.
730 *
731 * Only ever the four things the browser sets. A record cannot introduce a fifth,
732 * because the array is built here rather than passed through.
733 *
734 * @param array $record Normalised AI post record.
735 * @param array $category_ids name => term_id.
736 * @param array $tag_ids name => term_id.
737 * @param string $featured This post's planned pick from the visitor's images, or ''
738 * (see the featured-image plan in import()).
739 * @return array
740 */
741 private static function overrides( $record, $category_ids, $tag_ids, $featured, $wireframe = false ) {
742 $overrides = array();
743
744 if ( ! empty( $record['title'] ) ) {
745 $overrides['title'] = $record['title'];
746 }
747
748 $categories = self::map_terms( isset( $record['category'] ) ? $record['category'] : array(), $category_ids );
749 $tags = self::map_terms( isset( $record['tags'] ) ? $record['tags'] : array(), $tag_ids );
750
751 if ( ! empty( $categories ) ) {
752 $overrides['categories'] = $categories;
753 }
754
755 if ( ! empty( $tags ) ) {
756 $overrides['tags'] = $tags;
757 }
758
759 $featured = is_string( $featured ) ? $featured : '';
760
761 /* Wireframe import: use placeholder image for featured image instead of color dummy images */
762 if ( $wireframe ) {
763 $base = defined( 'WDKIT_SERVER_API_URL' ) ? WDKIT_SERVER_API_URL : 'https://api.wdesignkit.com/';
764 $featured = $base . 'images/v2/plugin/template/hero-placeholder.png';
765 } elseif ( '' === $featured && ! empty( $record['img_url'] ) && is_string( $record['img_url'] ) ) {
766 $featured = $record['img_url'];
767 } elseif ( '' === $featured ) {
768 $featured = self::default_image_for( isset( $record['source_id'] ) ? $record['source_id'] : ( isset( $record['id'] ) ? $record['id'] : '' ) );
769 }
770
771 if ( '' !== $featured ) {
772 $overrides['featured_image'] = $featured;
773 }
774
775 return $overrides;
776 }
777
778 /**
779 * The post kit's own artwork for one post, by its template id.
780 *
781 * Reads default_document(), so there is one list of these and it cannot drift from the
782 * non-AI path. Memoized because overrides() runs once per post.
783 *
784 * @since 2.7.2
785 *
786 * @param string|int $source_id Post template id.
787 * @return string Image URL, or empty string when the id is not one of the kit's.
788 */
789 private static function default_image_for( $source_id ) {
790 static $map = null;
791
792 $source_id = (string) $source_id;
793
794 if ( '' === $source_id ) {
795 return '';
796 }
797
798 if ( null === $map ) {
799 $map = array();
800 $document = self::default_document();
801
802 foreach ( ( isset( $document['posts'] ) ? (array) $document['posts'] : array() ) as $post ) {
803 if ( is_array( $post ) && ! empty( $post['id'] ) && ! empty( $post['img_url'] ) ) {
804 $map[ (string) $post['id'] ] = (string) $post['img_url'];
805 }
806 }
807 }
808
809 return isset( $map[ $source_id ] ) ? $map[ $source_id ] : '';
810 }
811
812 /**
813 * Choose a featured image and age the pool.
814 *
815 * Port of `import_featured_img()`: closest aspect ratio to 7/4, plus 0.5 per previous
816 * use so a small pool spreads across posts instead of repeating the best fit.
817 *
818 * @param array $context Validated context.
819 * @param array $images Image pool (by reference).
820 * @return string URL, or '' when there is nothing to pick.
821 */
822 private static function pick_featured_image( $context, &$images ) {
823 /* The non-AI path uses the record's own `img_url`; that is kit content and is
824 * already baked into the template, so there is nothing to override. Only the AI
825 * path substitutes a user-chosen image. */
826 if ( empty( $context['import_type'] ) || 'ai_import' !== $context['import_type'] ) {
827 return '';
828 }
829
830 if ( empty( $images ) ) {
831 return '';
832 }
833
834 $best = Wdkit_Import_Media::closest_by_aspect(
835 array(
836 'width' => self::FEATURED_RATIO_W,
837 'height' => self::FEATURED_RATIO_H,
838 ),
839 $images
840 );
841
842 if ( null === $best ) {
843 return '';
844 }
845
846 $index = $best['index'];
847 $used = isset( $images[ $index ]['used'] ) ? (float) $images[ $index ]['used'] : 0.0;
848
849 $images[ $index ]['used'] = $used + 0.5;
850
851 return isset( $best['candidate']['url'] ) ? (string) $best['candidate']['url'] : '';
852 }
853
854 /**
855 * Candidate featured images from the context.
856 *
857 * @param array $context Validated context.
858 * @return array[]
859 */
860 private static function image_pool( $context ) {
861 if ( empty( $context['images'] ) || ! is_array( $context['images'] ) ) {
862 return array();
863 }
864
865 $pool = array();
866
867 foreach ( $context['images'] as $image ) {
868 if ( ! is_array( $image ) || empty( $image['url'] ) ) {
869 continue;
870 }
871
872 $pool[] = array(
873 'url' => $image['url'],
874 'width' => isset( $image['width'] ) ? (float) $image['width'] : 0.0,
875 'height' => isset( $image['height'] ) ? (float) $image['height'] : 0.0,
876 'used' => isset( $image['used'] ) ? (float) $image['used'] : 0.0,
877 );
878 }
879
880 return $pool;
881 }
882
883 /**
884 * AI post records keyed by source id.
885 *
886 * @param array $document Canonical AI document.
887 * @return array<string,array>
888 */
889 private static function index_records( $document ) {
890 $index = array();
891
892 if ( empty( $document['posts'] ) || ! is_array( $document['posts'] ) ) {
893 return $index;
894 }
895
896 foreach ( $document['posts'] as $record ) {
897 if ( ! is_array( $record ) ) {
898 continue;
899 }
900
901 /* `source_id` is what Wdkit_Ai_Content::normalize_post() produces; `id` is what
902 * the browser's own records carry and what import_post_json() joins on
903 * (`posts[].id` against `temp.id`). Accepting both is why the non-AI records now
904 * match - keyed only on source_id, every default record was silently ignored and
905 * each post kept its template title with no category, tag or thumbnail. */
906 $key = '';
907
908 if ( ! empty( $record['source_id'] ) ) {
909 $key = (string) $record['source_id'];
910 } elseif ( ! empty( $record['id'] ) ) {
911 $key = (string) $record['id'];
912 }
913
914 if ( '' !== $key ) {
915 $index[ $key ] = $record;
916 }
917 }
918
919 return $index;
920 }
921
922 /**
923 * Every term name that needs to exist, from the document and from the records.
924 *
925 * Both sources are read because `taxonomy.categories` is the generator's own list and
926 * an individual record can still name a term the list forgot.
927 *
928 * @param array $document Canonical AI document.
929 * @param array $records Indexed records.
930 * @param string $field 'category'|'tags'.
931 * @return string[]
932 */
933 private static function taxonomy_names( $document, $records, $field ) {
934 $key = ( 'tags' === $field ) ? 'tags' : 'categories';
935 $names = array();
936
937 if ( ! empty( $document['taxonomy'][ $key ] ) && is_array( $document['taxonomy'][ $key ] ) ) {
938 $names = $document['taxonomy'][ $key ];
939 }
940
941 foreach ( $records as $record ) {
942 if ( ! empty( $record[ $field ] ) && is_array( $record[ $field ] ) ) {
943 $names = array_merge( $names, $record[ $field ] );
944 }
945 }
946
947 return array_values( array_unique( array_filter( $names, 'is_string' ) ) );
948 }
949
950 /**
951 * Term names to term ids, dropping names that have no term.
952 *
953 * A name with no term is silently skipped, which is what the browser does — its
954 * `findIndex` returns -1 and nothing is pushed. Creating a term here instead would
955 * mean an AI record could add taxonomy the generator never declared.
956 *
957 * @param mixed $names Term names.
958 * @param array $index name => term_id.
959 * @return int[]
960 */
961 private static function map_terms( $names, $index ) {
962 if ( ! is_array( $names ) ) {
963 return array();
964 }
965
966 $ids = array();
967
968 foreach ( $names as $name ) {
969 if ( is_string( $name ) && isset( $index[ $name ] ) ) {
970 $ids[] = $index[ $name ];
971 }
972 }
973
974 return array_values( array_unique( $ids ) );
975 }
976
977 /**
978 * Published posts already on the site, excluding the ones this run created.
979 *
980 * The exclusion is the difference between a gate and a trap: without it, a run
981 * interrupted after five posts would refuse to resume.
982 *
983 * @param string $session_id Session id.
984 * @return int
985 */
986 private static function existing_post_count( $session_id ) {
987 $found = get_posts(
988 array(
989 'post_type' => 'post',
990 'post_status' => 'publish',
991 'posts_per_page' => 50,
992 'fields' => 'ids',
993 'no_found_rows' => true,
994 )
995 );
996
997 $found = is_array( $found ) ? array_map( 'intval', $found ) : array();
998
999 if ( '' === $session_id || empty( $found ) ) {
1000 return count( $found );
1001 }
1002
1003 $ours = Wdkit_Import_Cleanup::session_created_ids( $session_id );
1004
1005 return count( array_diff( $found, array_keys( $ours ) ) );
1006 }
1007
1008 /**
1009 * The stable key for one post template.
1010 *
1011 * @param array $template Template record.
1012 * @return string
1013 */
1014 private static function source_id( $template ) {
1015 $id = isset( $template['id'] ) ? (string) $template['id'] : '';
1016
1017 return preg_replace( '/[^A-Za-z0-9_\-]/', '', $id );
1018 }
1019
1020 /**
1021 * The marker written to post meta.
1022 *
1023 * @param string $kit_id Kit id.
1024 * @param string $source_id Post source id.
1025 * @return string
1026 */
1027 private static function marker( $kit_id, $source_id ) {
1028 return preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $kit_id ) . ':post:' . $source_id;
1029 }
1030
1031 /**
1032 * Resolve every already-imported post for a batch of markers in one query.
1033 *
1034 * Replaces a get_posts() call per post inside the import loop with a single lookup for
1035 * the whole batch - a fresh (non-resumed) import of N posts used to run N of these.
1036 * See ClickUp 14ynqxywncd.
1037 *
1038 * @param string[] $markers Marker values for the whole batch.
1039 * @return array<string,int> marker => post_id, absent when no match.
1040 */
1041 private static function find_existing_by_markers( $markers ) {
1042 $markers = array_values( array_unique( array_filter( (array) $markers, 'strlen' ) ) );
1043
1044 if ( empty( $markers ) ) {
1045 return array();
1046 }
1047
1048 global $wpdb;
1049
1050 $placeholders = implode( ', ', array_fill( 0, count( $markers ), '%s' ) );
1051
1052 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names only; every value is a %s placeholder below.
1053 $rows = $wpdb->get_results(
1054 $wpdb->prepare(
1055 "SELECT p.ID as post_id, pm.meta_value as marker
1056 FROM {$wpdb->postmeta} pm
1057 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1058 WHERE pm.meta_key = %s AND pm.meta_value IN ($placeholders)
1059 AND p.post_type = 'post' AND p.post_status != 'trash'",
1060 array_merge( array( self::SOURCE_META ), $markers )
1061 )
1062 );
1063
1064 $found = array();
1065
1066 foreach ( (array) $rows as $row ) {
1067 if ( isset( $row->marker, $row->post_id ) ) {
1068 $found[ $row->marker ] = (int) $row->post_id;
1069 }
1070 }
1071
1072 return $found;
1073 }
1074 }
1075 }
1076