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-products.php

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

976 lines 35.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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