| 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 |
|