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

503 lines
16 KiB
PHP

<?php
declare( strict_types=1 );
namespace WooCommerce\Facebook\Feed\Localization;
use WooCommerce\Facebook\Integrations\IntegrationRegistry;
use WooCommerce\Facebook\Framework\Logger;
/**
* Language Feed Data Handler for Facebook Language Override Feeds
*
* Handles translation data extraction from localization plugins and CSV generation
* for Facebook language override feeds. Consolidates both data extraction and formatting.
*
* @since 3.6.0
*/
class LanguageFeedData {
// ===========================================
// TRANSLATION DATA EXTRACTION METHODS
// ====================================
/**
* Get all available languages from active localization plugins
*
* @return array Array of language codes
*/
public function get_available_languages(): array {
$all_languages = [];
$integrations = IntegrationRegistry::get_all_localization_integrations();
foreach ( $integrations as $integration ) {
if ( $integration->is_plugin_active() ) {
$languages = $integration->get_available_languages();
$all_languages = array_merge( $all_languages, $languages );
}
}
// Remove duplicates and default language
$all_languages = array_unique( $all_languages );
$default_language = $this->get_default_language();
if ( $default_language ) {
$all_languages = array_filter(
$all_languages,
function ( $lang ) use ( $default_language ) {
return $lang !== $default_language;
}
);
}
return array_values( $all_languages );
}
/**
* Get the default language from the first active localization plugin
*
* @return string|null Default language code or null if not available
*/
public function get_default_language(): ?string {
$integration = IntegrationRegistry::get_active_localization_integration();
if ( $integration ) {
return $integration->get_default_language();
}
return null;
}
/**
* Get products from the default language
*
* Uses the first active localization plugin to get products from the default language.
*
* @param int $limit Maximum number of products to return (-1 for all products, legacy feed behavior)
* @param int $offset Offset for pagination
* @return array Array of product IDs from the default language
*/
public function get_products_from_default_language( int $limit = 10, int $offset = 0 ): array {
$integration = IntegrationRegistry::get_active_localization_integration();
if ( $integration ) {
return $integration->get_products_from_default_language( $limit, $offset );
}
// Fallback: get regular products if no localization plugin is active
$args = [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => $limit,
'offset' => $offset,
'fields' => 'ids',
];
return get_posts( $args );
}
/**
* Get detailed translation information for a product
*
* Uses the first active localization plugin to get translation details.
*
* @param int $product_id Product ID (should be from default language)
* @return array Detailed translation information
*/
public function get_product_translation_details( int $product_id ): array {
$integration = IntegrationRegistry::get_active_localization_integration();
if ( $integration ) {
return $integration->get_product_translation_details( $product_id );
}
// Fallback: return basic structure if no localization plugin is active
return [
'product_id' => $product_id,
'default_language' => null,
'translations' => [],
'translation_status' => [],
'translated_fields' => [],
];
}
// ===========================================
// CSV FORMATTING AND GENERATION METHODS
// ===========================================
/**
* Get all unique translated fields across all products for a language
*
* @param string $language_code Language code
* @param int $limit Maximum number of products to check
* @return array Array of unique field names that have translations
*/
public function get_translated_fields_for_language( string $language_code, int $limit = 100 ): array {
if ( ! IntegrationRegistry::has_active_localization_plugin() ) {
return [];
}
$product_ids = $this->get_products_from_default_language( $limit, 0 );
$all_translated_fields = [];
foreach ( $product_ids as $product_id ) {
$details = $this->get_product_translation_details( $product_id );
if ( isset( $details['translated_fields'][ $language_code ] ) ) {
$translated_fields = $details['translated_fields'][ $language_code ];
$all_translated_fields = array_merge( $all_translated_fields, $translated_fields );
}
}
return array_unique( $all_translated_fields );
}
/**
* Map translated field names to Facebook CSV column names
*
* @param array $translated_fields Array of translated field names
* @return array Array of Facebook CSV column names
*/
private function map_translated_fields_to_csv_columns( array $translated_fields ): array {
// Mapping from WPML field names to Facebook CSV column names
$field_mapping = [
'name' => 'title',
'description' => 'description',
'short_description' => 'short_description',
'rich_text_description' => 'rich_text_description',
'image_id' => 'image_link',
'gallery_image_ids' => 'additional_image_link',
'link' => 'link',
'video' => 'video',
];
$csv_columns = [];
foreach ( $translated_fields as $field ) {
if ( isset( $field_mapping[ $field ] ) ) {
$csv_columns[] = $field_mapping[ $field ];
}
}
// Remove duplicates and ensure required columns
$csv_columns = array_unique( $csv_columns );
return $csv_columns;
}
/**
* Extract translation data and convert to CSV format for a specific language
*
* This method processes products from the default language and generates CSV rows
* for Facebook's language override feed. It handles both simple and variable products.
*
* **Variable Product Behavior:**
* For variable products, localization plugins do NOT support direct 1:1 variation-level
* translations. Instead, this method:
* 1. Gets the translated parent product content (title, description, images)
* 2. Applies that translated content to ALL variations of the original product
* 3. Each variation gets its own CSV row with the same translated parent content
*
* Note: Multiple plugin handling is managed by IntegrationRegistry::get_active_localization_integration()
*
* @param string $language_code Language code (e.g., 'es_ES', 'fr_FR')
* @param int $limit Maximum number of products to process
* @param int $offset Offset for pagination
* @return array CSV data ready for conversion to CSV string with dynamic columns
*/
public function get_language_csv_data( string $language_code, int $limit = 100, int $offset = 0 ): array {
if ( ! IntegrationRegistry::has_active_localization_plugin() ) {
return [
'data' => [],
'columns' => [ 'id', 'override' ],
'translated_fields' => [],
];
}
// First, determine which fields are translated for this language
$translated_fields = $this->get_translated_fields_for_language( $language_code, $limit );
$csv_columns = $this->map_translated_fields_to_csv_columns( $translated_fields );
$product_ids = $this->get_products_from_default_language( $limit, $offset );
$csv_data = [];
foreach ( $product_ids as $product_id ) {
// Skip products that don't pass Facebook sync validation
$original_product = wc_get_product( $product_id );
if ( ! $original_product ) {
continue;
}
// Use Facebook's product sync validator if available
if ( function_exists( 'facebook_for_woocommerce' ) ) {
$sync_validator = facebook_for_woocommerce()->get_product_sync_validator( $original_product );
if ( ! $sync_validator->passes_all_checks() ) {
continue;
}
}
$details = $this->get_product_translation_details( $product_id );
if ( empty( $details['translations'] ) || ! isset( $details['translations'][ $language_code ] ) ) {
continue;
}
$translated_id = $details['translations'][ $language_code ];
$product_translated_fields = $details['translated_fields'][ $language_code ] ?? [];
// Only include products that have actual translated content
if ( empty( $product_translated_fields ) ) {
continue;
}
$translated_product = wc_get_product( $translated_id );
if ( ! $translated_product ) {
continue;
}
// Create Facebook product instances for proper field extraction
if ( ! class_exists( 'WC_Facebook_Product' ) ) {
require_once WC_FACEBOOKCOMMERCE_PLUGIN_DIR . '/includes/fbproduct.php';
}
$original_fb_product = new \WC_Facebook_Product( $original_product );
$translated_fb_product = new \WC_Facebook_Product( $translated_product );
// Determine the ID based on product type (matching catalog backend logic):
// - Simple products: Use just the woo_id
// - Variable products: Iterate through each variation and use the variation woo_id
if ( $original_product->is_type( 'variable' ) ) {
// For variable products, build the translated content once (it's the same for all variations)
$translated_content = [
'override' => \WooCommerce\Facebook\Locale::convert_to_facebook_language_code( $language_code ),
];
// Build translated field values once (they're shared across all variations)
foreach ( $csv_columns as $column ) {
$translated_content[ $column ] = $this->get_translated_field_value(
$column,
$original_fb_product,
$translated_fb_product,
$product_translated_fields,
$language_code
);
}
// Check if at least one translatable field has content
$has_content = false;
foreach ( $csv_columns as $column ) {
if ( ! empty( $translated_content[ $column ] ) ) {
$has_content = true;
break;
}
}
// If there's no translated content, skip this entire variable product
if ( ! $has_content ) {
continue;
}
// Now iterate through variations and create a row for each with the same content
$variations = $original_product->get_children();
foreach ( $variations as $variation_id ) {
$variation = wc_get_product( $variation_id );
if ( ! $variation ) {
continue; // Skip invalid variations
}
// Get product ID based on content ID migration rollout switch
$variation_id = $this->get_product_id( $variation );
// Create row with the variation ID and reuse the translated content
$csv_row = array_merge(
[ 'id' => $variation_id ],
$translated_content
);
$csv_data[] = $csv_row;
}
} else {
// Get product ID based on content ID migration rollout switch
$product_id = $this->get_product_id( $original_product );
// Start with required columns
$csv_row = [
'id' => $product_id,
'override' => \WooCommerce\Facebook\Locale::convert_to_facebook_language_code( $language_code ),
];
// Add dynamic columns based on what's actually translated
foreach ( $csv_columns as $column ) {
$csv_row[ $column ] = $this->get_translated_field_value(
$column,
$original_fb_product,
$translated_fb_product,
$product_translated_fields,
$language_code
);
}
// Only add row if at least one translatable field has content
$has_content = false;
foreach ( $csv_columns as $column ) {
if ( ! empty( $csv_row[ $column ] ) ) {
$has_content = true;
break;
}
}
if ( $has_content ) {
$csv_data[] = $csv_row;
}
}
}
return [
'data' => $csv_data,
'columns' => array_merge( [ 'id', 'override' ], $csv_columns ),
'translated_fields' => $translated_fields,
];
}
/**
* Get the value for a specific translated field using Facebook product methods
*
* @param string $column CSV column name
* @param \WC_Facebook_Product $original_fb_product Original Facebook product
* @param \WC_Facebook_Product $translated_fb_product Translated Facebook product
* @param array $product_translated_fields Fields that are translated for this product
* @param string $language_code Target language code for permalink translation
* @return string Field value with proper validation and cleaning
*/
private function get_translated_field_value(
string $column,
\WC_Facebook_Product $original_fb_product,
\WC_Facebook_Product $translated_fb_product,
array $product_translated_fields,
string $language_code = ''
): string {
// Import required classes for validation
if ( ! class_exists( '\WooCommerce\Facebook\Framework\Helper' ) ) {
require_once WC_FACEBOOKCOMMERCE_PLUGIN_DIR . '/includes/Framework/Helper.php';
}
$value = '';
switch ( $column ) {
case 'title':
if ( in_array( 'name', $product_translated_fields, true ) ) {
$title = $translated_fb_product->get_name();
$value = \WooCommerce\Facebook\Framework\Helper::str_truncate(
\WC_Facebookcommerce_Utils::clean_string( $title ),
\WC_Facebook_Product::MAX_TITLE_LENGTH
);
}
break;
case 'description':
if ( in_array( 'description', $product_translated_fields, true ) ) {
// Use get_fb_description() to match main feed behavior (strips HTML tags)
$description = $translated_fb_product->get_fb_description();
$value = \WooCommerce\Facebook\Framework\Helper::str_truncate(
$description,
\WC_Facebook_Product::MAX_DESCRIPTION_LENGTH
);
}
break;
case 'short_description':
if ( in_array( 'short_description', $product_translated_fields, true ) ) {
$short_description = $translated_fb_product->get_fb_short_description();
$value = \WooCommerce\Facebook\Framework\Helper::str_truncate(
$short_description,
\WC_Facebook_Product::MAX_DESCRIPTION_LENGTH
);
}
break;
case 'rich_text_description':
if ( in_array( 'rich_text_description', $product_translated_fields, true ) ) {
$rich_text_description = $translated_fb_product->get_rich_text_description();
$value = \WooCommerce\Facebook\Framework\Helper::str_truncate(
$rich_text_description,
\WC_Facebook_Product::MAX_DESCRIPTION_LENGTH
);
}
break;
case 'image_link':
if ( in_array( 'image_id', $product_translated_fields, true ) ) {
$image_urls = $translated_fb_product->get_all_image_urls();
$value = $image_urls[0] ?? '';
}
break;
case 'additional_image_link':
if ( in_array( 'gallery_image_ids', $product_translated_fields, true ) ) {
$image_urls = $translated_fb_product->get_all_image_urls();
$additional_images = array_slice( $image_urls, 1, 5 ); // Max 5 additional images
$value = ! empty( $additional_images ) ? implode( ',', $additional_images ) : '';
}
break;
case 'video':
if ( in_array( 'video', $product_translated_fields, true ) ) {
$video_urls = $translated_fb_product->get_all_video_urls();
if ( ! empty( $video_urls ) ) {
// Format video URLs as JSON array of objects with 'url' key, matching main feed format
$value = wp_json_encode( $video_urls );
}
}
break;
case 'link':
if ( in_array( 'link', $product_translated_fields, true ) ) {
// Use the translated product's built-in get_permalink() method
// This automatically handles the correct URL structure and translated slugs
$value = $translated_fb_product->get_permalink();
}
break;
}
return $value;
}
/**
* Format price for CSV using Facebook's approach
*
* @param int $price Price in cents
* @param string $currency Currency code
* @return string Formatted price
*/
private function format_price_for_csv( $price, $currency ): string {
return (string) ( round( $price / 100.0, 2 ) ) . ' ' . $currency;
}
/**
* Get the product ID based on the content ID migration rollout switch
*
* @param \WC_Product $product Product object
* @return string Product ID (either simple ID or retailer ID based on GK)
*/
private function get_product_id( \WC_Product $product ): string {
// Check if content ID migration is enabled
if ( function_exists( 'facebook_for_woocommerce' ) ) {
$rollout_switches = facebook_for_woocommerce()->get_rollout_switches();
if ( $rollout_switches && $rollout_switches->is_switch_enabled(
\WooCommerce\Facebook\RolloutSwitches::SWITCH_CONTENT_ID_MIGRATION_ENABLED
) ) {
// New behavior: Use simple WooCommerce ID
return (string) $product->get_id();
}
}
// Old behavior: Use Facebook retailer ID format
return \WC_Facebookcommerce_Utils::get_fb_retailer_id( $product );
}
}