PluginProbe ʕ •ᴥ•ʔ
XML Feed for Skroutz & BestPrice for WooCommerce / 1.0.3
XML Feed for Skroutz & BestPrice for WooCommerce v1.0.3
trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 1.2.3 1.2.4
xml-feed-for-skroutz-for-woocommerce / admin / class-dc-skroutz-feed-creator.php
xml-feed-for-skroutz-for-woocommerce / admin Last commit date
css 1 year ago js 1 year ago class-dc-skroutz-feed-admin.php 1 year ago class-dc-skroutz-feed-creator.php 1 year ago class-dc-skroutz-feed-data-helper.php 1 year ago index.php 1 year ago mod_simplexml.php 1 year ago
class-dc-skroutz-feed-creator.php
1220 lines
1 <?php
2
3 /**
4 * The class that generates the XML feed.
5 */
6 class Dicha_Skroutz_Feed_Creator {
7
8 /**
9 * The plugin options (user settings).
10 * @var array $options
11 */
12 private array $options;
13
14 /**
15 * The feed type.
16 * @var string $feed_type
17 */
18 private string $feed_type;
19
20 /**
21 * A helper class to get product data.
22 * @var Dicha_Skroutz_Feed_Data_Helper $data_helper
23 */
24 private Dicha_Skroutz_Feed_Data_Helper $data_helper;
25
26 /**
27 * An array with mapping between sanitized and unsanitized attribute slugs.
28 * @var array $wc_product_attributes_sanitized
29 */
30 private array $wc_product_attributes_sanitized = [];
31
32 /**
33 * The array with product data that will be exported to XML.
34 * @var array
35 */
36 private array $products_for_export = [];
37
38 /**
39 * The array with problematic products that will be skipped from XML.
40 * @var array
41 */
42 private array $products_with_errors = [];
43
44 /**
45 * The array with errors that occurred during the XML file creation process.
46 * @var string[]
47 */
48 private array $xml_creation_errors = [];
49
50 /**
51 * Script execution start time.
52 *
53 * @var float
54 */
55 private float $start_full_time = 0.0;
56
57 /**
58 * XML generation completed time.
59 *
60 * @var float
61 */
62 private float $xml_generation_time = 0.0;
63
64 /**
65 * The max memory limit of the server.
66 *
67 * @var string The memory limit in bytes.
68 */
69 private string $memory_limit;
70
71 /**
72 * The fields to wrap in CDATA when writing the XML.
73 *
74 * @var array
75 */
76 private array $fields_in_cdata = [];
77
78
79 /**
80 * Constructor.
81 *
82 * @param string $feed_type
83 */
84 public function __construct( string $feed_type = '' ) {
85
86 $this->feed_type = empty( $feed_type ) ? 'skroutz' : trim( $feed_type );
87 }
88
89
90 /**
91 * The main function that generates the XML feed.
92 *
93 * @return bool True if XML creation was successful. False otherwise.
94 */
95 public function create_feed(): bool {
96
97 $this->start_full_time = microtime( true );
98
99 // echo '<pre>';
100
101 $this->init_options();
102 $this->init_data_helper();
103 $this->setup_wc_attributes_list();
104 $this->find_max_memory_limit();
105
106 // echo '<pre>'; print_r( $this->options ); echo '</pre>';
107
108 // Build product data for export
109 $this->build_product_export_data();
110
111 // Create XML and save
112 $this->saveXML();
113 $this->xml_generation_time = microtime( true );
114
115 // write log data
116 $this->write_logs();
117
118 return empty( $this->xml_creation_errors );
119 }
120
121
122 /**
123 * Gets all products for export and fills an array with their export data.
124 *
125 * @return void
126 */
127 private function build_product_export_data() {
128
129 $skroutz_products = $this->collect_products_for_export();
130 // var_dump( $skroutz_products );
131
132 $product_counter = 0;
133
134 foreach ( $skroutz_products as $product_id ) {
135
136 $product_counter ++;
137
138 if ( $product_counter >= 20 ) {
139 $product_counter = 0;
140
141 $this->maybe_flush_runtime_cache();
142 }
143
144 // if ( $product_id != 1186 ) continue; // Skroutz variable with Size + Color
145 // if ( $product_id != 1170 ) continue; // Skroutz Simple
146 // if ( $product_id != 1170 && $product_id != 1186 ) continue;
147 // if ( $product_id != 1175 ) continue; // Skroutz Variable with Size
148 // if ( $product_id != 1180 ) continue; // Skroutz Variable with Dimension
149 // if ( $product_id != 1230 ) continue; // Skroutz Variable with "Any" option
150 // if ( $product_id != 1264 && $product_id != 1268 ) continue; // Skroutz products with greek slug
151 // if ( $product_id != 1220 && $product_id != 1287 ) continue; // Skroutz variable with 2 attrs (no size var) + Skroutz Variable with Color (only) + Size attr (no var)
152
153 $product = wc_get_product( $product_id );
154
155 if ( ! $product instanceof WC_Product ) continue;
156
157 // Skip products from manual filter
158 if ( $this->data_helper->skroutz_exclude_product_from_xml( $product ) ) continue;
159
160 $product_type = $product->get_type();
161
162 if ( 'simple' === $product_type ) {
163 /** @var WC_Product_Simple $product */
164
165 $unique_id = $product->get_id();
166
167 // gather simple product data
168 $node_data = array_merge(
169 [ 'id' => apply_filters( 'dicha_skroutz_feed_custom_product_id', $unique_id, $product, $this->feed_type ) ],
170 $this->get_simple_product_data( $product )
171 );
172
173 $data_contain_no_errors = $this->detect_data_errors( $node_data );
174
175 if ( $data_contain_no_errors ) {
176 $this->products_for_export[ $unique_id ] = $node_data;
177 }
178 }
179 elseif ( 'variable' === $product_type ) {
180 /** @var WC_Product_Variable $product */
181
182 $variations_groups = [];
183
184 // 1. find variation atts and available variations
185 $available_variations = $product->get_available_variations( 'objects' );
186 $variation_attributes = $product->get_variation_attributes();
187 $has_size_variations = $this->has_size_options( $variation_attributes );
188 $has_non_size_variations = $this->has_non_size_options( $variation_attributes );
189 $has_color_variations = $this->has_color_options( $variation_attributes );
190
191 // var_dump( $variation_attributes );
192 // var_dump( $available_variations );
193 // var_dump($has_size_variations);
194 // var_dump($has_non_size_variations);
195 // var_dump($has_color_variations);
196
197 $parent_id = $product->get_id();
198 $parent_name = $product->get_name();
199
200 // Calculate parent level data once for all variation groups
201 $parent_level_data = $this->get_variable_parent_level_data( $product );
202
203 // if color not used for variations, calculate it now and NOT overwrite later
204 if ( ! $has_color_variations ) {
205 $parent_level_data['color'] = $this->data_helper->skroutz_get_color( $product );
206 }
207
208 // if size not used for variations, calculate it now and NOT overwrite later
209 if ( ! $has_size_variations ) {
210 $parent_level_data['size'] = $this->data_helper->skroutz_get_size( $product );
211 }
212
213 /** @var WC_Product_Variation[] $available_variations */
214
215 // split variations to groups, based on variation attributes
216 foreach ( $available_variations as $variation ) {
217
218 // $variation_attributes = $variation->get_attributes();
219 $variation_attributes = $variation->get_variation_attributes( false );
220 $unique_key = $parent_id;
221 $group_name = $parent_name;
222
223 // var_dump( $variation->get_id() );
224 // var_dump( $variation_attributes );
225
226
227 // Detect if a variation with "Any" size or "Any" color exists ("Any" === empty string)
228 // If "Any" attribute is color or size, we must skip the variation because of skroutz requirements
229 // If "Any" attribute is something else, it doesn't bother us, and the script will continue as usual
230 foreach ( $variation_attributes as $attribute_slug => $attribute_value ) {
231 if ( empty( $attribute_value ) ) {
232
233 // in this point, the $attribute_slug is already sanitized,
234 // so we need to fetch the original (unsanitized) slug with non english chars,
235 // to check inside $this->options which contains the unsanitized slugs
236 // Checked for greek slugs
237 if ( isset( $this->wc_product_attributes_sanitized[ $attribute_slug ] ) ) {
238 $attribute_slug = $this->wc_product_attributes_sanitized[ $attribute_slug ];
239 }
240
241 if ( in_array( $attribute_slug, $this->options['size'] ) || in_array( $attribute_slug, $this->options['color'] ) ) {
242 continue 2;
243 }
244 }
245 }
246
247 // if exist variations with attributes that are not "size", then split to "variations groups"
248 if ( $has_non_size_variations ) {
249
250 $unique_key_parts = [ $unique_key ];
251 $group_name_parts = [ $group_name ];
252
253 foreach ( $variation_attributes as $attribute_slug => $attribute_value ) {
254
255 // in this point, the $attribute_slug is already sanitized,
256 // so we need to fetch the original (unsanitized) slug with non english chars,
257 // in order to use the functions `taxonomy_exists` and `get_term_by`
258 // Checked for greek slugs
259 if ( isset( $this->wc_product_attributes_sanitized[ $attribute_slug ] ) ) {
260 $attribute_slug = $this->wc_product_attributes_sanitized[ $attribute_slug ];
261 }
262
263 // if "size" attribute or attribute with "Any" value, then no grouping happens
264 if ( in_array( $attribute_slug, $this->options['size'] ) || empty( $attribute_value ) ) continue;
265
266 $attribute_term = taxonomy_exists( $attribute_slug ) ? get_term_by( 'slug', $attribute_value, $attribute_slug ) : false;
267 $attribute_term = ! is_wp_error( $attribute_term ) && $attribute_term ? $attribute_term : false;
268
269 // use term_id if taxonomy exists - use value if custom (no taxonomy) attribute
270 // maybe hash $attribute_value for non taxonomies?
271 $term_id = ! is_wp_error( $attribute_term ) && $attribute_term ? $attribute_term->term_id : $attribute_value;
272
273 // Use attribute term_id to create a unique id for the variation group
274 $unique_key_parts[] = $term_id;
275
276 $term_name = ! is_wp_error( $attribute_term ) && $attribute_term ? $attribute_term->name : $attribute_value;
277 $group_name_parts[] = $term_name;
278 }
279
280 $unique_key = implode( '-', $unique_key_parts );
281 $group_name = implode( ' ', $group_name_parts );
282 }
283
284 // add variation to the correct variation group
285 if ( ! isset( $variations_groups[ $unique_key ] ) ) {
286 $variations_groups[ $unique_key ] = [
287 'unique_id' => $unique_key,
288 'group_name' => $this->data_helper->skroutz_get_name( $product, $group_name ),
289 'group_variations' => [ $variation ]
290 ];
291 }
292 else {
293 $variations_groups[ $unique_key ]['group_variations'][] = $variation;
294 }
295 }
296
297 // in case of all variations are "Any" size or "Any" color variations -> Skip product but add to array for logging purposes
298 if ( empty( $variations_groups ) ) {
299 $this->products_with_errors[ $parent_id ] = [
300 'id' => $parent_id,
301 'name' => $parent_name,
302 'errors' => [
303 'variations' => new WP_Error( '80-1', 'Product skipped due to "Any" variations' )
304 ]
305 ];
306 }
307 else {
308 // a list of used skus, to prevent "duplicate sku" errors when variations have no own sku, only on parent level
309 $groups_skus_list = [];
310
311 foreach ( $variations_groups as $unique_key => $variations_group ) {
312
313 // gather variable (parent) product data
314 $node_data = array_merge(
315 [
316 'id' => apply_filters( 'dicha_skroutz_feed_custom_product_id', $variations_group['unique_id'], $product, $this->feed_type ), // if no size variation grouping, this will be replaced later with variation ID
317 'name' => $variations_group['group_name']
318 ],
319 $parent_level_data,
320 $this->get_variations_group_data( $product, $variations_group['group_variations'], $has_size_variations, $groups_skus_list ),
321 );
322
323 // check if errors exist
324 $data_contain_no_errors = $this->detect_data_errors( $node_data );
325
326 if ( $data_contain_no_errors ) {
327 $this->products_for_export[ $unique_key ] = $node_data;
328 }
329 }
330 }
331 }
332 }
333
334 // Clear runtime cache in the end to free resources
335 $this->flush_runtime_cache();
336 }
337
338
339 /**
340 * Wrapper function for XML generation when triggered manually from the button inside Tools section.
341 * Redirects automatically to XML settings page after XML generation is completed.
342 *
343 * @return void
344 */
345 public function create_feed_manual_mode() {
346
347 $feed_generation_result = $this->create_feed();
348 $result_param_value = $feed_generation_result ? 1 : 0;
349
350 // enable this after testing
351 wp_redirect( admin_url( 'admin.php?page=' . DICHA_SKROUTZ_FEED_SLUG . '&feed_success=' . $result_param_value ) );
352 exit;
353 }
354
355
356 /**
357 * Initializes options.
358 *
359 * @return void
360 */
361 private function init_options() {
362
363 $options = [
364 'manufacturer' => $this->prefix_attributes( get_option( 'dicha_skroutz_feed_manufacturer', [] ) ),
365 'color' => $this->prefix_attributes( get_option( 'dicha_skroutz_feed_color', [] ) ),
366 'size' => $this->prefix_attributes( get_option( 'dicha_skroutz_feed_size', [] ) ),
367 'title_attributes' => $this->prefix_attributes( get_option( 'dicha_skroutz_feed_title_attributes', [] ) ),
368 'xml_availability' => get_option( 'dicha_skroutz_feed_availability' ),
369 'include_backorders' => get_option( 'dicha_skroutz_feed_include_backorders' ),
370 'description' => get_option( 'dicha_skroutz_feed_description', 'short' ),
371 'flat_rate' => get_option( 'dicha_skroutz_feed_shipping_cost' ),
372 'flat_rate_free' => get_option( 'dicha_skroutz_feed_free_shipping' ),
373 'selected_cats' => get_option( 'dicha_skroutz_feed_filter_categories', [] ),
374 'selected_tags' => get_option( 'dicha_skroutz_feed_filter_tags', [] ),
375 'cats_incl_mode' => get_option( 'dicha_skroutz_incl_excl_mode_categories' ),
376 'tags_incl_mode' => get_option( 'dicha_skroutz_incl_excl_mode_tags' ),
377 ];
378
379 $this->options = apply_filters( 'dicha_skroutz_feed_custom_options', $options, $this->feed_type );
380 }
381
382
383 /**
384 * Initializes data helper class.
385 *
386 * @return void
387 */
388 private function init_data_helper() {
389
390 require_once( 'class-dc-skroutz-feed-data-helper.php' );
391
392 $this->data_helper = new Dicha_Skroutz_Feed_Data_Helper( $this->options, $this->feed_type );
393 }
394
395
396 /**
397 * Fetches WooCommerce products for export, depending on options and filters.
398 *
399 * @return array Array of WooCommerce products.
400 */
401 private function collect_products_for_export(): array {
402
403 $query_args = [
404 'return' => 'ids',
405 'limit' => -1,
406 'type' => [ 'simple', 'variable' ],
407 'visibility' => 'catalog',
408 'status' => 'publish',
409 'virtual' => false,
410 'downloadable' => false,
411 'stock_status' => wc_string_to_bool( $this->options['include_backorders'] ) ? [ 'instock', 'onbackorder' ] : 'instock',
412 ];
413
414 // include/exclude products by category
415 if ( ! empty( $selected_product_cat_terms = $this->options['selected_cats'] ) ) {
416
417 if ( wc_string_to_bool( $this->options['cats_incl_mode'] ) ) {
418 $query_args['product_category_id'] = $selected_product_cat_terms;
419 }
420 else {
421 $query_args['dicha_exclude_product_category_id'] = $selected_product_cat_terms;
422 }
423 }
424
425 // include/exclude products by tag
426 if ( ! empty( $selected_product_tag_terms = $this->options['selected_tags'] ) ) {
427
428 if ( wc_string_to_bool( $this->options['tags_incl_mode'] ) ) {
429 $query_args['product_tag_id'] = $selected_product_tag_terms;
430 }
431 else {
432 $query_args['dicha_exclude_product_tag_id'] = $selected_product_tag_terms;
433 }
434 }
435
436 $query_args = apply_filters( 'dicha_skroutz_feed_product_query_args', $query_args, $this->options, $this->feed_type );
437
438 return wc_get_products( $query_args );
439 }
440
441
442 /**
443 * Builds node data for simple products.
444 *
445 * @param $product WC_Product_Simple
446 *
447 * @return array
448 */
449 private function get_simple_product_data( WC_Product_Simple $product ): array {
450 return [
451 'name' => $this->data_helper->skroutz_get_name( $product ),
452 'link' => $this->data_helper->skroutz_get_url( $product ),
453 'image' => $this->data_helper->skroutz_get_main_image_url( $product ),
454 'additional_imageurl' => $this->data_helper->skroutz_get_additional_images( $product ),
455 'category' => $this->data_helper->skroutz_get_category( $product ),
456 'price_with_vat' => $this->data_helper->skroutz_get_price( $product ),
457 'vat' => $this->data_helper->skroutz_get_vat( $product ),
458 'availability' => $this->data_helper->skroutz_get_availability( $product ),
459 'manufacturer' => $this->data_helper->skroutz_get_manufacturer( $product ),
460 'mpn' => $this->data_helper->skroutz_get_mpn( $product ),
461 'ean' => $this->data_helper->skroutz_get_ean( $product ),
462 'size' => $this->data_helper->skroutz_get_size( $product ),
463 'weight' => $this->data_helper->skroutz_get_weight( $product ),
464 'shipping_costs' => $this->data_helper->skroutz_get_shipping( $product ),
465 'color' => $this->data_helper->skroutz_get_color( $product ),
466 'description' => $this->data_helper->skroutz_get_description( $product ),
467 'quantity' => $this->data_helper->skroutz_get_quantity( $product ),
468 ];
469 }
470
471
472 /**
473 * Builds node data for variable products (Only parent-related data).
474 *
475 * @param $parent_product WC_Product_Variable
476 *
477 * @return array
478 */
479 private function get_variable_parent_level_data( WC_Product_Variable $parent_product ): array {
480 return [
481 'link' => $this->data_helper->skroutz_get_url( $parent_product ),
482 'category' => $this->data_helper->skroutz_get_category( $parent_product ),
483 'price_with_vat' => $this->data_helper->skroutz_get_price( $parent_product ),
484 'vat' => $this->data_helper->skroutz_get_vat( $parent_product ),
485 'availability' => $this->data_helper->skroutz_get_availability( $parent_product ),
486 'manufacturer' => $this->data_helper->skroutz_get_manufacturer( $parent_product ),
487 'mpn' => $this->data_helper->skroutz_get_mpn( $parent_product ), // todo check if same mpn in diff groups cause validation error (ID:1186)
488 'ean' => $this->data_helper->skroutz_get_ean( $parent_product ),
489 'shipping_costs' => $this->data_helper->skroutz_get_shipping( $parent_product ),
490 'description' => $this->data_helper->skroutz_get_description( $parent_product ),
491 ];
492 }
493
494
495 /**
496 * Builds node data for variable products (Variation groups data).
497 *
498 * @param $parent_product WC_Product_Variable The parent product
499 * @param $group_variations WC_Product_Variation[] An array with this group's variations
500 * @param $has_size_variations bool True if parent product has "size" variation attributes
501 * @param $groups_skus_list array A list with unique group SKUs
502 *
503 * @return array
504 */
505 private function get_variations_group_data( WC_Product_Variable $parent_product, array $group_variations, bool $has_size_variations, array &$groups_skus_list ): array {
506
507 // Protect against product data error - All groups with no size vars, should have exactly 1 group variation
508 // Usually with variations not showing in product page because their variations attributes were removed
509 if ( ! $has_size_variations && count( $group_variations ) > 1 ) {
510 $variable_group_data['variations'] = new WP_Error( '80-3', 'Product variation data has critical errors. Check your product.' );
511 return $variable_group_data;
512 }
513
514 $group_color = $group_image = $group_link = $group_sku = '';
515 $variable_group_data = $group_sizes = $group_additional_images = $variation_nodes = [];
516
517 // Get parent stock if manage stock happens on parent level
518 // if this happens, stock status field (instock/outofstock/backorder) disappears from variations tabs
519 // To set stock for a single variation, you should enable manage stock and add stock quantity
520 $parent_manages_stock = $parent_product->managing_stock();
521 $parent_stock = $parent_manages_stock ? max( $parent_product->get_stock_quantity(), 0 ) : false;
522 $group_quantity = $parent_stock !== false ? $parent_stock : 0;
523
524 // Get parent weight - If it's empty, try to get weight if exists on any variation
525 $group_weight = $this->data_helper->skroutz_get_weight( $parent_product );
526
527 // parent main product image
528 $parent_main_image = $this->data_helper->skroutz_get_main_image_url( $parent_product );
529
530 foreach ( $group_variations as $variation ) {
531
532 // Skip variation from manual filter
533 $exclude_variation = $this->data_helper->skroutz_exclude_variation_from_xml( $variation, $parent_product );
534
535 if ( $exclude_variation ) {
536
537 $exclude_error_data = new WP_Error( '10-4', 'Η παραλλαγή έχει εξαιρεθεί λόγω το�
538 φίλτρο�
539 `dicha_skroutz_feed_exclude_variation_from_xml`' );
540
541 if ( ! $has_size_variations ) {
542 $variable_group_data['exclude_xml'] = $exclude_error_data;
543 }
544 else {
545 $variation_nodes[] = [
546 'variationid' => $variation->get_id(),
547 'exclude_xml' => $exclude_error_data
548 ];
549 }
550
551 // continue foreach loop to next group variation
552 continue;
553 }
554
555
556 // get variation manage stock - Returns true/false or 'parent' if managing stock happens on parent level
557 $variation_manages_stock = $variation->get_manage_stock();
558
559 // if variation not managing stock, but parent does, then variation quantity equals parent quantity
560 // in this rare edge case, the total parent stock will not match with the variations' stock sum, but it is more correct in this way
561 if ( $parent_manages_stock && 'parent' === $variation_manages_stock ) {
562 $variation_quantity = $parent_stock;
563 }
564 else {
565 $variation_quantity = $this->data_helper->skroutz_get_quantity( $variation );
566
567 // Create an error if variation is out of stock
568 if ( ! is_wp_error( $variation_quantity ) && $variation_quantity == 0 ) {
569 $variation_quantity = new WP_Error( '10-1', 'Η κατάσταση αποθέματος της παραλλαγής είναι εξαντλημένη' );
570 }
571
572 // If error exists, add it to the <quantity> node in order to skip product from XML and continue to next variation
573 // Skip if variation is out of stock
574 // Skip if variation is on backorder and backorders are not allowed based on settings
575 if ( is_wp_error( $variation_quantity ) ) {
576
577 if ( ! $has_size_variations ) {
578 $group_quantity = $variation_quantity;
579 }
580 else {
581 $variation_nodes[] = [
582 'variationid' => $variation->get_id(),
583 'quantity' => $variation_quantity
584 ];
585 }
586
587 // continue foreach loop to next group variation
588 continue;
589 }
590 else {
591 $group_quantity += $variation_quantity;
592 }
593 }
594
595
596 if ( empty( $group_sku ) ) {
597 // variation sku (not inheriting parent's)
598 $group_sku = $this->data_helper->skroutz_get_mpn( $variation, 'not_inherit_from_parent' );
599 }
600
601 if ( empty( $group_link ) ) {
602 $group_link = $this->data_helper->skroutz_get_url( $variation, true );
603 }
604
605 // Get variation weight if not set on parent level
606 // (tip: skroutz supports weight on parent level only, not variation level)
607 if ( empty( $group_weight ) ) {
608 $variation_weight = $this->data_helper->skroutz_get_weight( $variation );
609 $group_weight = $variation_weight;
610 }
611
612 // calculate variation size and add it to group sizes
613 $variation_size = $this->data_helper->skroutz_get_size( $variation );
614
615 if ( ! empty( $variation_size ) ) {
616 $group_sizes[] = $variation_size;
617 }
618
619 // calculate variation color and set group color
620 if ( empty( $group_color ) ) {
621 $group_color = $this->data_helper->skroutz_get_color( $variation );
622 }
623
624 // get variation image, if not exists returns parent main image
625 $variation_image = $this->data_helper->skroutz_get_main_image_url( $variation );
626
627 // If variation image is different from main product image, then keep the more specific variation image
628 if ( empty( $group_image ) || $variation_image !== $parent_main_image ) {
629 $group_image = $variation_image;
630 }
631
632 /*
633 * If variations have extra gallery images in some custom field, you can use this filter
634 * to add these images in this array.
635 * If custom images are found, they are shown in the <additional_imageurl> nodes.
636 * If no custom images found, then the parent's (variable) gallery images will be shown in this field.
637 */
638 $variation_additional_images = apply_filters( 'dicha_skroutz_feed_custom_variation_additional_images', [], $variation, $parent_product, $this->feed_type );
639
640 if ( ! empty( $variation_additional_images ) ) {
641 $group_additional_images = array_merge( $group_additional_images, $variation_additional_images );
642 }
643
644
645 // if no size variations, then this group has only one variation because no size grouping happens
646 // if no size groups, then don't add size_variations node, but add these data as main nodes
647 if ( ! $has_size_variations ) {
648 $variable_group_data = [
649 'id' => apply_filters( 'dicha_skroutz_feed_custom_variation_id', $variation->get_id(), $variation, $parent_product, $this->feed_type ), // We replace group unique_id with variation ID
650 'availability' => $this->data_helper->skroutz_get_availability( $variation ),
651 'price_with_vat' => $this->data_helper->skroutz_get_price( $variation ),
652 'vat' => $this->data_helper->skroutz_get_vat( $variation ),
653 'ean' => $this->data_helper->skroutz_get_ean( $variation ),
654 ];
655 }
656 else {
657 // if size variations exist, then add size_variations nodes
658 $variation_nodes[] = [
659 'variationid' => apply_filters( 'dicha_skroutz_feed_custom_variation_id', $variation->get_id(), $variation, $parent_product, $this->feed_type ),
660 'availability' => $this->data_helper->skroutz_get_availability( $variation ),
661 'size' => $variation_size,
662 'quantity' => apply_filters( 'dicha_skroutz_feed_custom_variation_quantity', $variation_quantity, $variation, $parent_product, $this->feed_type ),
663 'price_with_vat' => $this->data_helper->skroutz_get_price( $variation ),
664 'link' => $this->data_helper->skroutz_get_url( $variation ),
665 'mpn' => $this->data_helper->skroutz_get_mpn( $variation, 'not_inherit_from_parent' ), // if empty, then empty, don't inherit
666 'ean' => $this->data_helper->skroutz_get_ean( $variation ),
667 ];
668 }
669 }
670
671
672 // if group sku empty, then all variations have no sku in their own tab, so get from parent
673 if ( empty( $group_sku ) ) {
674 $group_sku = $this->data_helper->skroutz_get_mpn( $parent_product );
675 }
676
677 // if this sku already exists in another variation group, add a suffix to make it unique
678 if ( ! empty( $group_sku ) && in_array( $group_sku, $groups_skus_list ) ) {
679 $current_groups_count = count( $groups_skus_list );
680 $group_sku .= '-' . $current_groups_count;
681 }
682
683 // add unique sku to variation group data, and in the unique skus list
684 if ( ! empty( $group_sku ) ) {
685 $variable_group_data['mpn'] = $group_sku;
686 $groups_skus_list[] = $group_sku;
687 }
688
689 $variable_group_data['link'] = $group_link;
690 $variable_group_data['weight'] = $group_weight;
691 $variable_group_data['quantity'] = $group_quantity;
692 $variable_group_data['image'] = $group_image;
693
694 // if not additional images found for variations, then use parents gallery images
695 $group_additional_images = array_unique( array_filter( $group_additional_images ) );
696
697 if ( empty( $group_additional_images ) ) {
698 $group_additional_images = $this->data_helper->skroutz_get_additional_images( $parent_product );
699 }
700
701 if ( ! empty( $group_additional_images ) ) {
702 $variable_group_data['additional_imageurl'] = $group_additional_images;
703 }
704
705 // if size or color empty, then don't add in array, to keep original parent data
706 if ( ! empty( $group_color ) ) {
707 $variable_group_data['color'] = $group_color;
708 }
709
710 if ( ! empty( $group_sizes ) ) {
711 $variable_group_data['size'] = implode( ',', $group_sizes );
712 }
713
714 // Add variation nested nodes if size variations exist and have no errors
715 if ( ! empty( $variation_nodes ) ) {
716
717 // clean variation nodes with errors
718 foreach ( $variation_nodes as $node_key => $variation_node_data ) {
719
720 $nodes_with_errors = array_filter( $variation_node_data, 'is_wp_error' );
721
722 if ( ! empty( $nodes_with_errors ) ) {
723
724 $this->products_with_errors[ $variation_node_data['variationid'] ] = [
725 'name' => $parent_product->get_name() . ' - Variation #' . $variation_node_data['variationid'],
726 'errors' => $nodes_with_errors
727 ];
728
729 unset( $variation_nodes[ $node_key ] );
730 }
731 }
732
733 // if variation nodes still exist, add them to XML
734 if ( ! empty( $variation_nodes ) ) {
735 $variable_group_data['variations'] = $variation_nodes;
736 }
737 else {
738 // if empty, then all variations have errors -> add a WP_Error to force skipping for the parent product
739 $variable_group_data['variations'] = new WP_Error( '80-2', 'All "size" variations nodes have errors or are hidden from XML' );
740 }
741 }
742
743 return $variable_group_data;
744 }
745
746
747 /**
748 * Checks if a "size" attribute exists.
749 *
750 * @param $variation_attributes array of variation attributes slugs and values
751 *
752 * @return bool True if a "size" attribute exists.
753 */
754 private function has_size_options( array $variation_attributes ): bool {
755 return ! empty( array_intersect( array_keys( $variation_attributes ), $this->options['size'] ) );
756 }
757
758
759 /**
760 * Checks if any non "size" attribute exists.
761 *
762 * @param $variation_attributes array of variation attributes slugs and values
763 *
764 * @return bool True if any non "size" attribute exists.
765 */
766 private function has_non_size_options( array $variation_attributes ): bool {
767 return ! empty( array_diff( array_keys( $variation_attributes ), $this->options['size'] ) );
768 }
769
770
771 /**
772 * Checks if a "color" attribute exists.
773 *
774 * @param $variation_attributes array of variation attributes slugs and values
775 *
776 * @return bool True if a "color" attribute exists.
777 */
778 private function has_color_options( array $variation_attributes ): bool {
779 return ! empty( array_intersect( array_keys( $variation_attributes ), $this->options['color'] ) );
780 }
781
782
783 /**
784 * Adds the 'pa_' prefix to attributes slugs, only if missing.
785 *
786 * @param $atts_array string[] Attributes slugs.
787 *
788 * @return string[]
789 */
790 private function prefix_attributes( array $atts_array ): array {
791 return array_map( function( $v ) {
792 if ( empty( $v ) ) return $v;
793 return strpos( $v, 'pa_' ) === 0 ? $v : 'pa_' . $v;
794 }, $atts_array );
795 }
796
797
798 /**
799 * Detect if WP_Errors exist in node data and also adds the problematic node to the errors array.
800 *
801 * @param $node_data array The node data.
802 *
803 * @return bool True if no errors found. False otherwise.
804 */
805 private function detect_data_errors( array $node_data ): bool {
806
807 $data_contain_no_errors = true;
808 $nodes_with_errors = array_filter( $node_data, 'is_wp_error' );
809
810 if ( ! empty( $nodes_with_errors ) ) {
811
812 $this->products_with_errors[ $node_data['id'] ] = [
813 'name' => $node_data['name'],
814 'errors' => $nodes_with_errors
815 ];
816
817 $data_contain_no_errors = false;
818 }
819
820
821 return $data_contain_no_errors;
822 }
823
824
825 /**
826 * Prepares error data to be printed in log files.
827 *
828 * @param $error_data array Original error data containing WP_Errors.
829 *
830 * @return array|false Error data ready for printing in log file, or false if no errors remain after removing unwanted errors.
831 */
832 private function prepare_error_for_printing( array $error_data ) {
833
834 $errors_for_print = [];
835
836 /** @var WP_Error $wp_error */
837 foreach ( $error_data['errors'] as $wp_error ) {
838
839 if ( ! is_wp_error( $wp_error ) ) continue;
840
841 $error_code = $wp_error->get_error_code();
842
843 // Exclude errors which are about stock
844 // Not really errors and not so important to log them
845 if ( in_array( $error_code, [ '10-1', '10-2' ] ) ) continue;
846
847 $errors_for_print[] = [
848 'code' => $error_code,
849 'message' => $wp_error->get_error_message()
850 ];
851 }
852
853 if ( ! empty( $errors_for_print ) ) {
854 $error_data['errors'] = $errors_for_print;
855 return $error_data;
856 }
857
858 return false;
859 }
860
861
862 /**
863 * Creates a new WC log file and prints script info and products with errors.
864 *
865 * @return void
866 */
867 private function write_logs() {
868
869 $log_level = get_option( 'dicha_skroutz_feed_log_level', 'minimal' );
870
871 if ( 'disabled' === $log_level ) return;
872
873 if ( 'full' === $log_level ) {
874 $errors_in_readable_form = array_filter( array_map( [ $this, 'prepare_error_for_printing' ], $this->products_with_errors ) );
875 }
876
877 $start_time = gmdate( 'Y-m-d H:i:s', floor( $this->start_full_time ) );
878 $xml_generation_duration = intval( $this->xml_generation_time - $this->start_full_time );
879 $full_script_duration = intval( microtime( true ) - $this->start_full_time );
880
881 $logger = wc_get_logger();
882 $context = [ 'source' => DICHA_SKROUTZ_FEED_SLUG . '-' . get_date_from_gmt( $start_time, 'Hi' ) ];
883
884 if ( ! empty( $this->xml_creation_errors ) ) {
885 $logger->critical( 'XML file generation failed. Errors: ' . implode( ', ', $this->xml_creation_errors ), $context );
886 }
887
888 $logger->info( sprintf( 'XML generation started for feed type: %s', $this->feed_type ), $context );
889 $logger->info( sprintf( 'XML creation start time: %s', get_date_from_gmt( $start_time, 'd-m-Y H:i:s' ) ), $context );
890 $logger->info( sprintf( 'XML generation duration: %d minutes and %s seconds', (int) floor( $xml_generation_duration / 60 ), round( $xml_generation_duration % 60 ) ), $context );
891 $logger->info( sprintf( 'Full script execution duration: %d minutes and %s seconds', (int) floor( $full_script_duration / 60 ), round( $full_script_duration % 60 ) ), $context );
892 $logger->info( sprintf( 'Peak memory usage: %s MB', round( memory_get_peak_usage( true ) / ( 1024 * 1024 ), 2 ) ), $context );
893 $logger->info( sprintf( 'Max memory limit: %s MB', round( $this->memory_limit / ( 1024 * 1024 ), 2 ) ), $context );
894
895 if ( 'full' === $log_level ) {
896 $logger->notice( 'Product nodes with errors:', $context );
897 $logger->notice( wc_print_r( $errors_in_readable_form, true ), $context );
898 }
899 }
900
901
902 /**
903 * Checks if currently used memory is close to max memory limit and in that case flushes the object cache.
904 * Except for object cache, it should be left free memory for PHP internal workings and also a memory size equal to the XML filesize for the file_put_contents() to succeed.
905 *
906 * After testing, it seems that 75% of RAM is a nice limit for cleaning the OB cache, for max memory limit larger than 512MB.
907 * Tested OK for 15k products with variations, with 75% limit on max memory size of 512MB.
908 *
909 * If the memory limit is very low like 256MB, then use 80% of memory as limit, but there is a limit here on the total exported products because of low memory.
910 * Tested OK for 5k products with variations, with 80% limit on max memory size of 256MB.
911 *
912 * @return void
913 */
914 private function maybe_flush_runtime_cache() {
915
916 // 80% if under 500MB, 75% if over 500MB
917 $limit_ram_usage_percent = $this->memory_limit < 524288000 ? 0.8 : 0.75;
918
919 $max_memory_to_use = min( $limit_ram_usage_percent * $this->memory_limit, $this->memory_limit - 52428800 ); // always leave a minimum of 50MB free
920
921 if ( memory_get_usage() > $max_memory_to_use ) {
922
923 $this->flush_runtime_cache();
924 }
925 }
926
927
928 /**
929 * Clears the runtime object cache to free memory.
930 * After each iteration, the object cache is increasing, which makes the used memory really huge for eshops with thousands of products.
931 *
932 * @return void
933 */
934 private function flush_runtime_cache() {
935
936 /*
937 * Calling wp_cache_flush_runtime() lets us clear the runtime cache without invalidating the external object
938 * cache, so we will always prefer this when it is available (works for WordPress v6.1+).
939 */
940 if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ) ) {
941 wp_cache_flush_runtime();
942 }
943 else {
944 wp_cache_flush();
945 }
946
947 $GLOBALS['wpdb']->flush();
948 }
949
950
951 /**
952 * Find the max memory limit for this PHP script.
953 *
954 * @return void
955 */
956 private function find_max_memory_limit() {
957
958 $memory_limit = ini_get( 'memory_limit' );
959
960 if ( empty( $memory_limit ) ) {
961 $memory_limit = '256M';
962 }
963 elseif ( $memory_limit == -1 ) {
964 $memory_limit = '1G';
965 }
966
967 $this->memory_limit = preg_replace_callback('/^\s*([\d.]+)\s*(?:([kmgt]?)b?)?\s*$/i', function($matches) {
968 switch ( strtolower( $matches[2] ) ) {
969 case 't': $matches[1] *= 1024;
970 case 'g': $matches[1] *= 1024;
971 case 'm': $matches[1] *= 1024;
972 case 'k': $matches[1] *= 1024;
973 }
974 return $matches[1];
975 }, $memory_limit );
976 }
977
978
979 /**
980 * Creates a mapping between unserialized and serialized slugs for WC attributes.
981 *
982 * @return void
983 */
984 private function setup_wc_attributes_list() {
985
986 global $wc_product_attributes;
987
988 foreach ( $wc_product_attributes as $wc_product_attribute_slug => $wc_product_attribute_obj ) {
989
990 if ( ! isset( $wc_product_attribute_obj->attribute_id ) || $wc_product_attribute_obj->attribute_id < 1 ) continue;
991
992 $this->wc_product_attributes_sanitized[ 'pa_' . sanitize_title( $wc_product_attribute_obj->attribute_name ) ] = $wc_product_attribute_slug;
993 }
994 }
995
996
997
998 /**
999 *********************************
1000 ***** PRODUCT QUERY FILTERS *****
1001 *********************************
1002 */
1003
1004 /**
1005 * Handle custom params in wc_get_products query.
1006 *
1007 * @param array $query Args for WP_Query.
1008 * @param array $query_vars Query vars from WC_Product_Query.
1009 *
1010 * @return array modified $query
1011 */
1012 function handle_skroutz_products_query_vars( array $query, array $query_vars ): array {
1013
1014 if ( ! empty( $query_vars['dicha_exclude_product_category_id'] ) ) {
1015 $query['tax_query'][] = [
1016 'taxonomy' => 'product_cat',
1017 'field' => 'term_id',
1018 'terms' => $query_vars['dicha_exclude_product_category_id'],
1019 'include_children' => true,
1020 'operator' => 'NOT IN',
1021 ];
1022 }
1023
1024 if ( ! empty( $query_vars['dicha_exclude_product_tag_id'] ) ) {
1025 $query['tax_query'][] = [
1026 'taxonomy' => 'product_tag',
1027 'field' => 'term_id',
1028 'terms' => $query_vars['dicha_exclude_product_tag_id'],
1029 'include_children' => true,
1030 'operator' => 'NOT IN',
1031 ];
1032 }
1033
1034 return $query;
1035 }
1036
1037
1038
1039 /**
1040 *****************************
1041 ***** EXPORT & SAVE XML *****
1042 *****************************
1043 */
1044
1045 /**
1046 * Saves the data for export into an XML file.
1047 *
1048 * @return void
1049 */
1050 private function saveXML() {
1051
1052 require_once( 'mod_simplexml.php' );
1053
1054 echo "#========================================================================#\n";
1055 echo "-> Saving Products XML...\n";
1056
1057 $dt = new DateTime( "now", new DateTimeZone( 'Europe/Athens' ) );
1058
1059 $data_for_export = [
1060 'created_at' => $dt->format( 'Y-m-d H:i:s' ),
1061 'products' => $this->products_for_export
1062 ];
1063
1064 // echo '<br><br><br><br><br>Export(' . count( $this->products_for_export ) . ' product nodes):<br><br>';
1065 // var_dump($data_for_export);
1066
1067 $this->decide_nodes_for_cdata();
1068
1069 // creating object of SimpleXMLElement
1070 $xml_data = new Dicha_SimpleXMLElement_Extension( '<?xml version="1.0" encoding="UTF-8"?><mywebstore></mywebstore>' );
1071
1072 // function call to convert array to xml
1073 $this->xmlProcess( $data_for_export, $xml_data );
1074
1075 $dom = new DOMDocument( "1.0" );
1076 $dom->preserveWhiteSpace = false;
1077 $dom->formatOutput = apply_filters( 'dicha_skroutz_feed_format_output', true, $this->feed_type );
1078 $dom->loadXML( $xml_data->asXML() );
1079
1080 // Include the required files for WP_Filesystem
1081 if ( ! function_exists( 'WP_Filesystem' ) ) {
1082 require_once( ABSPATH . 'wp-admin/includes/file.php' );
1083 }
1084
1085 // Initialize the WP_Filesystem
1086 global $wp_filesystem;
1087 WP_Filesystem();
1088
1089 $xml_location_path = Dicha_Skroutz_Feed_Admin::get_default_feed_filepath();
1090
1091 // create folder inside /uploads/ if not exist
1092 if ( ! $wp_filesystem->exists( $xml_location_path ) ) {
1093 $folder_creation_result = $wp_filesystem->mkdir( $xml_location_path, 0755 );
1094
1095 if ( ! $folder_creation_result ) {
1096 $this->xml_creation_errors[] = 'Could not create folder ' . $xml_location_path . ' inside /uploads/';
1097 return;
1098 }
1099 }
1100
1101 $feed_filename = Dicha_Skroutz_Feed_Admin::get_feed_filename( $this->feed_type ) . '.xml';
1102 $filename_with_path = $xml_location_path . $feed_filename;
1103
1104 // save XML
1105 $xml_datas = $dom->saveXML();
1106
1107 $file_creation_result = $wp_filesystem->put_contents( $filename_with_path, $xml_datas );
1108
1109 if ( ! $file_creation_result ) {
1110 $this->xml_creation_errors[] = 'Could not create the file: ' . $filename_with_path;
1111 return;
1112 }
1113
1114 // Zip XML
1115 if ( apply_filters( 'dicha_skroutz_feed_zip_xml', false, $this->feed_type ) && class_exists( 'ZipArchive' ) ) {
1116
1117 $zip = new ZipArchive();
1118 $success_open = $zip->open( $filename_with_path . '.zip', ZipArchive::CREATE );
1119
1120 if ( $success_open === true ) {
1121 $zip->addFile( $filename_with_path, $feed_filename );
1122 $zip->close();
1123 }
1124 }
1125
1126 echo "-> Saving: DONE!\n";
1127 echo "#========================================================================#\n";
1128
1129 $dt = new DateTime( "now", new DateTimeZone( 'Europe/Athens' ) );
1130 update_option( 'dicha_skroutz_feed_last_run', $dt->format( 'd/m/Y H:i:s' ), false );
1131 }
1132
1133
1134 /**
1135 * Recursive function to create the XML nodes.
1136 *
1137 * Skroutz validator rules (Jun 2024):
1138 * Manufacturer: Can be missing 100% *** can NOT be all empty *** can NOT be only one node *** can be some filled, some missing, some empty
1139 * Color: Can be missing 100% *** can NOT be all empty *** can NOT be only one node, at least 2 even if one of them empty *** can be some filled, some missing, some empty
1140 * Size: Can be missing 100% *** can be all empty *** can be only one node *** can be some filled, some missing, some empty
1141 * Ean: Can be missing 100% *** can NOT be all empty *** can NOT be only one node, at least 2 even if one of them empty *** can be some filled, some missing, some empty
1142 * Weight: Can be missing 100% *** can NOT be all empty *** can NOT be only one node, at least 2 even if one of them empty *** can be some filled, some missing, some empty
1143 *
1144 *
1145 * @param $data array
1146 * @param $xml_data Dicha_SimpleXMLElement_Extension | SimpleXMLElement
1147 * @param $parent_node string
1148 *
1149 * @return void
1150 */
1151 private function xmlProcess( array $data, &$xml_data, string $parent_node = 'root' ) {
1152
1153 foreach ( $data as $key => $value ) {
1154
1155 if ( 'products' === $parent_node ) {
1156 $node_name = 'product';
1157 }
1158 elseif ( 'variations' === $parent_node ) {
1159 $node_name = 'variation';
1160 }
1161 else {
1162 $node_name = (string) $key;
1163 }
1164
1165
1166 if ( is_array( $value ) ) {
1167
1168 if ( 'additional_imageurl' === $node_name ) {
1169
1170 // Exception: if additional images, then don't create sub-nodes, but add same-level nodes
1171 if ( in_array( $node_name, $this->fields_in_cdata ) ) {
1172 foreach ( $value as $additional_img_url ) {
1173 $xml_data->addChildCData( $node_name, (string) $additional_img_url );
1174 }
1175 }
1176 else {
1177 foreach ( $value as $additional_img_url ) {
1178 $xml_data->addChild( $node_name, (string) $additional_img_url );
1179 }
1180 }
1181 }
1182 else {
1183 $sub_node = $xml_data->addChild( $node_name );
1184 $this->xmlProcess( $value, $sub_node, $node_name );
1185 }
1186 }
1187 else {
1188
1189 // don't print empty nodes
1190 if ( $value === '' || $value === NULL ) continue;
1191
1192 if ( in_array( $node_name, $this->fields_in_cdata ) ) {
1193 $xml_data->addChildCData( $node_name, (string) $value );
1194 }
1195 else {
1196 $xml_data->addChild( $node_name, (string) $value );
1197 }
1198 }
1199 }
1200 }
1201
1202
1203 /**
1204 * Set which fields will be wrapped in CDATA.
1205 *
1206 * @return void
1207 */
1208 private function decide_nodes_for_cdata() {
1209
1210 $this->fields_in_cdata = apply_filters( 'dicha_skroutz_feed_fields_in_cdata', [
1211 'name',
1212 'link',
1213 'category',
1214 'description',
1215 'image',
1216 'additional_imageurl'
1217 ], $this->feed_type );
1218 }
1219
1220 }