| 1 |
<?php |
| 2 |
/** |
| 3 |
* WooCommerce product import. |
| 4 |
* |
| 5 |
* ── How the browser does it ───────────────────────────────────────────────── |
| 6 |
* |
| 7 |
* `wkit_check_product_count` asks how many products already exist; if the kit needs more, |
| 8 |
* `wkit_generate_product_data` fetches generated product data from the cloud, and then |
| 9 |
* `wkit_cteate_product` is called once per product. That last handler is now reachable as |
| 10 |
* Wdkit_Import_temp_Ajax::wdkit_create_product_data(), so every WooCommerce call — the simple |
| 11 |
* / variable branches, the hardcoded Red/Green/Blue variation set, the price offsets, the |
| 12 |
* `product_cat` term creation, the image sideload — is reused rather than reimplemented. |
| 13 |
* |
| 14 |
* ── What this class adds ─────────────────────────────────────────────────── |
| 15 |
* |
| 16 |
* Idempotency, which the browser does not have. `wkit_cteate_product` has no notion of a |
| 17 |
* product source id, so calling it twice creates two products. That browser behaviour is |
| 18 |
* untouched. Here, every product created through the runner is stamped with |
| 19 |
* |
| 20 |
* _wdkit_import_source = <kit_id>:<source_id> |
| 21 |
* |
| 22 |
* and looked up by that marker first, so a retry after a mid-run failure resumes at the next |
| 23 |
* unfinished product instead of duplicating the ones that already landed. |
| 24 |
* |
| 25 |
* Per-product session steps (`product_<source_id>`) give the same guarantee at the runner |
| 26 |
* level, so both layers have to agree before anything is recreated. |
| 27 |
* |
| 28 |
* @package Wdesignkit |
| 29 |
* @since 2.6.5 |
| 30 |
*/ |
| 31 |
|
| 32 |
if ( ! defined( 'ABSPATH' ) ) { |
| 33 |
exit; |
| 34 |
} |
| 35 |
|
| 36 |
if ( ! class_exists( 'Wdkit_Import_Products' ) ) { |
| 37 |
|
| 38 |
/** |
| 39 |
* Product import. |
| 40 |
*/ |
| 41 |
class Wdkit_Import_Products { |
| 42 |
|
| 43 |
/** |
| 44 |
* Post meta key recording which import created a product. |
| 45 |
*/ |
| 46 |
const SOURCE_META = '_wdkit_import_source'; |
| 47 |
|
| 48 |
/** |
| 49 |
* Marks the one media-library copy of the bundled WooCommerce placeholder, so repeated |
| 50 |
* and resumed imports reuse it instead of adding another. |
| 51 |
* |
| 52 |
* @var string |
| 53 |
*/ |
| 54 |
const PLACEHOLDER_META = '_wdkit_wc_placeholder'; |
| 55 |
|
| 56 |
/** |
| 57 |
* Product types the extracted handler understands. Anything else is coerced to the |
| 58 |
* simple branch by that handler, so the list is here to make the set explicit rather |
| 59 |
* than to add behaviour. |
| 60 |
* |
| 61 |
* @var string[] |
| 62 |
*/ |
| 63 |
private static $types = array( 'simple', 'variation', 'discount', 'out_of_stock' ); |
| 64 |
|
| 65 |
/** |
| 66 |
* The type each product gets when the record does not name one. |
| 67 |
* |
| 68 |
* Straight out of `import_dummy_products()`: |
| 69 |
* ['simple', 'discount', 'variation', 'out_of_stock', 'simple', 'discount'] |
| 70 |
* assigned by array position, so a six-product kit gets one of each interesting type. |
| 71 |
* The generator never returns a type — it returns title, description, price and |
| 72 |
* category only — so this position-based assignment IS the browser's behaviour, not a |
| 73 |
* fallback for it. Positions past the sixth wrap, which the browser does not do (it |
| 74 |
* yields `undefined`, and the handler coerces that to the simple branch); wrapping keeps |
| 75 |
* the same set for a longer list instead of making every extra product simple. |
| 76 |
* |
| 77 |
* @var string[] |
| 78 |
*/ |
| 79 |
private static $type_cycle = array( 'simple', 'discount', 'variation', 'out_of_stock', 'simple', 'discount' ); |
| 80 |
|
| 81 |
/** |
| 82 |
* The browser's gate: this many existing published products and the product import is |
| 83 |
* skipped entirely (`product_check.count < 5`). |
| 84 |
*/ |
| 85 |
const EXISTING_PRODUCT_LIMIT = 5; |
| 86 |
|
| 87 |
/** |
| 88 |
* Is WooCommerce usable in this request? |
| 89 |
* |
| 90 |
* @return bool |
| 91 |
*/ |
| 92 |
public static function is_available() { |
| 93 |
return class_exists( 'WC_Product_Simple' ); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* The image every browser-created product gets. |
| 98 |
* |
| 99 |
* `insert_products()` always sends the bundled WooCommerce placeholder and never a |
| 100 |
* generated or user-chosen image — the generator returns no image field at all. So this |
| 101 |
* is the default here too, and a record can only override it with a URL that passes the |
| 102 |
* same SSRF guard as any other imported image. |
| 103 |
* |
| 104 |
* @return string |
| 105 |
*/ |
| 106 |
public static function placeholder_image() { |
| 107 |
if ( ! defined( 'WDKIT_URL' ) ) { |
| 108 |
return ''; |
| 109 |
} |
| 110 |
|
| 111 |
return WDKIT_URL . 'assets/images/jpg/woocommerce-placeholder.webp'; |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* The bundled WooCommerce placeholder, in the media library, created at most once. |
| 116 |
* |
| 117 |
* Sideloaded from the plugin directory on disk — never fetched over HTTP. The file is |
| 118 |
* already local, so downloading it from our own site would be a pointless round trip |
| 119 |
* even where it succeeds, and where the site is not publicly resolvable it does not |
| 120 |
* succeed: the SSRF guard every outbound image fetch goes through rejects loopback and |
| 121 |
* reserved ranges, and it is right to. |
| 122 |
* |
| 123 |
* Idempotent three ways: an in-process static, then a marker-meta lookup so a resumed or |
| 124 |
* repeated import reuses the attachment rather than filling the library with copies. |
| 125 |
* |
| 126 |
* @since 2.7.2 |
| 127 |
* |
| 128 |
* @return int Attachment id, or 0 when the file or the upload is unavailable. |
| 129 |
*/ |
| 130 |
public static function placeholder_attachment_id() { |
| 131 |
static $cached = null; |
| 132 |
|
| 133 |
if ( null !== $cached ) { |
| 134 |
return $cached; |
| 135 |
} |
| 136 |
|
| 137 |
$cached = 0; |
| 138 |
|
| 139 |
$existing = get_posts( |
| 140 |
array( |
| 141 |
'post_type' => 'attachment', |
| 142 |
'post_status' => 'any', |
| 143 |
'posts_per_page' => 1, |
| 144 |
'fields' => 'ids', |
| 145 |
'no_found_rows' => true, |
| 146 |
'suppress_filters' => false, |
| 147 |
'meta_key' => self::PLACEHOLDER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key |
| 148 |
'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 149 |
) |
| 150 |
); |
| 151 |
|
| 152 |
/* A record whose file has since been deleted is worse than none: WooCommerce would |
| 153 |
* render a broken image rather than fall back. */ |
| 154 |
if ( ! empty( $existing ) && '' !== (string) get_attached_file( (int) $existing[0] ) && file_exists( get_attached_file( (int) $existing[0] ) ) ) { |
| 155 |
$cached = (int) $existing[0]; |
| 156 |
|
| 157 |
return $cached; |
| 158 |
} |
| 159 |
|
| 160 |
if ( ! defined( 'WDKIT_PATH' ) ) { |
| 161 |
return $cached; |
| 162 |
} |
| 163 |
|
| 164 |
$source = WDKIT_PATH . '/assets/images/jpg/woocommerce-placeholder.webp'; |
| 165 |
|
| 166 |
if ( ! file_exists( $source ) ) { |
| 167 |
return $cached; |
| 168 |
} |
| 169 |
|
| 170 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 171 |
require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 172 |
require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 173 |
|
| 174 |
/* wp_handle_sideload() MOVES what it is given, so it gets a copy — moving the file |
| 175 |
* out of the plugin directory would break the placeholder for every later import. */ |
| 176 |
$tmp = wp_tempnam( 'wdkit-woocommerce-placeholder.webp' ); |
| 177 |
|
| 178 |
if ( ! $tmp || ! @copy( $source, $tmp ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 179 |
if ( $tmp ) { |
| 180 |
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 181 |
} |
| 182 |
|
| 183 |
return $cached; |
| 184 |
} |
| 185 |
|
| 186 |
$id = media_handle_sideload( |
| 187 |
array( |
| 188 |
'name' => 'woocommerce-placeholder.webp', |
| 189 |
'tmp_name' => $tmp, |
| 190 |
), |
| 191 |
0, |
| 192 |
null |
| 193 |
); |
| 194 |
|
| 195 |
if ( is_wp_error( $id ) ) { |
| 196 |
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 197 |
|
| 198 |
return $cached; |
| 199 |
} |
| 200 |
|
| 201 |
update_post_meta( $id, self::PLACEHOLDER_META, '1' ); |
| 202 |
|
| 203 |
$cached = (int) $id; |
| 204 |
|
| 205 |
return $cached; |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* The demo catalogue a normal (non-AI) import creates. |
| 210 |
* |
| 211 |
* Copied field-for-field from import_dummy_products()'s `else` branch in |
| 212 |
* import_loader.js — same six products, same order, same prices and categories — so the |
| 213 |
* runner produces the shop the browser path produced. Order matters: normalize() assigns |
| 214 |
* the product type from the record's POSITION via $type_cycle, which is how the browser's |
| 215 |
* `product_type` array mapped one simple/discount/variation/out_of_stock set onto these |
| 216 |
* six. Reordering this list silently changes which product is a variable product. |
| 217 |
* |
| 218 |
* No `image` field: normalize() substitutes placeholder_image() for a record without one, |
| 219 |
* which is the same bundled placeholder upload_dummy_placeholder_image() sideloads. |
| 220 |
* |
| 221 |
* An AI import never reaches this — its catalogue arrives on the `ai_document` and is |
| 222 |
* merged by apply_ai_document() before import() is called. |
| 223 |
* |
| 224 |
* @since 2.7.2 |
| 225 |
* |
| 226 |
* @return array[] Product records in normalize()'s accepted shape. |
| 227 |
*/ |
| 228 |
public static function default_products() { |
| 229 |
return array( |
| 230 |
array( |
| 231 |
'title' => 'Classic Cool Kids Denim', |
| 232 |
'description' => 'Stylish and durable kids denim, perfect for everyday casual comfort wear', |
| 233 |
'price' => '200', |
| 234 |
'category' => array( "Kid's" ), |
| 235 |
), |
| 236 |
array( |
| 237 |
'title' => 'Fit and Flare Dresses', |
| 238 |
'description' => 'Elegant fit and flare dresses designed for flattering, feminine everyday style', |
| 239 |
'price' => '300', |
| 240 |
'category' => array( 'Women' ), |
| 241 |
), |
| 242 |
array( |
| 243 |
'title' => 'Body Con Jacket', |
| 244 |
'description' => 'Sleek body con jacket offering a modern, snug, and stylish silhouette', |
| 245 |
'price' => '150', |
| 246 |
'category' => array( 'Men' ), |
| 247 |
), |
| 248 |
array( |
| 249 |
'title' => 'Modern Tailored Trousers', |
| 250 |
'description' => 'Sharp tailored trousers crafted for a clean, modern, and polished look', |
| 251 |
'price' => '130', |
| 252 |
'category' => array( 'Men' ), |
| 253 |
), |
| 254 |
array( |
| 255 |
'title' => 'Dazzling Denim Duster Coat', |
| 256 |
'description' => 'Statement denim duster coat with dazzling style and contemporary fashion appeal', |
| 257 |
'price' => '150', |
| 258 |
'category' => array( 'Women' ), |
| 259 |
), |
| 260 |
array( |
| 261 |
'title' => 'Cozy Couture Cardigan', |
| 262 |
'description' => 'Soft cozy cardigan blending comfort with chic couture-inspired everyday elegance', |
| 263 |
'price' => '190', |
| 264 |
'category' => array( 'Women' ), |
| 265 |
), |
| 266 |
); |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* Import a list of products. |
| 271 |
* |
| 272 |
* Each product is independent: one failure is recorded and the rest continue, which |
| 273 |
* matches how the browser treats a failed product. |
| 274 |
* |
| 275 |
* @param array $products Product records from the kit / generator. |
| 276 |
* @param string $kit_id Kit id, part of the idempotency marker. |
| 277 |
* @param string $session_id Session id, for per-product step records. |
| 278 |
* @param array $options {update_existing:bool, skip_count_gate:bool, use_defaults:bool}. |
| 279 |
* `use_defaults` supplies default_products() when the caller has |
| 280 |
* no list of its own — see the note in import() below. |
| 281 |
* @return array{status:string,created:array,existing:array,updated:array,failed:array,reason?:string} |
| 282 |
*/ |
| 283 |
public static function import( $products, $kit_id, $session_id = '', $options = array() ) { |
| 284 |
if ( ! self::is_available() ) { |
| 285 |
/* Not an error: a kit can legitimately ship products for a site that has no |
| 286 |
* shop. Never install WooCommerce from here — that is the dependency system's |
| 287 |
* job, and only when the kit itself declares it. */ |
| 288 |
return array( |
| 289 |
'status' => 'skipped', |
| 290 |
'reason' => 'woocommerce_not_available', |
| 291 |
'created' => array(), |
| 292 |
'existing' => array(), |
| 293 |
'updated' => array(), |
| 294 |
'failed' => array(), |
| 295 |
); |
| 296 |
} |
| 297 |
|
| 298 |
$result = array( |
| 299 |
'status' => 'ok', |
| 300 |
'created' => array(), |
| 301 |
'existing' => array(), |
| 302 |
'updated' => array(), |
| 303 |
'failed' => array(), |
| 304 |
|
| 305 |
/* Records that were never products — see the price guard in the loop below. |
| 306 |
* Kept apart from `failed` so they neither stop the stage nor read as errors. */ |
| 307 |
'skipped' => array(), |
| 308 |
); |
| 309 |
|
| 310 |
$products = is_array( $products ) ? $products : array(); |
| 311 |
$options = is_array( $options ) ? $options : array(); |
| 312 |
|
| 313 |
/* Nothing supplied, but the visitor asked for a shop: use the browser's own set, the |
| 314 |
* way Wdkit_Import_Posts::import() falls back to default_document(). |
| 315 |
* |
| 316 |
* import_dummy_products() has two branches — the generated catalogue for an AI import, |
| 317 |
* a hardcoded six for a normal one — and the runner had neither. `products` is not a |
| 318 |
* field the wizard has ever put on site_obj, and the generated catalogue was not being |
| 319 |
* sent either, so this list was empty on EVERY wizard run and the whole class returned |
| 320 |
* `nothing_to_do`: an ecommerce kit imported zero products. |
| 321 |
* |
| 322 |
* Gated on `use_defaults` rather than on is_available() alone. WooCommerce being |
| 323 |
* active is not the same question as "this import wants a shop" — a site that already |
| 324 |
* runs WooCommerce and imports a non-ecommerce kit must not have six demo products |
| 325 |
* dropped into it, which is precisely the distinction the browser drew by checking |
| 326 |
* plugin_setting.ecommerce.required before calling import_dummy_products() at all. */ |
| 327 |
if ( empty( $products ) && ! empty( $options['use_defaults'] ) ) { |
| 328 |
$products = self::default_products(); |
| 329 |
} |
| 330 |
|
| 331 |
if ( empty( $products ) ) { |
| 332 |
$result['status'] = 'nothing_to_do'; |
| 333 |
|
| 334 |
return $result; |
| 335 |
} |
| 336 |
|
| 337 |
$instance = class_exists( 'Wdkit_Import_temp_Ajax' ) ? Wdkit_Import_temp_Ajax::get_instance() : null; |
| 338 |
|
| 339 |
if ( null === $instance || ! method_exists( $instance, 'wdkit_create_product_data' ) ) { |
| 340 |
return array( |
| 341 |
'status' => 'skipped', |
| 342 |
'reason' => 'product_service_unavailable', |
| 343 |
'created' => array(), |
| 344 |
'existing' => array(), |
| 345 |
'updated' => array(), |
| 346 |
'failed' => array(), |
| 347 |
); |
| 348 |
} |
| 349 |
|
| 350 |
/* The browser's gate. Products this run created are excluded, otherwise a run |
| 351 |
* interrupted after the fifth product would refuse to resume. */ |
| 352 |
if ( empty( $options['skip_count_gate'] ) && self::existing_product_count( $session_id ) >= self::EXISTING_PRODUCT_LIMIT ) { |
| 353 |
$result['status'] = 'skipped'; |
| 354 |
$result['reason'] = 'site_already_has_products'; |
| 355 |
|
| 356 |
return $result; |
| 357 |
} |
| 358 |
|
| 359 |
$update_existing = ! empty( $options['update_existing'] ); |
| 360 |
|
| 361 |
/* Products still outstanding for another lane — see the same field in |
| 362 |
* Wdkit_Import_Posts::import(). The runner folds it into $pending. */ |
| 363 |
$incomplete = 0; |
| 364 |
|
| 365 |
/* One query for the whole batch instead of one per product inside the loop below - |
| 366 |
* see ClickUp 14ynqxywncd and the identical fix in Wdkit_Import_Posts::import(). */ |
| 367 |
$existing_by_marker = self::find_existing_by_markers( |
| 368 |
array_map( |
| 369 |
function ( $product, $index ) use ( $kit_id ) { |
| 370 |
return self::marker( $kit_id, self::source_id( $product, $index ) ); |
| 371 |
}, |
| 372 |
$products, |
| 373 |
array_keys( $products ) |
| 374 |
) |
| 375 |
); |
| 376 |
|
| 377 |
foreach ( $products as $index => $product ) { |
| 378 |
$source_id = self::source_id( $product, $index ); |
| 379 |
$marker = self::marker( $kit_id, $source_id ); |
| 380 |
$step = 'product_' . $source_id; |
| 381 |
|
| 382 |
/* Two independent guards. The session step says "this run already did it"; the |
| 383 |
* meta lookup says "a product with this marker already exists", which also |
| 384 |
* covers a session that was lost. Either one is enough to skip. */ |
| 385 |
if ( '' !== $session_id && Wdkit_Import_Session::is_step_complete( $session_id, $step ) ) { |
| 386 |
$result['existing'][] = $source_id; |
| 387 |
continue; |
| 388 |
} |
| 389 |
|
| 390 |
/* One lane per product — several lanes reach this tail together once the pages |
| 391 |
* are done, and two of them creating the same product is the RUNNER_LANES > 1 |
| 392 |
* duplicate. A lane that cannot claim it counts it outstanding and moves on. */ |
| 393 |
if ( '' !== $session_id && ! Wdkit_Import_Session::claim( $session_id, $step ) ) { |
| 394 |
++$incomplete; |
| 395 |
continue; |
| 396 |
} |
| 397 |
|
| 398 |
try { |
| 399 |
/* The gap between the checks above and winning the claim is not zero, and a |
| 400 |
* stale-claim takeover can hand us a product a now-gone lane already made. */ |
| 401 |
if ( '' !== $session_id && Wdkit_Import_Session::is_step_complete( $session_id, $step ) ) { |
| 402 |
$result['existing'][] = $source_id; |
| 403 |
continue; |
| 404 |
} |
| 405 |
|
| 406 |
$existing_id = isset( $existing_by_marker[ $marker ] ) ? (int) $existing_by_marker[ $marker ] : 0; |
| 407 |
|
| 408 |
if ( $existing_id > 0 ) { |
| 409 |
/* Update, when asked, is deliberately narrow: it writes only the four |
| 410 |
* whitelisted fields, and only onto a product carrying OUR marker. A |
| 411 |
* product the site owner created is never touched, because it cannot have |
| 412 |
* the marker. Off by default — the browser never updates, it only creates. */ |
| 413 |
if ( $update_existing ) { |
| 414 |
$updated = self::update( $existing_id, self::normalize( $product, $index ) ); |
| 415 |
|
| 416 |
if ( $updated ) { |
| 417 |
$result['updated'][] = array( |
| 418 |
'source_id' => $source_id, |
| 419 |
'product_id' => $existing_id, |
| 420 |
); |
| 421 |
} |
| 422 |
} |
| 423 |
|
| 424 |
$result['existing'][] = $source_id; |
| 425 |
|
| 426 |
if ( '' !== $session_id ) { |
| 427 |
Wdkit_Import_Session::mark_step_complete( |
| 428 |
$session_id, |
| 429 |
$step, |
| 430 |
array( |
| 431 |
'source_id' => $source_id, |
| 432 |
'product_id' => $existing_id, |
| 433 |
'reused' => true, |
| 434 |
) |
| 435 |
); |
| 436 |
} |
| 437 |
|
| 438 |
continue; |
| 439 |
} |
| 440 |
|
| 441 |
$args = self::normalize( $product, $index ); |
| 442 |
|
| 443 |
/* No usable image URL: attach the bundled placeholder from disk instead. |
| 444 |
* |
| 445 |
* normalize() blanks `product_image` whenever safe_image_url() cannot vouch |
| 446 |
* for it — and that includes the placeholder normalize() itself just |
| 447 |
* substituted, because the URL points at THIS site and |
| 448 |
* wdesignkit_validate_external_url() refuses loopback and reserved ranges. |
| 449 |
* So on any site that is not publicly resolvable (local, staging, an intranet, |
| 450 |
* wp-env) every product came out with no image at all. Testing the blank |
| 451 |
* rather than the URL also picks up a record whose own image was rejected, |
| 452 |
* which the browser treats the same way: a placeholder beats nothing. |
| 453 |
* |
| 454 |
* Passing an id rather than a URL is what upload_dummy_placeholder_image() |
| 455 |
* does in the browser, and it is also one sideload instead of six. */ |
| 456 |
if ( '' === $args['product_image'] ) { |
| 457 |
$placeholder_id = self::placeholder_attachment_id(); |
| 458 |
|
| 459 |
if ( $placeholder_id > 0 ) { |
| 460 |
$args['product_image_id'] = $placeholder_id; |
| 461 |
} |
| 462 |
} |
| 463 |
|
| 464 |
if ( '' === $args['product_title'] || $args['product_price'] <= 0 ) { |
| 465 |
/* Not a failure — a record that was never a product. |
| 466 |
* |
| 467 |
* The generated catalogue reliably contains one of these: asked for |
| 468 |
* products, the model also returns the page's call to action ("Request a |
| 469 |
* Shipping Quote") with no price. Treating that as a failed step stopped |
| 470 |
* the whole content stage on `stage_incomplete` and put Skip / Retry on |
| 471 |
* screen over an import where the other five products imported perfectly, |
| 472 |
* and retrying could never help — the record has no price to find. |
| 473 |
* |
| 474 |
* Recorded as skipped and the step marked COMPLETE, so it neither holds |
| 475 |
* the stage open nor is attempted again. The reason stays in the session |
| 476 |
* either way, so a genuinely malformed kit catalogue is still visible. */ |
| 477 |
$record = array( |
| 478 |
'source_id' => $source_id, |
| 479 |
'message' => __( 'Skipped: no price, so this is not a product.', 'wdesignkit' ), |
| 480 |
); |
| 481 |
|
| 482 |
$result['skipped'][] = $record; |
| 483 |
|
| 484 |
if ( '' !== $session_id ) { |
| 485 |
Wdkit_Import_Session::mark_step_complete( $session_id, $step, $record ); |
| 486 |
Wdkit_Import_Session::release( $session_id, $step ); |
| 487 |
} |
| 488 |
|
| 489 |
if ( class_exists( 'Wdkit_Import_Log' ) ) { |
| 490 |
Wdkit_Import_Log::add( |
| 491 |
'product_skip', |
| 492 |
array( |
| 493 |
'source_id' => $source_id, |
| 494 |
'title' => $args['product_title'], |
| 495 |
'price' => $args['product_price'], |
| 496 |
) |
| 497 |
); |
| 498 |
} |
| 499 |
|
| 500 |
continue; |
| 501 |
} |
| 502 |
|
| 503 |
$response = $instance->wdkit_create_product_data( $args ); |
| 504 |
|
| 505 |
if ( empty( $response['success'] ) || empty( $response['product_id'] ) ) { |
| 506 |
$record = array( |
| 507 |
'source_id' => $source_id, |
| 508 |
'message' => isset( $response['message'] ) ? (string) $response['message'] : __( 'Product creation failed.', 'wdesignkit' ), |
| 509 |
); |
| 510 |
|
| 511 |
$result['failed'][] = $record; |
| 512 |
|
| 513 |
if ( '' !== $session_id ) { |
| 514 |
Wdkit_Import_Session::mark_step_failed( $session_id, $step, $record ); |
| 515 |
} |
| 516 |
|
| 517 |
continue; |
| 518 |
} |
| 519 |
|
| 520 |
$product_id = (int) $response['product_id']; |
| 521 |
|
| 522 |
/* Stamped immediately after creation, so an interruption between the two |
| 523 |
* leaves at worst one unmarked product rather than a run that repeats. */ |
| 524 |
update_post_meta( $product_id, self::SOURCE_META, $marker ); |
| 525 |
|
| 526 |
$record = array( |
| 527 |
'source_id' => $source_id, |
| 528 |
'product_id' => $product_id, |
| 529 |
); |
| 530 |
|
| 531 |
$result['created'][] = $record; |
| 532 |
|
| 533 |
if ( '' !== $session_id ) { |
| 534 |
Wdkit_Import_Session::mark_step_complete( $session_id, $step, $record ); |
| 535 |
} |
| 536 |
} finally { |
| 537 |
if ( '' !== $session_id ) { |
| 538 |
Wdkit_Import_Session::release( $session_id, $step ); |
| 539 |
} |
| 540 |
} |
| 541 |
} |
| 542 |
|
| 543 |
$result['incomplete'] = $incomplete; |
| 544 |
|
| 545 |
return $result; |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* A stable identifier for one product record. |
| 550 |
* |
| 551 |
* Prefers an id the kit supplies; falls back to a hash of the title so the same |
| 552 |
* generated product resolves to the same marker across retries. The index is the last |
| 553 |
* resort and is the only case where a retry could theoretically re-create — noted |
| 554 |
* rather than hidden. |
| 555 |
* |
| 556 |
* @param array $product Product record. |
| 557 |
* @param int $index Position in the list. |
| 558 |
* @return string |
| 559 |
*/ |
| 560 |
private static function source_id( $product, $index ) { |
| 561 |
foreach ( array( 'source_id', 'id', 'product_id' ) as $key ) { |
| 562 |
if ( ! empty( $product[ $key ] ) && ( is_string( $product[ $key ] ) || is_numeric( $product[ $key ] ) ) ) { |
| 563 |
return preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $product[ $key ] ); |
| 564 |
} |
| 565 |
} |
| 566 |
|
| 567 |
$title = isset( $product['product_title'] ) ? $product['product_title'] : ( isset( $product['title'] ) ? $product['title'] : '' ); |
| 568 |
|
| 569 |
if ( is_string( $title ) && '' !== trim( $title ) ) { |
| 570 |
return 't' . substr( md5( trim( $title ) ), 0, 16 ); |
| 571 |
} |
| 572 |
|
| 573 |
return 'i' . (int) $index; |
| 574 |
} |
| 575 |
|
| 576 |
/** |
| 577 |
* The marker written to post meta. |
| 578 |
* |
| 579 |
* @param string $kit_id Kit id. |
| 580 |
* @param string $source_id Product source id. |
| 581 |
* @return string |
| 582 |
*/ |
| 583 |
private static function marker( $kit_id, $source_id ) { |
| 584 |
return preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $kit_id ) . ':' . $source_id; |
| 585 |
} |
| 586 |
|
| 587 |
/** |
| 588 |
* Resolve every already-imported product for a batch of markers in one query. |
| 589 |
* |
| 590 |
* Replaces a get_posts() call per product inside the import loop with a single lookup |
| 591 |
* for the whole batch - a fresh (non-resumed) import of N products used to run N of |
| 592 |
* these. See ClickUp 14ynqxywncd. |
| 593 |
* |
| 594 |
* @param string[] $markers Marker values for the whole batch. |
| 595 |
* @return array<string,int> marker => product_id, absent when no match. |
| 596 |
*/ |
| 597 |
private static function find_existing_by_markers( $markers ) { |
| 598 |
$markers = array_values( array_unique( array_filter( (array) $markers, 'strlen' ) ) ); |
| 599 |
|
| 600 |
if ( empty( $markers ) ) { |
| 601 |
return array(); |
| 602 |
} |
| 603 |
|
| 604 |
global $wpdb; |
| 605 |
|
| 606 |
$placeholders = implode( ', ', array_fill( 0, count( $markers ), '%s' ) ); |
| 607 |
|
| 608 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names only; every value is a %s placeholder below. |
| 609 |
$rows = $wpdb->get_results( |
| 610 |
$wpdb->prepare( |
| 611 |
"SELECT p.ID as post_id, pm.meta_value as marker |
| 612 |
FROM {$wpdb->postmeta} pm |
| 613 |
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id |
| 614 |
WHERE pm.meta_key = %s AND pm.meta_value IN ($placeholders) |
| 615 |
AND p.post_type = 'product' AND p.post_status != 'trash'", |
| 616 |
array_merge( array( self::SOURCE_META ), $markers ) |
| 617 |
) |
| 618 |
); |
| 619 |
|
| 620 |
$found = array(); |
| 621 |
|
| 622 |
foreach ( (array) $rows as $row ) { |
| 623 |
if ( isset( $row->marker, $row->post_id ) ) { |
| 624 |
$found[ $row->marker ] = (int) $row->post_id; |
| 625 |
} |
| 626 |
} |
| 627 |
|
| 628 |
return $found; |
| 629 |
} |
| 630 |
|
| 631 |
/** |
| 632 |
* Shape one product record for the extracted handler. |
| 633 |
* |
| 634 |
* Image URLs go through the same SSRF guard every other imported image does — the |
| 635 |
* handler sideloads whatever URL it is given, so validation has to happen before it. |
| 636 |
* |
| 637 |
* @param array $product Raw product record. |
| 638 |
* @param int $index Position in the list, for the type cycle. |
| 639 |
* @return array |
| 640 |
*/ |
| 641 |
private static function normalize( $product, $index = 0 ) { |
| 642 |
$product = is_array( $product ) ? $product : array(); |
| 643 |
|
| 644 |
$title = isset( $product['product_title'] ) ? $product['product_title'] : ( isset( $product['title'] ) ? $product['title'] : '' ); |
| 645 |
$desc = isset( $product['product_desc'] ) ? $product['product_desc'] : ( isset( $product['description'] ) ? $product['description'] : '' ); |
| 646 |
$price = isset( $product['product_price'] ) ? $product['product_price'] : ( isset( $product['price'] ) ? $product['price'] : 0 ); |
| 647 |
$image = isset( $product['product_image'] ) ? $product['product_image'] : ( isset( $product['image'] ) ? $product['image'] : '' ); |
| 648 |
|
| 649 |
/* No type on the record is the normal case, not the exception — see $type_cycle. */ |
| 650 |
$type = isset( $product['product_type'] ) ? $product['product_type'] : ( isset( $product['type'] ) ? $product['type'] : '' ); |
| 651 |
|
| 652 |
if ( ! in_array( $type, self::$types, true ) ) { |
| 653 |
$type = self::$type_cycle[ ( (int) $index ) % count( self::$type_cycle ) ]; |
| 654 |
} |
| 655 |
|
| 656 |
/* The browser always sends the bundled placeholder; a record with no image gets the |
| 657 |
* same rather than a product with no image at all. */ |
| 658 |
if ( '' === trim( (string) $image ) ) { |
| 659 |
$image = self::placeholder_image(); |
| 660 |
} |
| 661 |
|
| 662 |
$categories = array(); |
| 663 |
|
| 664 |
foreach ( array( 'product_category', 'categories', 'category' ) as $key ) { |
| 665 |
if ( ! empty( $product[ $key ] ) && is_array( $product[ $key ] ) ) { |
| 666 |
$categories = $product[ $key ]; |
| 667 |
break; |
| 668 |
} |
| 669 |
} |
| 670 |
|
| 671 |
return array( |
| 672 |
'product_title' => is_string( $title ) ? trim( $title ) : '', |
| 673 |
'product_desc' => is_string( $desc ) ? $desc : '', |
| 674 |
'product_price' => is_numeric( $price ) ? (float) $price : 0, |
| 675 |
'product_type' => $type, |
| 676 |
'product_image' => self::safe_image_url( $image ), |
| 677 |
'product_category' => array_values( |
| 678 |
array_filter( |
| 679 |
array_map( |
| 680 |
static function ( $name ) { |
| 681 |
return is_string( $name ) ? trim( $name ) : ''; |
| 682 |
}, |
| 683 |
$categories |
| 684 |
), |
| 685 |
static function ( $name ) { |
| 686 |
return '' !== $name; |
| 687 |
} |
| 688 |
) |
| 689 |
), |
| 690 |
); |
| 691 |
} |
| 692 |
|
| 693 |
/** |
| 694 |
* Update a product this importer created. |
| 695 |
* |
| 696 |
* Writes only name, description, price and categories — the same four fields the |
| 697 |
* generator produces. It cannot change the product type, the variations, the image or |
| 698 |
* the status, and it refuses outright unless the product carries our marker, so there is |
| 699 |
* no path from a payload to a product the site owner made. |
| 700 |
* |
| 701 |
* @param int $product_id Product id. |
| 702 |
* @param array $args Normalised args. |
| 703 |
* @return bool True when something was written. |
| 704 |
*/ |
| 705 |
private static function update( $product_id, $args ) { |
| 706 |
if ( ! function_exists( 'wc_get_product' ) ) { |
| 707 |
return false; |
| 708 |
} |
| 709 |
|
| 710 |
/* Belt and braces: import() only reaches here via find_existing_by_markers(), but this |
| 711 |
* method is the one that writes, so it re-checks rather than trusting its caller. */ |
| 712 |
if ( '' === (string) get_post_meta( $product_id, self::SOURCE_META, true ) ) { |
| 713 |
return false; |
| 714 |
} |
| 715 |
|
| 716 |
$product = wc_get_product( $product_id ); |
| 717 |
|
| 718 |
if ( ! $product ) { |
| 719 |
return false; |
| 720 |
} |
| 721 |
|
| 722 |
if ( '' !== $args['product_title'] ) { |
| 723 |
$product->set_name( $args['product_title'] ); |
| 724 |
} |
| 725 |
|
| 726 |
if ( '' !== $args['product_desc'] ) { |
| 727 |
$product->set_description( $args['product_desc'] ); |
| 728 |
} |
| 729 |
|
| 730 |
/* Only on a simple product: setting a regular price on a variable product would |
| 731 |
* contradict its variations. */ |
| 732 |
if ( $args['product_price'] > 0 && method_exists( $product, 'set_regular_price' ) && ! $product->is_type( 'variable' ) ) { |
| 733 |
$product->set_regular_price( $args['product_price'] ); |
| 734 |
} |
| 735 |
|
| 736 |
if ( ! empty( $args['product_category'] ) ) { |
| 737 |
$ids = array(); |
| 738 |
|
| 739 |
foreach ( $args['product_category'] as $name ) { |
| 740 |
$term = term_exists( $name, 'product_cat' ); |
| 741 |
|
| 742 |
if ( $term && ! is_wp_error( $term ) ) { |
| 743 |
$ids[] = is_array( $term ) ? (int) $term['term_id'] : (int) $term; |
| 744 |
} |
| 745 |
} |
| 746 |
|
| 747 |
if ( ! empty( $ids ) ) { |
| 748 |
$product->set_category_ids( $ids ); |
| 749 |
} |
| 750 |
} |
| 751 |
|
| 752 |
$product->save(); |
| 753 |
|
| 754 |
return true; |
| 755 |
} |
| 756 |
|
| 757 |
/** |
| 758 |
* Published products on the site, excluding this run's own. |
| 759 |
* |
| 760 |
* @param string $session_id Session id. |
| 761 |
* @return int |
| 762 |
*/ |
| 763 |
private static function existing_product_count( $session_id ) { |
| 764 |
$found = get_posts( |
| 765 |
array( |
| 766 |
'post_type' => 'product', |
| 767 |
'post_status' => 'publish', |
| 768 |
'posts_per_page' => 50, |
| 769 |
'fields' => 'ids', |
| 770 |
'no_found_rows' => true, |
| 771 |
) |
| 772 |
); |
| 773 |
|
| 774 |
$found = is_array( $found ) ? array_map( 'intval', $found ) : array(); |
| 775 |
|
| 776 |
if ( '' === $session_id || empty( $found ) ) { |
| 777 |
return count( $found ); |
| 778 |
} |
| 779 |
|
| 780 |
$ours = Wdkit_Import_Cleanup::session_created_ids( $session_id ); |
| 781 |
|
| 782 |
return count( array_diff( $found, array_keys( $ours ) ) ); |
| 783 |
} |
| 784 |
|
| 785 |
/** |
| 786 |
* AI products for an import that arrived without any (website / sandbox jobs). |
| 787 |
* |
| 788 |
* The plugin's own wizard asks `wkit_generate_product_data` in the browser and sends the |
| 789 |
* answer as `ai_document.products`. A job started from the website has no browser step, |
| 790 |
* so without this the shop fell back to the demo catalogue on a site about something |
| 791 |
* else entirely (ClickUp 14ynqxywnad). Cached per session - every lane reaches the |
| 792 |
* product tail - and a failure is cached too, so a dead endpoint costs one call. |
| 793 |
* |
| 794 |
* @param array $context Import context. |
| 795 |
* @param string $session_id Session id. |
| 796 |
* @return array Product records, empty when the generator gave nothing usable. |
| 797 |
*/ |
| 798 |
public static function generate_ai_products( $context, $session_id = '' ) { |
| 799 |
$key = '' !== $session_id ? 'wdkit_ai_products_' . $session_id : ''; |
| 800 |
|
| 801 |
if ( '' !== $key ) { |
| 802 |
$cached = get_transient( $key ); |
| 803 |
if ( is_array( $cached ) ) { |
| 804 |
return isset( $cached['products'] ) ? $cached['products'] : array(); |
| 805 |
} |
| 806 |
} |
| 807 |
|
| 808 |
$site_info = ( ! empty( $context['site_info'] ) && is_array( $context['site_info'] ) ) ? $context['site_info'] : array(); |
| 809 |
$site_name = ! empty( $site_info['site_name'] ) ? (string) $site_info['site_name'] : ''; |
| 810 |
$site_type = ! empty( $context['site_type'] ) ? (string) $context['site_type'] : ''; |
| 811 |
$site_desc = ! empty( $context['site_description'] ) ? $context['site_description'] : ''; |
| 812 |
$site_desc = is_array( $site_desc ) ? (string) reset( $site_desc ) : (string) $site_desc; |
| 813 |
$products = array(); |
| 814 |
|
| 815 |
if ( '' !== $site_name || '' !== $site_type ) { |
| 816 |
$base_url = defined( 'WDKIT_SERVER_API_URL' ) ? WDKIT_SERVER_API_URL : 'https://api.wdesignkit.com/'; |
| 817 |
$body = array( |
| 818 |
'site_type' => '' !== $site_type ? $site_type : $site_name, |
| 819 |
'site_title' => '' !== $site_name ? $site_name : $site_type, |
| 820 |
'site_desc' => $site_desc, |
| 821 |
'token' => function_exists( 'wdkit_kit_import_resolve_token' ) ? wdkit_kit_import_resolve_token() : '', |
| 822 |
); |
| 823 |
|
| 824 |
/* A sandbox has no account token; the cloud accepts this site's poll token instead. */ |
| 825 |
if ( function_exists( 'wdkit_kit_import_with_site_identity' ) ) { |
| 826 |
$body = wdkit_kit_import_with_site_identity( $body ); |
| 827 |
} |
| 828 |
|
| 829 |
$response = wp_remote_post( |
| 830 |
trailingslashit( $base_url ) . 'api/wp/ai/product/generate', |
| 831 |
array( |
| 832 |
'timeout' => 60, |
| 833 |
'body' => $body, |
| 834 |
) |
| 835 |
); |
| 836 |
|
| 837 |
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { |
| 838 |
$json = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 839 |
|
| 840 |
if ( is_array( $json ) && ! empty( $json['success'] ) && ! empty( $json['data'] ) ) { |
| 841 |
$text = is_string( $json['data'] ) ? $json['data'] : wp_json_encode( $json['data'] ); |
| 842 |
$text = preg_replace( '/^```(?:json)?\s*/i', '', trim( $text ) ); |
| 843 |
$text = preg_replace( '/\s*```$/', '', $text ); |
| 844 |
$parsed = json_decode( $text, true ); |
| 845 |
|
| 846 |
if ( is_array( $parsed ) && ! empty( $parsed['products'] ) && is_array( $parsed['products'] ) ) { |
| 847 |
$products = array_values( array_filter( $parsed['products'], 'is_array' ) ); |
| 848 |
} |
| 849 |
} |
| 850 |
} |
| 851 |
} |
| 852 |
|
| 853 |
if ( '' !== $key ) { |
| 854 |
set_transient( $key, array( 'products' => $products ), HOUR_IN_SECONDS ); |
| 855 |
} |
| 856 |
|
| 857 |
return $products; |
| 858 |
} |
| 859 |
|
| 860 |
/** |
| 861 |
* Merge a canonical AI document's product copy over the kit's product list. |
| 862 |
* |
| 863 |
* The kit list is the structural side — how many products, in what order, which |
| 864 |
* therefore decides the type cycle. The AI side is copy only. Records are joined on |
| 865 |
* source id when both carry one, and fall back to position, which is what the browser |
| 866 |
* does (it has no ids at all and zips the two lists by index). |
| 867 |
* |
| 868 |
* A product present in the kit but missing from the AI document keeps its kit copy; |
| 869 |
* that is the same rule the page merge follows. |
| 870 |
* |
| 871 |
* @param array $kit_products Kit / structural product records. |
| 872 |
* @param array $document Canonical AI document. |
| 873 |
* @return array |
| 874 |
*/ |
| 875 |
public static function apply_ai_document( $kit_products, $document ) { |
| 876 |
$kit_products = is_array( $kit_products ) ? array_values( $kit_products ) : array(); |
| 877 |
$ai = ( ! empty( $document['products'] ) && is_array( $document['products'] ) ) |
| 878 |
? array_values( $document['products'] ) |
| 879 |
: array(); |
| 880 |
|
| 881 |
if ( empty( $ai ) ) { |
| 882 |
return $kit_products; |
| 883 |
} |
| 884 |
|
| 885 |
/* AI-only is a legitimate shape: the generator invents the whole catalogue when the |
| 886 |
* kit ships none, which is exactly what import_dummy_products() does. */ |
| 887 |
if ( empty( $kit_products ) ) { |
| 888 |
return $ai; |
| 889 |
} |
| 890 |
|
| 891 |
$by_id = array(); |
| 892 |
|
| 893 |
foreach ( $ai as $record ) { |
| 894 |
if ( ! empty( $record['source_id'] ) ) { |
| 895 |
$by_id[ (string) $record['source_id'] ] = $record; |
| 896 |
} |
| 897 |
} |
| 898 |
|
| 899 |
/* Position is only a valid join when the AI side carries no ids at all — that is the |
| 900 |
* browser's situation, where the two lists are index-aligned by construction. The |
| 901 |
* moment ids are present they are authoritative: falling back to position for an |
| 902 |
* unmatched product would graft one product's copy onto another, which is worse than |
| 903 |
* leaving the kit copy in place. */ |
| 904 |
$by_position = empty( $by_id ); |
| 905 |
|
| 906 |
$merged = array(); |
| 907 |
|
| 908 |
foreach ( $kit_products as $index => $product ) { |
| 909 |
$product = is_array( $product ) ? $product : array(); |
| 910 |
$key = self::source_id( $product, $index ); |
| 911 |
|
| 912 |
if ( isset( $by_id[ $key ] ) ) { |
| 913 |
$record = $by_id[ $key ]; |
| 914 |
} elseif ( $by_position && isset( $ai[ $index ] ) ) { |
| 915 |
$record = $ai[ $index ]; |
| 916 |
} else { |
| 917 |
$merged[] = $product; |
| 918 |
continue; |
| 919 |
} |
| 920 |
|
| 921 |
/* Write onto whichever spelling the kit record already uses, because normalize() |
| 922 |
* prefers the `product_*` form — writing `title` next to an existing |
| 923 |
* `product_title` would look applied and silently do nothing. */ |
| 924 |
$aliases = array( |
| 925 |
'title' => 'product_title', |
| 926 |
'description' => 'product_desc', |
| 927 |
'price' => 'product_price', |
| 928 |
'category' => 'product_category', |
| 929 |
); |
| 930 |
|
| 931 |
foreach ( $aliases as $field => $prefixed ) { |
| 932 |
if ( ! isset( $record[ $field ] ) || '' === $record[ $field ] || array() === $record[ $field ] ) { |
| 933 |
continue; |
| 934 |
} |
| 935 |
|
| 936 |
$product[ array_key_exists( $prefixed, $product ) ? $prefixed : $field ] = $record[ $field ]; |
| 937 |
} |
| 938 |
|
| 939 |
$merged[] = $product; |
| 940 |
} |
| 941 |
|
| 942 |
return $merged; |
| 943 |
} |
| 944 |
|
| 945 |
/** |
| 946 |
* Validate a product image URL before the handler sideloads it. |
| 947 |
* |
| 948 |
* @param mixed $url Raw URL. |
| 949 |
* @return string Empty string when unusable, which makes the handler skip the image. |
| 950 |
*/ |
| 951 |
private static function safe_image_url( $url ) { |
| 952 |
if ( ! is_string( $url ) || '' === trim( $url ) ) { |
| 953 |
return ''; |
| 954 |
} |
| 955 |
|
| 956 |
$url = esc_url_raw( trim( $url ) ); |
| 957 |
|
| 958 |
if ( '' === $url ) { |
| 959 |
return ''; |
| 960 |
} |
| 961 |
|
| 962 |
$uploads = wp_get_upload_dir(); |
| 963 |
|
| 964 |
if ( ! empty( $uploads['baseurl'] ) && 0 === strpos( $url, $uploads['baseurl'] ) ) { |
| 965 |
return $url; |
| 966 |
} |
| 967 |
|
| 968 |
if ( function_exists( 'wdesignkit_validate_external_url' ) && ! wdesignkit_validate_external_url( $url ) ) { |
| 969 |
return ''; |
| 970 |
} |
| 971 |
|
| 972 |
return $url; |
| 973 |
} |
| 974 |
} |
| 975 |
} |
| 976 |
|