559 lines
17 KiB
PHP
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
|
|
);
|
|
}
|
|
}
|
|
}
|