add_hooks(); } /** * Get the language feed data handler (lazy-loaded). * * Only instantiates LanguageFeedData when it's actually needed, * following the same pattern as Products\Feed. * * @return LanguageFeedData * @since 3.6.0 */ private function get_language_feed_data(): LanguageFeedData { if ( null === $this->language_feed_data ) { $this->language_feed_data = new LanguageFeedData(); } return $this->language_feed_data; } /** * Schedules the recurring feed generation. * * @since 3.6.0 */ public function schedule_feed_generation(): void { $flag_name = '_wc_facebook_language_override_schedule_feed_generation'; if ( 'yes' === get_transient( $flag_name ) ) { return; } set_transient( $flag_name, 'yes', HOUR_IN_SECONDS ); $integration = facebook_for_woocommerce()->get_integration(); $connection_handler = facebook_for_woocommerce()->get_connection_handler(); // Language feeds only require an active connection, not a Facebook Page ID $is_connected = $connection_handler && $connection_handler->is_connected(); // Only schedule feed job if store has not opted out of product sync. $store_allows_sync = ( $is_connected && $integration->is_product_sync_enabled() ) || $integration->is_woo_all_products_enabled(); // Only schedule if has not opted out of language override feed generation. $store_allows_language_feeds = $is_connected && $this->is_language_override_feed_generation_enabled(); $schedule_action_hook_name = self::GENERATE_FEED_ACTION . static::get_data_stream_name(); if ( ! $store_allows_sync || ! $store_allows_language_feeds || $this->should_skip_feed() ) { as_unschedule_all_actions( $schedule_action_hook_name ); $message = ''; if ( ! $is_connected ) { $message = 'Integration not configured.'; } elseif ( ! $store_allows_language_feeds ) { $message = 'Store does not allow language override feeds.'; } elseif ( ! $store_allows_sync ) { $message = 'Store does not allow sync.'; } elseif ( $this->should_skip_feed() ) { $message = 'Feed should be skipped.'; } Logger::log( sprintf( 'Language override feed scheduling failed: %s', $message ), array( 'flow_name' => 'language_override_feed', 'flow_step' => 'schedule_feed_generation', ), array( 'should_send_log_to_meta' => true, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::WARNING, ) ); return; } // Prevent double registration by checking for existing scheduled actions if ( ! as_next_scheduled_action( $schedule_action_hook_name ) ) { as_schedule_recurring_action( time(), static::get_feed_gen_interval(), $schedule_action_hook_name, array(), facebook_for_woocommerce()->get_id_dasherized() ); } } /** * Unschedules the recurring feed generation. * * @since 3.6.0 */ public function unschedule_feed_generation(): void { $schedule_action_hook_name = self::GENERATE_FEED_ACTION . static::get_data_stream_name(); // Unschedule all actions for this feed type as_unschedule_all_actions( $schedule_action_hook_name ); } /** * Regenerates language override feeds (required for AJAX and scheduled actions). * This method is called by the WordPress action scheduler and AJAX handlers. * It regenerates all language feeds and triggers the upload hook. * * @since 3.6.0 */ public function regenerate_feed(): void { // Call the main regeneration method $this->regenerate_all_language_feeds(); /** * Fires after language override feed generation is completed. * * @since 3.6.0 */ do_action( self::FEED_GEN_COMPLETE_ACTION . static::get_data_stream_name() ); } /** * Regenerates language override feeds for all available languages. * Uses the feed handler directly instead of the feed generator to create * multiple language files in a single action. * * @since 3.6.0 */ public function regenerate_all_language_feeds(): void { if ( $this->should_skip_feed() ) { return; } // Get all available languages $languages = $this->get_language_feed_data()->get_available_languages(); if ( empty( $languages ) ) { return; } $successful_languages = []; $failed_languages = []; $language_stats = []; // Generate feed file for each language using the feed handler directly foreach ( $languages as $language_code ) { try { // Generate the feed file for this language $language_feed_writer = new LanguageOverrideFeedWriter( $language_code ); $result = $language_feed_writer->write_language_feed_file( $this->get_language_feed_data(), $language_code ); if ( $result['success'] ) { $successful_languages[] = $language_code; $language_stats[ $language_code ] = [ 'translated_products' => $result['count'], 'last_generated' => time(), ]; } else { $failed_languages[] = $language_code; Logger::log( sprintf( 'Failed to generate language override feed for: %s', $language_code ), [ 'language_code' => $language_code ], array( 'should_send_log_to_meta' => true, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::ERROR, ) ); } } catch ( \Exception $e ) { $failed_languages[] = $language_code; Logger::log( sprintf( 'Exception while generating language override feed for %s: %s', $language_code, $e->getMessage() ), [ 'language_code' => $language_code, 'exception_message' => $e->getMessage(), ], array( 'should_send_log_to_meta' => true, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::ERROR, ), $e ); } } // Log completion if ( ! empty( $failed_languages ) ) { Logger::log( sprintf( 'Language override feeds generated with failures. Success: %d, Failed: %d (%s)', count( $successful_languages ), count( $failed_languages ), implode( ', ', $failed_languages ) ), [ 'failed_languages' => $failed_languages, 'total_languages' => count( $languages ), ], array( 'should_send_log_to_meta' => false, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::WARNING, ) ); } // Send language feed statistics directly to Meta via commerce_3p_platform_event if ( ! empty( $language_stats ) ) { // Cache the stats in a transient for the hourly telemetry to use set_transient( \WooCommerce\Facebook\ExternalVersionUpdate\Update::TRANSIENT_LANGUAGE_FEED_STATS, $language_stats, \WooCommerce\Facebook\ExternalVersionUpdate\Update::TRANSIENT_LANGUAGE_FEED_STATS_LIFETIME ); } // Trigger the upload hook if any languages were successful if ( ! empty( $successful_languages ) ) { do_action( self::FEED_GEN_COMPLETE_ACTION . static::get_data_stream_name() ); } } /** * Get the Heartbeat interval to ensure that feed gen is scheduled. Must be shorter than the feed gen interval. * * @return string Heartbeat constant value */ protected static function get_feed_gen_scheduling_interval(): string { return Heartbeat::HOURLY; } /** * Override add_hooks to use the correct REQUEST_FEED_ACTION constant. * This ensures the WooCommerce API hook is registered with the proper action name. * * @since 3.6.0 */ protected function add_hooks(): void { add_action( static::get_feed_gen_scheduling_interval(), array( $this, 'schedule_feed_generation' ) ); add_action( self::GENERATE_FEED_ACTION . static::get_data_stream_name(), array( $this, 'regenerate_all_language_feeds' ) ); add_action( self::FEED_GEN_COMPLETE_ACTION . static::get_data_stream_name(), array( $this, 'upload_language_override_feeds' ) ); add_action( self::LEGACY_API_PREFIX . static::REQUEST_FEED_ACTION, array( $this, 'handle_feed_data_request', ) ); } /** * Gets the feed secret used for feed requests. * Reuses the existing Feed class's secret for consistency. * * @return string */ protected function get_feed_secret(): string { return \WooCommerce\Facebook\Products\Feed::get_feed_secret(); } /** * Checks if language override feed generation is enabled in the admin settings. * * @return bool * @since 3.6.0 */ private function is_language_override_feed_generation_enabled(): bool { $integration = facebook_for_woocommerce()->get_integration(); return $integration && $integration->is_language_override_feed_generation_enabled(); } /** * Get the data feed type for language override feeds. * * @return string */ protected static function get_feed_type(): string { return 'LANGUAGE_OVERRIDE'; } /** * Get the data stream name for language override feeds. * * @return string */ protected static function get_data_stream_name(): string { return 'language_override'; } /** * Override the feed generation interval to match product feeds frequency. * * @return int */ protected static function get_feed_gen_interval(): int { /** * Filters the frequency with which the language override feed data is generated. * * @since 3.6.0 * * @param int $interval the frequency with which the language override feed data is generated, in seconds. */ return apply_filters( 'wc_facebook_language_override_feed_generation_interval', DAY_IN_SECONDS ); } /** * Check if feed generation should be skipped. * * @return bool */ public function should_skip_feed(): bool { // Check if language override feed generation is enabled if ( ! $this->is_language_override_feed_generation_enabled() ) { return true; } $connection_handler = facebook_for_woocommerce()->get_connection_handler(); // Check connection methods $has_valid_connection = ! empty( $connection_handler->get_commerce_partner_integration_id() ) || ! empty( $connection_handler->get_commerce_merchant_settings_id() ) || ! empty( $connection_handler->get_access_token() ); if ( ! $has_valid_connection ) { return true; } // Check localization plugin if ( ! IntegrationRegistry::has_active_localization_plugin() ) { return true; } return false; } /** * Override handle_feed_data_request to add language parameter handling. * This mirrors Feed.php's handle_feed_data_request but adds language support. * * @throws PluginException If the feed secret is invalid, file is not readable, or other errors occur. */ public function handle_feed_data_request(): void { try { // Get the language code from the request $language_code = Helper::get_requested_value( 'language' ); if ( empty( $language_code ) ) { throw new PluginException( 'Language code is required', 400 ); } // Validate the feed secret if ( $this->get_feed_secret() !== Helper::get_requested_value( 'secret' ) ) { throw new PluginException( 'Invalid feed secret provided', 401 ); } // Create language-specific feed writer to get file path $language_feed_writer = new LanguageOverrideFeedWriter( $language_code ); $file_path = $language_feed_writer->get_file_path(); // Regenerate if the file doesn't exist or if explicitly requested $regenerate = Helper::get_requested_value( 'regenerate' ); if ( ! empty( $regenerate ) || ! file_exists( $file_path ) ) { $success = $language_feed_writer->write_language_feed_file( $this->language_feed_data, $language_code ); if ( ! $success ) { throw new PluginException( 'Failed to regenerate language feed file', 500 ); } } // Check if the file can be read if ( ! is_readable( $file_path ) ) { throw new PluginException( 'Language feed file is not readable', 404 ); } // Set the download headers header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Description: File Transfer' ); header( 'Content-Disposition: attachment; filename="' . basename( $file_path ) . '"' ); header( 'Expires: 0' ); header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' ); header( 'Pragma: public' ); header( 'Content-Length:' . filesize( $file_path ) ); $file = @fopen( $file_path, 'rb' ); if ( ! $file ) { throw new PluginException( 'Could not open language feed file', 500 ); } // fpassthru might be disabled in some hosts (like Flywheel) if ( \WC_Facebookcommerce_Utils::is_fpassthru_disabled() || ! @fpassthru( $file ) ) { $contents = @stream_get_contents( $file ); if ( ! $contents ) { throw new PluginException( 'Could not get language feed file contents', 500 ); } echo $contents; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } @fclose( $file ); } catch ( \Exception $exception ) { Logger::log( 'Could not serve language override feed. ' . $exception->getMessage() . ' (' . $exception->getCode() . ')', [], array( 'should_send_log_to_meta' => false, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::ERROR, ) ); status_header( $exception->getCode() ? $exception->getCode() : 500 ); } exit; } /** * Override get_feed_data_url to add language parameter. * This mirrors Feed.php's get_feed_data_url but adds language support. * * @param string $language_code Language code * @return string */ public function get_language_feed_url( string $language_code ): string { $query_args = array( 'wc-api' => static::REQUEST_FEED_ACTION, 'language' => $language_code, 'secret' => $this->get_feed_secret(), ); return add_query_arg( $query_args, home_url( '/' ) ); } /** * Upload language override feeds to Facebook for all available languages. * This mirrors Feed.php's send_request_to_upload_feed but handles multiple languages. * * @since 3.6.0 */ public function upload_language_override_feeds() { if ( ! IntegrationRegistry::has_active_localization_plugin() ) { return; } $languages = $this->get_language_feed_data()->get_available_languages(); foreach ( $languages as $language_code ) { $this->upload_single_language_feed( $language_code ); } } /** * Upload a single language override feed to Facebook. * This mirrors Feed.php's send_request_to_upload_feed but for a specific language. * Only uploads if the feed file exists and has actual product data. * * @param string $language_code Language code (e.g., 'es_ES', 'fr_FR') * @throws \Exception If feed creation/retrieval fails or API upload fails. * @since 3.6.0 */ private function upload_single_language_feed( string $language_code ) { try { // Check if feed file exists and has data before attempting upload $language_feed_writer = new LanguageOverrideFeedWriter( $language_code ); $file_path = $language_feed_writer->get_file_path(); // Skip upload if file doesn't exist if ( ! file_exists( $file_path ) ) { return; } // Step 1: Create or get the language override feed configuration using trait method $feed_id = $this->retrieve_or_create_language_feed_id( $language_code ); if ( empty( $feed_id ) ) { throw new \Exception( 'Could not create or retrieve language override feed ID' ); } // Step 2: Tell Facebook to fetch the CSV data from our endpoint (feed files are already generated) $data = [ 'url' => $this->get_language_feed_url( $language_code ), ]; facebook_for_woocommerce()->get_api()->create_product_feed_upload( $feed_id, $data ); // Successful upload - no log needed (matches main feed behavior) } catch ( \Exception $exception ) { Logger::log( 'Language override feed upload failed: ' . $exception->getMessage(), array( 'language_code' => $language_code, ), array( 'should_send_log_to_meta' => false, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::ERROR, ), $exception ); } } }