Files
2026-04-28 15:13:50 +02:00

559 lines
17 KiB
PHP

<?php
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*
* @package MetaCommerce
*/
namespace WooCommerce\Facebook\Feed\Localization;
defined( 'ABSPATH' ) || exit;
use Exception;
use WooCommerce\Facebook\Framework\Helper;
use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException;
use WooCommerce\Facebook\Framework\Logger;
use WooCommerce\Facebook\Utilities\Heartbeat;
use WooCommerce\Facebook\Feed\AbstractFeed;
use WooCommerce\Facebook\Integrations\IntegrationRegistry;
/**
* Language Override Feed handler.
*
* Specialized functionality for language override feeds.
*
* @since 3.6.0
*/
class LanguageOverrideFeed {
use LanguageFeedManagementTrait;
/** @var string the feed name for creating a new feed by this plugin */
const FEED_NAME_TEMPLATE = 'WooCommerce Language Override Feed (%s)';
/** @var \WooCommerce\Facebook\Feed\Localization\LanguageFeedData|null Lazy-loaded data handler */
private $language_feed_data = null;
/** Action constants */
const GENERATE_FEED_ACTION = 'wc_facebook_regenerate_feed_';
const REQUEST_FEED_ACTION = 'wc_facebook_get_feed_data_language_override';
const FEED_GEN_COMPLETE_ACTION = 'wc_facebook_feed_generation_completed_';
const LEGACY_API_PREFIX = 'woocommerce_api_';
const OPTION_FEED_URL_SECRET = 'wc_facebook_feed_url_secret_';
/**
* Constructor
*
* Follows the same pattern as Products\Feed - only registers hooks in constructor.
* Data objects are instantiated on-demand when actually needed.
*
* @since 3.6.0
*/
public function __construct() {
$this->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
);
}
}
}