_feed_data = $feed_data; $this->_feed_file_path = $feed_file_path; $this->_pre_data = $pre_data; $this->_channel_details = $channel_details; $this->_relation_table = $relation_table; if ( ! $this->_channel_details['channel_id'] ) { return false; } // instantiate the correct channel class $this->_channel_class = new WPPFM_Google_Feed_Class(); return $this->do_task( $item ); } /** * Handles the actions after completing a feed update task. */ public function complete() { if ( ! $this->ensure_feed_context_is_available() ) { $this->handle_missing_feed_context(); return; } do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, 'Started the complete function to clean up the feed process and queue.' ); // Successful completion cancels any deferred watchdog failure email for this feed. if ( function_exists( 'wppfm_cancel_deferred_feed_failure_notice' ) ) { wppfm_cancel_deferred_feed_failure_notice( (string) $this->_feed_data->feedId ); } // Flush any remaining buffer before completing if ( method_exists( $this, 'flush_file_buffer' ) ) { $this->flush_file_buffer(); } // Remove the properties from the option table. $this->cleanup_background_process_options(); $feed_status = '0' !== $this->_feed_data->status && '3' !== $this->_feed_data->status && '4' !== $this->_feed_data->status ? $this->_feed_data->status : $this->_feed_data->baseStatusId; $feed_title = $this->_feed_data->title . '.' . pathinfo( $this->_feed_file_path, PATHINFO_EXTENSION ); $total_handled_products = get_transient( 'wppfm_nr_of_processed_products' ); $total_handled_products = false === $total_handled_products ? 0 : intval( $total_handled_products ); $this->register_feed_update( $this->_feed_data->feedId, $feed_title, $total_handled_products, $feed_status ); $this->clear_the_queue(); // Now the feed is ready to go, remove the feed id from the feed queue. WPPFM_Feed_Controller::remove_id_from_feed_queue( $this->_feed_data->feedId ); WPPFM_Feed_Controller::set_feed_processing_flag(); $message = sprintf( 'Completed feed %s. The feed should contain %d products and its status has been set to %s.', $this->_feed_data->feedId, $total_handled_products, $feed_status ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message ); do_action( 'wppfm_register_feed_url', $this->_feed_data->feedId, $this->_feed_data->url ); // Hook for performance monitoring - feed generation complete do_action( 'wppfm_feed_generation_complete', $this->_feed_data->feedId ); // Clean up preserved feed context transient now that completion succeeded. delete_transient( 'wppfm_feed_completion_context_' . $this->_feed_data->feedId ); delete_transient( 'wppfm_client_request_id_' . $this->_feed_data->feedId ); delete_transient( 'wppfm_nr_of_products_to_process_' . $this->_feed_data->feedId ); if ( ! WPPFM_Feed_Controller::feed_queue_is_empty() ) { do_action( 'wppfm_next_in_queue_feed_update_activated', $this->_feed_data->feedId ); // So there is another feed in the queue. $feed_master_class = new WPPFM_Feed_Master_Class( WPPFM_Feed_Controller::get_next_id_from_feed_queue() ); $feed_master_class->initiate_update_next_feed_in_queue(); } } /** * Selects the required action. * * @param array $task_data the work value, usually a product id, but it can also be an XML header line. * * @return boolean true if the action is started. */ private function do_task( $task_data ) { if ( array_key_exists( 'product_id', $task_data ) ) { return $this->add_product_to_feed( $task_data['product_id'] ); } elseif ( array_key_exists( 'file_format_line', $task_data ) ) { // the WordFence plugin sometimes identifies the string as a XSS vulnerability and blocks wp_remote_post action starting the feed process // To counter that, I changed the string in the (google) xml feed header to (in the Google channels class-feed.php file) // and now I need to change that back to again. $task_data['file_format_line'] = str_replace( '', '', $task_data['file_format_line'] ); return $this->add_file_format_line_to_feed( $task_data ); } elseif ( array_key_exists( 'error_message', $task_data ) ) { return $this->add_error_message_to_feed( $task_data ); } else { return false; } } /** * Ads a single product based on a product id to the feed file. * * @param string $product_id the id of the product to be added to the feed. * * @since 2.37.0. Changed the return value for Grouped products to exclude them from being counted as processed products. * @return boolean true if the product has been added correctly to the feed. */ private function add_product_to_feed( $product_id ) { if ( ! $product_id ) { $message = 'Add product to feed process started without product id'; do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; } $wc_product = wc_get_product( $product_id ); if ( false === $wc_product ) { $message = sprintf( 'Failed to get the WooCommerce product data from product with id %s.', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; } if ( $wc_product instanceof WC_Product_Grouped ) { $message = sprintf( 'The product with id %s is a grouped product and has been skipped.', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; // Skip grouped products. } do_action( 'wppfm_started_product_processing', $this->_feed_data->feedId, $product_id ); $class_data = new WPPFM_Data(); $product_placeholder = array(); $post_columns_query_string = $this->_pre_data['database_fields']['post_column_string'] ? substr( $this->_pre_data['database_fields']['post_column_string'], 0, - 2 ) : ''; $product_parent_id = $product_id; // Keep the Parent Id equal to the Product Id for non-variation products $product_data = (array) $this->get_products_main_data( $product_id, $wc_product->get_parent_id(), $post_columns_query_string, $wc_product ); /** * Users can use the wppfm_leave_links_in_descriptions filter if they want to keep links in the product descriptions by changing the * filter output to true. They can also target specific feeds. * * @since 2.6.0 */ if ( ! apply_filters( 'wppfm_leave_links_in_descriptions', false, $this->_feed_data->feedId ) ) { $this->remove_links_from_product_data_description( $product_data ); } if ( ( $wc_product instanceof WC_Product_Variation && $this->_pre_data['include_vars'] ) || ( $wc_product instanceof WC_Product_Variable ) && $this->_pre_data['include_vars'] ) { $product_parent_id = $wc_product->get_parent_id(); // Add parent data when this item is not available in the variation. if ( $post_columns_query_string ) { $class_data->add_parent_data( $product_data, $product_parent_id, $post_columns_query_string, $this->_feed_data->language ); } $translated_variation_id = $product_id; $variation_product = $wc_product; if ( $wc_product instanceof WC_Product_Variation && has_filter( 'wpml_object_id' ) ) { // Use the translated variation object so variation-specific fields do not fall back to the original-language ID or description. $wpml_variation_id = apply_filters( 'wpml_object_id', $product_id, 'product_variation', true, $this->_feed_data->language ); if ( $wpml_variation_id && (int) $wpml_variation_id !== (int) $product_id ) { $translated_wc_product = wc_get_product( $wpml_variation_id ); if ( $translated_wc_product instanceof WC_Product_Variation ) { $translated_variation_id = (int) $wpml_variation_id; $variation_product = $translated_wc_product; } } } $wpmr_variation_data = $class_data->get_own_variation_data( $translated_variation_id ); // Get the correct variation data. WPPFM_Variations::fill_product_data_with_variation_data( $product_data, $variation_product, $wpmr_variation_data, $this->_feed_data->language, $this->_feed_data->currency ); } $row_category = $this->get_mapped_category( $product_parent_id, $this->_feed_data->mainCategory, json_decode( $this->_feed_data->categoryMapping ) ); if ( ! $row_category ) { $message = sprintf( 'Could not identify the correct category map for product %s', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; } $row_filtered = $this->is_product_filtered( $this->_pre_data['filters'], $product_data ); // Only process the product if it is not filtered out. if ( ! $row_filtered ) { // For each row loop through each active field. foreach ( $this->_pre_data['active_fields'] as $field ) { $attribute_meta_data = $this->get_meta_data_from_specific_attribute( $field, $this->_feed_data->attributes ); // Get the field data based on the user settings. $feed_object = $this->process_product_field( $product_data, $attribute_meta_data, $this->_channel_details['category_name'], $row_category, $this->_feed_data->language, $this->_feed_data->currency, $this->_relation_table ); $key = key( $feed_object ); // For an XML file only add fields that contain data. if ( ( ! empty( $feed_object[ $key ] ) || '0' === $feed_object[ $key ] ) || 'xml' !== pathinfo( $this->_feed_file_path, PATHINFO_EXTENSION ) ) { // Keep money values that have a 0 value out of the feed. @since 2.11.2. // Modified @since 2.24.0, so it first converts money values to a standard decimal separator that it evaluates correctly in the floatval() evaluation. if ( wppfm_meta_key_is_money( $key ) ) { $money_value = wppfm_number_format_parse( $feed_object[ $key ] ); if ( 0.0 === floatval( $money_value ) ) { continue; } } // Catch the DraftImages key for the Ricardo.ch channel. if ( 'DraftImages' !== $key ) { $product_placeholder[ $key ] = $feed_object[ $key ]; } else { $support_class = new WPPFM_Feed_Support(); $support_class->process_ricardo_draft_images( $product_placeholder, $feed_object[ $key ] ); } // @since 3.8.0. // The Google feed specifications allow the material and color attributes to have a maximum of three items, displayed with a / separated string // Here the material and color features are converted to the prescribed format if ( ( 'material' === $key || 'color' === $key ) && false !== strpos( $product_placeholder[ $key ], ',' ) && '1' === $this->_channel_details['channel_id'] ) { $product_features = explode( ',', $product_placeholder[ $key ] ); foreach( $product_features as $i => $product_feature ) { $product_features[ $i ] = trim( $product_feature ); } $main_materials = array_slice( $product_features, 0, 3 ); $product_placeholder[ $key ] = implode( '/', $main_materials ); } // @since 3.21.0. // The Facebook feed specifications allow the internal_label attribute that contains comma separated labels to be wrapped in square brackets. if ( 'internal_label' === $key ) { $raw_value = (string) $product_placeholder[ $key ]; $labels = array_map( 'trim', explode( ',', $raw_value ) ); $labels = array_filter( $labels ); $quoted_labels = array_map( function ( $label ) { return "'" . $label . "'"; }, $labels ); $product_placeholder[ $key ] = implode( ',', $quoted_labels ); } } } } else { $message = sprintf( 'Product %s is filtered out', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message ); return 'filtered'; } if ( $product_placeholder ) { // The wppfm_feed_item_value filter allows users to modify the data that goes into the feed. The $data variable contains an array // with all the data that goes into the feed, with the item name as a key. $product_placeholder = apply_filters( 'wppfm_feed_item_value', $product_placeholder, $this->_feed_data->feedId, $product_id ); return $this->write_product_object( $product_placeholder, $this->_feed_data->feedId, $product_id ); } else { $message = sprintf( 'Product %s has no data to write to the feed', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; } } /** * Appends a processed product to the feed. * Uses buffering to reduce file I/O operations. * * @param array $product_placeholder an array with the product data to be written to the feed file. * @param string $feed_id the id of the feed. * @param string $product_id the id of the product. * * @return string product added or boolean false. * @since 3.15.0 - Updated to use buffering for better performance. */ private function write_product_object( $product_placeholder, $feed_id, $product_id ) { $product_text = $this->generate_feed_text( $product_placeholder ); // Buffer the output instead of writing immediately return $this->buffer_product_output( $product_text, $feed_id, $product_id ); } /** * Buffer product output for batch writing. * * @param string $product_text The generated product text. * @param string $feed_id Feed ID. * @param string $product_id Product ID. * * @return string 'product added' on success, false on failure. * @since 3.15.0 */ private function buffer_product_output( $product_text, $feed_id, $product_id ) { // Initialize buffer if not already initialized if ( ! isset( $this->file_write_buffer ) ) { $this->file_write_buffer = array(); } // Get buffer size from filter (allows customization) $this->file_buffer_size = apply_filters( 'wppfm_file_buffer_size', 50 ); // Add to buffer $this->file_write_buffer[] = $product_text; // Flush if buffer is full if ( count( $this->file_write_buffer ) >= $this->file_buffer_size ) { if ( ! $this->flush_file_buffer() ) { wppfm_write_log_file( sprintf( 'Could not flush buffer for product %s to the feed', $product_id ) ); $message = sprintf( 'Could not flush buffer for product %s to the feed', $product_id ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, $message, 'ERROR' ); return false; } } // @since 2.3.0 do_action( 'wppfm_add_product_to_feed', $feed_id, $product_id ); return 'product added'; } /** * Flush buffered content to file. * Uses the improved wppfm_append_line_to_file() function with file locking. * Made protected so it can be called from parent class methods. * For XML files, ensures each item is on its own line. * * @return bool Success status. * @since 3.15.0 */ protected function flush_file_buffer() { if ( empty( $this->file_write_buffer ) ) { return true; } // Determine file type to handle line breaks appropriately $file_extension = pathinfo( $this->_feed_file_path, PATHINFO_EXTENSION ); // For XML files, join items with newlines to ensure each item is on its own line // For other file types (CSV, TXT, TSV), join without separator as they handle formatting internally if ( 'xml' === $file_extension ) { // Join with PHP_EOL to ensure proper line breaks between XML items $combined_text = implode( PHP_EOL, $this->file_write_buffer ); } else { // For non-XML files, join without separator (they may have their own formatting) $combined_text = implode( '', $this->file_write_buffer ); } // Write using the improved append function (with file locking) // For XML, we add PHP_EOL at the end to ensure the last item ends with a newline // For other formats, we don't add extra newline as they handle it themselves $add_newline = ( 'xml' === $file_extension ); if ( false === wppfm_append_line_to_file( $this->_feed_file_path, $combined_text, $add_newline ) ) { wppfm_write_log_file( 'Failed to flush file buffer to feed file' ); do_action( 'wppfm_feed_generation_message', $this->_feed_data->feedId, 'Failed to flush file buffer', 'ERROR' ); return false; } // Clear buffer after successful write $this->file_write_buffer = array(); return true; } /** * Convert the feed data of a single product into XML or csv text depending on the channel. * * @param array $product_placeholder contains the product data. * * @return string */ private function generate_feed_text( $product_placeholder ) { switch ( pathinfo( $this->_feed_file_path, PATHINFO_EXTENSION ) ) { case 'xml': return $this->convert_data_to_xml( $product_placeholder, $this->_channel_details['category_name'], $this->_channel_details['description_name'], $this->_channel_details['channel_id'] ); case 'txt': $txt_sep = apply_filters( 'wppfm_txt_separator', wppfm_get_correct_txt_separator( $this->_channel_details['channel_id'] ) ); return $this->convert_data_to_txt( $product_placeholder, $txt_sep ); case 'csv': $csv_sep = apply_filters( 'wppfm_csv_separator', wppfm_get_correct_csv_separator( $this->_channel_details['channel_id'] ) ); return $this->convert_data_to_csv( $product_placeholder, $this->_pre_data['active_fields'], $csv_sep ); case 'tsv': return $this->convert_data_to_tsv( $product_placeholder ); } return ''; } /** * Ensures the feed context is available before completing the process. * * @return bool */ private function ensure_feed_context_is_available() { if ( $this->_feed_data && property_exists( $this->_feed_data, 'feedId' ) ) { return true; } list( , $batch_metadata ) = $this->get_current_batch_metadata(); if ( $batch_metadata ) { if ( empty( $this->_feed_data ) && isset( $batch_metadata['feed_data'] ) ) { $this->_feed_data = $batch_metadata['feed_data']; } if ( empty( $this->_feed_file_path ) && ! empty( $batch_metadata['file_path'] ) ) { $this->_feed_file_path = $batch_metadata['file_path']; } } return $this->_feed_data && property_exists( $this->_feed_data, 'feedId' ); } /** * Handles the situation where the feed context can no longer be restored. */ private function handle_missing_feed_context() { $feed_id = $this->resolve_active_feed_id(); $log_id = $feed_id ? $feed_id : 'unknown'; do_action( 'wppfm_feed_generation_message', $log_id, 'Feed completion aborted because the feed metadata could not be restored.', 'ERROR' ); // Clean up any preserved context to avoid stale data. if ( $feed_id ) { delete_transient( 'wppfm_feed_completion_context_' . $feed_id ); } if ( $feed_id ) { $data_class = new WPPFM_Data(); $data_class->update_feed_status( $feed_id, 6 ); WPPFM_Feed_Controller::remove_id_from_feed_queue( $feed_id ); // Terminal failure while metadata could not be restored (automatic runs only). if ( get_transient( 'wppfm_running_silent' ) && class_exists( 'WPPFM_Email' ) ) { if ( function_exists( 'wppfm_cancel_deferred_feed_failure_notice' ) ) { wppfm_cancel_deferred_feed_failure_notice( (string) $feed_id ); } WPPFM_Email::send_feed_failed_message( (string) $feed_id, array( 'detected_at' => time(), 'source' => 'missing_feed_context', ) ); } } $this->clear_the_queue(); $this->cleanup_background_process_options(); WPPFM_Feed_Controller::set_feed_processing_flag(); } /** * Preserves feed context before batch deletion so complete() can access it. * * @param string $feed_id Feed ID. * @param string $properties_key Batch properties key. */ protected function preserve_feed_context_for_completion( $feed_id, $properties_key ) { if ( ! $properties_key ) { do_action( 'wppfm_feed_generation_message', $feed_id, 'Warning: No properties key available to preserve feed context for completion', 'WARNING' ); return; } set_transient( 'wppfm_feed_completion_context_' . $feed_id, $properties_key, 15 * MINUTE_IN_SECONDS ); do_action( 'wppfm_feed_generation_message', $feed_id, sprintf( 'Preserved feed context for completion (properties key: %s)', $properties_key ) ); } /** * Ensures feed context is available before calling complete(). * * @param string $feed_id Feed ID. */ protected function ensure_feed_context_before_completion( $feed_id ) { // If feed context is already available, nothing to do. if ( $this->_feed_data && property_exists( $this->_feed_data, 'feedId' ) ) { do_action( 'wppfm_feed_generation_message', $feed_id, 'Feed context already available, no restoration needed' ); return; } $properties_key = get_transient( 'wppfm_feed_completion_context_' . $feed_id ); if ( ! $properties_key ) { do_action( 'wppfm_feed_generation_message', $feed_id, 'Warning: No preserved feed context found for completion', 'WARNING' ); return; } $batch_metadata = get_site_option( 'wppfm_batch_metadata_' . $properties_key ); if ( $batch_metadata && is_array( $batch_metadata ) ) { if ( empty( $this->_feed_data ) && isset( $batch_metadata['feed_data'] ) ) { $this->_feed_data = $batch_metadata['feed_data']; } if ( empty( $this->_feed_file_path ) && ! empty( $batch_metadata['file_path'] ) ) { $this->_feed_file_path = $batch_metadata['file_path']; } if ( empty( $this->_pre_data ) && ! empty( $batch_metadata['pre_data'] ) ) { $this->_pre_data = $batch_metadata['pre_data']; } if ( empty( $this->_channel_details ) && ! empty( $batch_metadata['channel_details'] ) ) { $this->_channel_details = $batch_metadata['channel_details']; } if ( empty( $this->_relations_table ) && ! empty( $batch_metadata['relations_table'] ) ) { $this->_relations_table = $batch_metadata['relations_table']; } do_action( 'wppfm_feed_generation_message', $feed_id, 'Restored feed context from preserved metadata: feed_data, file_path, pre_data, channel_details, relations_table' ); } else { do_action( 'wppfm_feed_generation_message', $feed_id, 'Warning: Could not load batch metadata for preserved context', 'WARNING' ); } } /** * Removes the stored batch metadata from the options table. */ private function cleanup_background_process_options() { list( $properties_key ) = $this->get_current_batch_metadata(); delete_site_option( 'wppfm_background_process_key' ); if ( $properties_key ) { delete_site_option( 'wppfm_batch_metadata_' . $properties_key ); delete_site_option( $properties_key ); } } /** * Resolves the active feed id from the available context. * * @return string|null */ private function resolve_active_feed_id() { if ( $this->_feed_data && property_exists( $this->_feed_data, 'feedId' ) ) { return $this->_feed_data->feedId; } list( , $batch_metadata ) = $this->get_current_batch_metadata(); if ( $batch_metadata && ! empty( $batch_metadata['feed_id'] ) ) { return $batch_metadata['feed_id']; } $request_feed_id = filter_input( INPUT_GET, 'feed_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); return $request_feed_id ? $request_feed_id : null; } /** * Returns the current batch metadata and key stored in the options table. * * @return array */ private function get_current_batch_metadata() { $properties_key = get_site_option( 'wppfm_background_process_key' ); if ( ! $properties_key ) { return array( null, null ); } $batch_metadata = get_site_option( 'wppfm_batch_metadata_' . $properties_key ); if ( ! is_array( $batch_metadata ) ) { $batch_metadata = null; } return array( $properties_key, $batch_metadata ); } } endif;