diff --git a/composer.json b/composer.json index 7f015d89c..ce285cd79 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "autoload": { "psr-4": { "WooCommerce\\Facebook\\": "includes", - "WooCommerce\\Facebook\\Feed\\": ["includes/Feed", "includes/Feed/Promotions", "includes/Feed/RatingsAndReviews", "includes/Feed/ShippingProfiles"] + "WooCommerce\\Facebook\\Feed\\": ["includes/Feed", "includes/Feed/Promotions", "includes/Feed/RatingsAndReviews", "includes/Feed/ShippingProfiles", "includes/Feed/NavigationMenu"] } }, "autoload-dev": { diff --git a/includes/Feed/AbstractFeed.php b/includes/Feed/AbstractFeed.php index d2399df7e..18e139a15 100644 --- a/includes/Feed/AbstractFeed.php +++ b/includes/Feed/AbstractFeed.php @@ -10,7 +10,6 @@ namespace WooCommerce\Facebook\Feed; -use WooCommerce\Facebook\Framework\Api\Exception; use WooCommerce\Facebook\Framework\Helper; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; use WooCommerce\Facebook\Utilities\Heartbeat; @@ -42,10 +41,10 @@ abstract class AbstractFeed { /** * The feed writer instance for the given feed. * - * @var FeedFileWriter + * @var AbstractFeedFileWriter * @since 3.5.0 */ - protected FeedFileWriter $feed_writer; + protected AbstractFeedFileWriter $feed_writer; /** * The feed generator instance for the given feed. @@ -66,11 +65,11 @@ abstract class AbstractFeed { /** * Initialize feed properties. * - * @param FeedFileWriter $feed_writer The feed file writer instance. - * @param AbstractFeedHandler $feed_handler The feed handler instance. - * @param FeedGenerator $feed_generator The feed generator instance. + * @param AbstractFeedFileWriter $feed_writer The feed file writer instance. + * @param AbstractFeedHandler $feed_handler The feed handler instance. + * @param FeedGenerator $feed_generator The feed generator instance. */ - protected function init( FeedFileWriter $feed_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator ): void { + protected function init( AbstractFeedFileWriter $feed_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator ): void { $this->feed_writer = $feed_writer; $this->feed_handler = $feed_handler; $this->feed_generator = $feed_generator; @@ -253,8 +252,14 @@ public function handle_feed_data_request(): void { throw new PluginException( "{$name}: File at path ' . $file_path . ' is not readable.", 404 ); } + if ( $this->feed_writer instanceof JsonFeedFileWriter ) { + $content_type = 'Content-Type: application/json; charset=utf-8'; + } else { + $content_type = 'Content-Type: text/csv; charset=utf-8'; + } + // set the download headers. - header( 'Content-Type: text/csv; charset=utf-8' ); + header( $content_type ); header( 'Content-Description: File Transfer' ); header( 'Content-Disposition: attachment; filename="' . basename( $file_path ) . '"' ); header( 'Expires: 0' ); diff --git a/includes/Feed/AbstractFeedFileWriter.php b/includes/Feed/AbstractFeedFileWriter.php new file mode 100644 index 000000000..51b63ee26 --- /dev/null +++ b/includes/Feed/AbstractFeedFileWriter.php @@ -0,0 +1,307 @@ +feed_name = $feed_name; + $this->header_row = $header_row; + $this->delimiter = $delimiter; + $this->enclosure = $enclosure; + $this->escape_char = $escape_char; + } + + /** + * Write the feed file. + * + * @param array $data The data to write to the feed file. + * + * @return void + * @since 3.5.0 + */ + public function write_feed_file( array $data ): void { + try { + $this->create_feed_directory(); + $this->create_files_to_protect_feed_directory(); + + // Step 1: Prepare the temporary empty feed file with header row. + $temp_feed_file = $this->prepare_temporary_feed_file(); + + // Step 2: Write feed into the temporary feed file. + $this->write_temp_feed_file( $data ); + + // Step 3: Rename temporary feed file to final feed file. + $this->promote_temp_file(); + } catch ( PluginException $exception ) { + WC_Facebookcommerce_Utils::log_exception_immediately_to_meta( + $exception, + [ + 'event' => 'feed_upload', + 'event_type' => 'write_feed_file', + 'extra_data' => [ + 'feed_name' => $this->feed_name, + ], + ] + ); + // Close the temporary file if it is still open. + if ( ! empty( $temp_feed_file ) && is_resource( $temp_feed_file ) ) { + fclose( $temp_feed_file ); // phpcs:ignore + } + + // Delete the temporary file if it exists. + if ( ! empty( $temp_file_path ) && file_exists( $temp_file_path ) ) { + unlink( $temp_file_path ); // phpcs:ignore + } + } + } + + /** + * Generates the feed file. + * + * @throws PluginException If the directory could not be created. + * @since 3.5.0 + */ + public function create_feed_directory(): void { + $file_directory = $this->get_file_directory(); + $directory_created = wp_mkdir_p( $file_directory ); + if ( ! $directory_created ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Could not create feed directory at {$file_directory}", 'facebook-for-woocommerce' ), 500 ); + } + } + + /** + * Creates files in the feed directory to prevent directory listing and hotlinking. + * + * @since 3.5.0 + */ + public function create_files_to_protect_feed_directory(): void { + $feed_directory = trailingslashit( $this->get_file_directory() ); + + $files = array( + array( + 'base' => $feed_directory, + 'file' => 'index.html', + 'content' => '', + ), + array( + 'base' => $feed_directory, + 'file' => '.htaccess', + 'content' => 'deny from all', + ), + ); + + foreach ( $files as $file ) { + $file_path = trailingslashit( $file['base'] ) . $file['file']; + if ( wp_mkdir_p( $file['base'] ) && ! file_exists( $file_path ) ) { + // phpcs:ignore -- use php file i/o functions + $file_handle = @fopen( $file_path, 'w' ); + if ( $file_handle ) { + fwrite( $file_handle, $file['content'] ); //phpcs:ignore + fclose( $file_handle ); //phpcs:ignore + } + } + } + } + + /** + * Gets the feed file path of given feed. + * + * @return string + * @since 3.5.0 + */ + public function get_file_path(): string { + return "{$this->get_file_directory()}/{$this->get_file_name()}"; + } + + + /** + * Gets the temporary feed file path. + * + * @return string + * @since 3.5.0 + */ + public function get_temp_file_path(): string { + return "{$this->get_file_directory()}/{$this->get_temp_file_name()}"; + } + + /** + * Gets the feed file directory. + * + * @return string + * @since 3.5.0 + */ + public function get_file_directory(): string { + $uploads_directory = wp_upload_dir( null, false ); + + return trailingslashit( $uploads_directory['basedir'] ) . sprintf( self::UPLOADS_DIRECTORY, $this->feed_name ); + } + + + /** + * Gets the feed file name. + * + * @return string + * @since 3.5.0 + */ + public function get_file_name(): string { + $feed_secret = facebook_for_woocommerce()->feed_manager->get_feed_secret( $this->feed_name ); + + return sprintf( static::FILE_NAME, $this->feed_name, $feed_secret ); + } + + /** + * Gets the temporary feed file name. + * + * @return string + * @since 3.5.0 + */ + public function get_temp_file_name(): string { + $feed_secret = facebook_for_woocommerce()->feed_manager->get_feed_secret( $this->feed_name ); + + return sprintf( static::FILE_NAME, $this->feed_name, 'temp_' . wp_hash( $feed_secret ) ); + } + + /** + * Prepare a fresh empty temporary feed file with the header row. + * + * @throws PluginException We can't open the file or the file is not writable. + * @return resource A file pointer resource. + * @since 3.5.0 + */ + public function prepare_temporary_feed_file() { + $temp_file_path = $this->get_temp_file_path(); + // phpcs:ignore -- use php file i/o functions + $temp_feed_file = @fopen( $temp_file_path, 'w' ); + + // Check if we can open the temporary feed file. + // phpcs:ignore + if ( false === $temp_feed_file || ! is_writable( $temp_file_path ) ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Could not open file {$temp_file_path} for writing.", 'facebook-for-woocommerce' ), 500 ); + } + + $file_path = $this->get_file_path(); + + // Check if we will be able to write to the final feed file. + // phpcs:ignore -- use php file i/o functions + if ( file_exists( $file_path ) && ! is_writable( $file_path ) ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Could not open file {$file_path} for writing.", 'facebook-for-woocommerce' ), 500 ); + } + + if ( ! empty( $this->header_row ) ) { + $headers = str_getcsv( $this->header_row ); + if ( fputcsv( $temp_feed_file, $headers, $this->delimiter, $this->enclosure, $this->escape_char ) === false ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Failed to write header row to {$temp_file_path}.", 'facebook-for-woocommerce' ), 500 ); + } + } + + return $temp_feed_file; + } + + /** + * Rename temporary feed file into the final feed file. + * This is the last step fo the feed generation procedure. + * + * @throws PluginException If the temporary feed file could not be renamed. + * @since 3.5.0 + */ + public function promote_temp_file(): void { + $file_path = $this->get_file_path(); + $temp_file_path = $this->get_temp_file_path(); + if ( ! empty( $temp_file_path ) && ! empty( $file_path ) ) { + // phpcs:ignore -- use php file i/o functions + $renamed = rename( $temp_file_path, $file_path ); + if ( empty( $renamed ) ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Could not promote temp file: {$temp_file_path}", 'facebook-for-woocommerce' ), 500 ); + } + } + } + + /** + * Write to the temp feed file. + * + * @param array $data The data to write to the feed file. + * @since 3.5.0 + */ + abstract public function write_temp_feed_file( array $data ); +} diff --git a/includes/Feed/AbstractFeedHandler.php b/includes/Feed/AbstractFeedHandler.php index 4aa5ee157..905d00730 100644 --- a/includes/Feed/AbstractFeedHandler.php +++ b/includes/Feed/AbstractFeedHandler.php @@ -25,10 +25,10 @@ abstract class AbstractFeedHandler { /** * The feed file writer instance. * - * @var FeedFileWriter + * @var AbstractFeedFileWriter * @since 3.5.0 */ - protected FeedFileWriter $feed_writer; + protected AbstractFeedFileWriter $feed_writer; /** * The feed type identifier. @@ -55,10 +55,10 @@ public function generate_feed_file(): void { /** * Get the feed file writer instance. * - * @return FeedFileWriter + * @return AbstractFeedFileWriter * @since 3.5.0 */ - public function get_feed_writer(): FeedFileWriter { + public function get_feed_writer(): AbstractFeedFileWriter { return $this->feed_writer; } diff --git a/includes/Feed/CsvFeedFileWriter.php b/includes/Feed/CsvFeedFileWriter.php index d9821d90d..27748ddaf 100644 --- a/includes/Feed/CsvFeedFileWriter.php +++ b/includes/Feed/CsvFeedFileWriter.php @@ -10,7 +10,6 @@ namespace WooCommerce\Facebook\Feed; -use WC_Facebookcommerce_Utils; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; defined( 'ABSPATH' ) || exit; @@ -22,131 +21,10 @@ * * @since 3.5.0 */ -class CsvFeedFileWriter implements FeedFileWriter { - /** Feed file directory inside the uploads folder @var string */ - const UPLOADS_DIRECTORY = 'facebook_for_woocommerce/%s'; - +class CsvFeedFileWriter extends AbstractFeedFileWriter { /** Feed file name @var string */ const FILE_NAME = '%s_feed_%s.csv'; - /** - * Use the feed name to distinguish which folder to write to. - * - * @var string - * @since 3.5.0 - */ - private string $feed_name; - - /** - * Header row for the feed file. - * - * @var string - * @since 3.5.0 - */ - private string $header_row; - - /** - * CSV delimiter. - * - * @var string - * @since 3.5.0 - */ - private string $delimiter; - - /** - * CSV enclosure. - * - * @var string - * @since 3.5.0 - */ - private string $enclosure; - - /** - * CSV escape character. - * - * @var string - * @since 3.5.0 - */ - private string $escape_char; - - /** - * Constructor. - * - * @param string $feed_name The name of the feed. - * @param string $header_row The headers for the feed csv. - * @param string $delimiter Optional. The field delimiter. Default: comma. - * @param string $enclosure Optional. The field enclosure. Default: double quotes. - * @param string $escape_char Optional. The escape character. Default: backslash. - * - * @since 3.5.0 - */ - public function __construct( string $feed_name, string $header_row, string $delimiter = ',', string $enclosure = '"', string $escape_char = '\\' ) { - $this->feed_name = $feed_name; - $this->header_row = $header_row; - $this->delimiter = $delimiter; - $this->enclosure = $enclosure; - $this->escape_char = $escape_char; - } - - /** - * Write the feed file. - * - * @param array $data The data to write to the feed file. - * - * @return void - * @since 3.5.0 - */ - public function write_feed_file( array $data ): void { - try { - $this->create_feed_directory(); - $this->create_files_to_protect_feed_directory(); - - // Step 1: Prepare the temporary empty feed file with header row. - $temp_feed_file = $this->prepare_temporary_feed_file(); - - // Step 2: Write feed into the temporary feed file. - $this->write_temp_feed_file( $data ); - - // Step 3: Rename temporary feed file to final feed file. - $this->promote_temp_file(); - } catch ( PluginException $exception ) { - \WC_Facebookcommerce_Utils::log_exception_immediately_to_meta( - $exception, - [ - 'event' => 'feed_upload', - 'event_type' => 'csv_write_feed_file', - 'extra_data' => [ - 'feed_name' => $this->feed_name, - ], - ] - ); - // Close the temporary file if it is still open. - if ( ! empty( $temp_feed_file ) && is_resource( $temp_feed_file ) ) { - fclose( $temp_feed_file ); // phpcs:ignore - } - - // Delete the temporary file if it exists. - if ( ! empty( $temp_file_path ) && file_exists( $temp_file_path ) ) { - unlink( $temp_file_path ); // phpcs:ignore - } - } - } - - /** - * Generates the feed file. - * - * @throws PluginException If the directory could not be created. - * @since 3.5.0 - */ - public function create_feed_directory(): void { - $file_directory = $this->get_file_directory(); - $directory_created = wp_mkdir_p( $file_directory ); - if ( ! $directory_created ) { - // phpcs:ignore -- Escaping function for translated string not available in this context - throw new PluginException( __( "Could not create feed directory at {$file_directory}", 'facebook-for-woocommerce' ), 500 ); - } - } - /** * Write the feed data to the temporary feed file. * @@ -158,7 +36,7 @@ public function create_feed_directory(): void { */ public function write_temp_feed_file( array $data ): void { $temp_file_path = $this->get_temp_file_path(); - //phpcs:ignore -- use php file i/o functions + // phpcs:ignore -- use php file i/o functions $temp_feed_file = fopen( $temp_file_path, 'a' ); if ( false === $temp_feed_file ) { // phpcs:ignore -- Escaping function for translated string not available in this context @@ -175,7 +53,6 @@ public function write_temp_feed_file( array $data ): void { // Map each field in the row to ensure proper string conversion $value = $obj[ $accessor ] ?? ''; $row[] = $this->format_field( $value ); - } if ( fputcsv( $temp_feed_file, $row, $this->delimiter, $this->enclosure, $this->escape_char ) === false ) { throw new PluginException( 'Failed to write a CSV data row.', 500 ); @@ -192,154 +69,4 @@ protected function format_field( $value ) { } return $value; } - - - /** - * Creates files in the feed directory to prevent directory listing and hotlinking. - * - * @since 3.5.0 - */ - public function create_files_to_protect_feed_directory(): void { - $feed_directory = trailingslashit( $this->get_file_directory() ); - - $files = array( - array( - 'base' => $feed_directory, - 'file' => 'index.html', - 'content' => '', - ), - array( - 'base' => $feed_directory, - 'file' => '.htaccess', - 'content' => 'deny from all', - ), - ); - - foreach ( $files as $file ) { - $file_path = trailingslashit( $file['base'] ) . $file['file']; - if ( wp_mkdir_p( $file['base'] ) && ! file_exists( $file_path ) ) { - // phpcs:ignore -- use php file i/o functions - $file_handle = @fopen( $file_path, 'w' ); - if ( $file_handle ) { - fwrite( $file_handle, $file['content'] ); //phpcs:ignore - fclose( $file_handle ); //phpcs:ignore - } - } - } - } - - /** - * Gets the feed file path of given feed. - * - * @return string - * @since 3.5.0 - */ - public function get_file_path(): string { - return "{$this->get_file_directory()}/{$this->get_file_name()}"; - } - - - /** - * Gets the temporary feed file path. - * - * @return string - * @since 3.5.0 - */ - public function get_temp_file_path(): string { - return "{$this->get_file_directory()}/{$this->get_temp_file_name()}"; - } - - /** - * Gets the feed file directory. - * - * @return string - * @since 3.5.0 - */ - public function get_file_directory(): string { - $uploads_directory = wp_upload_dir( null, false ); - - return trailingslashit( $uploads_directory['basedir'] ) . sprintf( self::UPLOADS_DIRECTORY, $this->feed_name ); - } - - - /** - * Gets the feed file name. - * - * @return string - * @since 3.5.0 - */ - public function get_file_name(): string { - $feed_secret = facebook_for_woocommerce()->feed_manager->get_feed_secret( $this->feed_name ); - - return sprintf( self::FILE_NAME, $this->feed_name, $feed_secret ); - } - - /** - * Gets the temporary feed file name. - * - * @return string - * @since 3.5.0 - */ - public function get_temp_file_name(): string { - $feed_secret = facebook_for_woocommerce()->feed_manager->get_feed_secret( $this->feed_name ); - - return sprintf( self::FILE_NAME, $this->feed_name, 'temp_' . wp_hash( $feed_secret ) ); - } - - /** - * Prepare a fresh empty temporary feed file with the header row. - * - * @throws PluginException We can't open the file or the file is not writable. - * @return resource A file pointer resource. - * @since 3.5.0 - */ - public function prepare_temporary_feed_file() { - $temp_file_path = $this->get_temp_file_path(); - // phpcs:ignore -- use php file i/o functions - $temp_feed_file = @fopen( $temp_file_path, 'w' ); - - // Check if we can open the temporary feed file. - // phpcs:ignore - if ( false === $temp_feed_file || ! is_writable( $temp_file_path ) ) { - // phpcs:ignore -- Escaping function for translated string not available in this context - throw new PluginException( __( "Could not open file {$temp_file_path} for writing.", 'facebook-for-woocommerce' ), 500 ); - } - - $file_path = $this->get_file_path(); - - // Check if we will be able to write to the final feed file. - // phpcs:ignore -- use php file i/o functions - if ( file_exists( $file_path ) && ! is_writable( $file_path ) ) { - // phpcs:ignore -- Escaping function for translated string not available in this context - throw new PluginException( __( "Could not open file {$file_path} for writing.", 'facebook-for-woocommerce' ), 500 ); - } - - $headers = str_getcsv( $this->header_row ); - if ( fputcsv( $temp_feed_file, $headers, $this->delimiter, $this->enclosure, $this->escape_char ) === false ) { - // phpcs:ignore -- Escaping function for translated string not available in this context - throw new PluginException( __( "Failed to write header row to {$temp_file_path}.", 'facebook-for-woocommerce' ), 500 ); - } - - return $temp_feed_file; - } - - /** - * Rename temporary feed file into the final feed file. - * This is the last step fo the feed generation procedure. - * - * @throws PluginException If the temporary feed file could not be renamed. - * @since 3.5.0 - */ - public function promote_temp_file(): void { - $file_path = $this->get_file_path(); - $temp_file_path = $this->get_temp_file_path(); - if ( ! empty( $temp_file_path ) && ! empty( $file_path ) ) { - // phpcs:ignore -- use php file i/o functions - $renamed = rename( $temp_file_path, $file_path ); - if ( empty( $renamed ) ) { - // phpcs:ignore -- Escaping function for translated string not available in this context - throw new PluginException( __( "Could not promote temp file: {$temp_file_path}", 'facebook-for-woocommerce' ), 500 ); - } - } - } } diff --git a/includes/Feed/FeedFileWriter.php b/includes/Feed/FeedFileWriter.php deleted file mode 100644 index adbb2282e..000000000 --- a/includes/Feed/FeedFileWriter.php +++ /dev/null @@ -1,102 +0,0 @@ -feed_writer = $feed_writer; $this->feed_name = $feed_name; diff --git a/includes/Feed/FeedManager.php b/includes/Feed/FeedManager.php index 42c1ab59c..22251eff3 100644 --- a/includes/Feed/FeedManager.php +++ b/includes/Feed/FeedManager.php @@ -21,6 +21,7 @@ class FeedManager { const PROMOTIONS = 'promotions'; const RATINGS_AND_REVIEWS = 'ratings_and_reviews'; const SHIPPING_PROFILES = 'shipping_profiles'; + const NAVIGATION_MENU = 'navigation_menu'; /** * The map of feed types to their instances. @@ -59,6 +60,8 @@ private function create_feed( string $data_stream_name ): AbstractFeed { return new RatingsAndReviewsFeed(); case self::SHIPPING_PROFILES: return new ShippingProfilesFeed(); + case self::NAVIGATION_MENU: + return new NavigationMenuFeed(); default: throw new \InvalidArgumentException( "Invalid feed type {$data_stream_name}" ); } @@ -71,6 +74,7 @@ private function create_feed( string $data_stream_name ): AbstractFeed { * @since 3.5.0 */ public static function get_active_feed_types(): array { + // TODO add self::NAVIGATION_MENU once category sync is implemented return array( self::PROMOTIONS, self::RATINGS_AND_REVIEWS, self::SHIPPING_PROFILES ); } diff --git a/includes/Feed/FeedUploadUtils.php b/includes/Feed/FeedUploadUtils.php index 3d638d918..8bad96899 100644 --- a/includes/Feed/FeedUploadUtils.php +++ b/includes/Feed/FeedUploadUtils.php @@ -30,6 +30,7 @@ class FeedUploadUtils { const PROMO_SYNC_LOGGING_FLOW_NAME = 'promotion_feed_sync'; const RATINGS_AND_REVIEWS_SYNC_LOGGING_FLOW_NAME = 'ratings_and_reviews_feed_sync'; const SHIPPING_PROFILES_SYNC_LOGGING_FLOW_NAME = 'shipping_profiles_feed_sync'; + const NAVIGATION_MENU_SYNC_LOGGING_FLOW_NAME = 'navigation_menu_feed_sync'; public static function get_ratings_and_reviews_data( array $query_args ): array { @@ -75,6 +76,10 @@ public static function get_ratings_and_reviews_data( array $query_args ): array 'title' => null, 'content' => $comment->comment_content, 'created_at' => $comment->comment_date, + 'updated_at' => null, + 'review_image_urls' => null, + 'incentivized' => 'false', + 'has_verified_purchase' => 'false', 'reviewer.name' => $comment->comment_author, 'reviewer.reviewerID' => $reviewer_id, 'reviewer.isAnonymous' => $reviewer_is_anonymous, @@ -396,4 +401,64 @@ private static function get_products( array $product_ids, array $product_categor return $products; } + + public static function get_navigation_menu_data(): array { + try { + // Fetch all product categories + $args = array( + 'taxonomy' => 'product_cat', + 'orderby' => 'name', + 'order' => 'ASC', + 'hide_empty' => false, // Show all categories, even if they are empty + ); + $categories = get_terms( $args ); + + $category_tree = self::build_category_tree( $categories ); + return array( + 'navigation' => array( + array( + 'items' => $category_tree, + 'title' => 'Product Categories', + 'partner_menu_handle' => 'product_categories_menu', + 'partner_menu_id' => '1', + ), + ), + ); + } catch ( \Exception $e ) { + \WC_Facebookcommerce_Utils::log_exception_immediately_to_meta( + $e, + array( + 'event' => self::NAVIGATION_MENU_SYNC_LOGGING_FLOW_NAME, + 'event_type' => 'get_navigation_menu_data', + ) + ); + throw $e; + } + } + + private static function build_category_tree( array $categories, int $parent_id = 0, array &$memo = [] ): array { + if ( isset( $memo[ $parent_id ] ) ) { + return $memo[ $parent_id ]; + } + + $branch = []; + + foreach ( $categories as $category ) { + if ( $category->parent === $parent_id ) { + $children = self::build_category_tree( $categories, $category->term_taxonomy_id, $memo ); + $category_data = array( + 'title' => $category->name, + 'resourceType' => 'collection', + 'retailerID' => $category->term_taxonomy_id, + ); + if ( ! empty( $children ) ) { + $category_data['items'] = $children; + } + $branch[] = $category_data; + } + } + + $memo[ $parent_id ] = $branch; + return $branch; + } } diff --git a/includes/Feed/JsonFeedFileWriter.php b/includes/Feed/JsonFeedFileWriter.php new file mode 100644 index 000000000..7684de627 --- /dev/null +++ b/includes/Feed/JsonFeedFileWriter.php @@ -0,0 +1,54 @@ +get_temp_file_path(); + // phpcs:ignore -- use php file i/o functions + $temp_feed_file = fopen( $temp_file_path, 'a' ); + if ( false === $temp_feed_file ) { + // phpcs:ignore -- Escaping function for translated string not available in this context + throw new PluginException( __( "Unable to open temporary file {$temp_file_path} for appending.", 'facebook-for-woocommerce' ), 500 ); + } + + // phpcs:ignore -- use php file i/o functions + if ( fwrite( $temp_feed_file, wp_json_encode( $data ) ) === false ) { + throw new PluginException( 'Failed to write JSON data to the file.', 500 ); + } + + // phpcs:ignore -- use php file i/o functions + fclose( $temp_feed_file ); + } +} diff --git a/includes/Feed/NavigationMenu/NavigationMenuFeed.php b/includes/Feed/NavigationMenu/NavigationMenuFeed.php new file mode 100644 index 000000000..b35b337ed --- /dev/null +++ b/includes/Feed/NavigationMenu/NavigationMenuFeed.php @@ -0,0 +1,56 @@ +init( + $file_writer, + $feed_handler, + $feed_generator, + ); + } + + protected static function get_feed_type(): string { + return 'NAVIGATION_MENU'; + } + + protected static function get_data_stream_name(): string { + return FeedManager::NAVIGATION_MENU; + } + + protected static function get_feed_gen_interval(): int { + return DAY_IN_SECONDS; + } +} diff --git a/includes/Feed/NavigationMenu/NavigationMenuFeedGenerator.php b/includes/Feed/NavigationMenu/NavigationMenuFeedGenerator.php new file mode 100644 index 000000000..1e03c9c67 --- /dev/null +++ b/includes/Feed/NavigationMenu/NavigationMenuFeedGenerator.php @@ -0,0 +1,42 @@ +feed_writer = $feed_writer; + $this->feed_type = FeedManager::NAVIGATION_MENU; + } + + /** + * Get the feed data and return as array of objects. + * + * @return array + * @since 3.5.0 + */ + public function get_feed_data(): array { + return FeedUploadUtils::get_navigation_menu_data(); + } +} diff --git a/includes/Feed/Promotions/PromotionsFeed.php b/includes/Feed/Promotions/PromotionsFeed.php index fd216614a..7cf4b37b0 100644 --- a/includes/Feed/Promotions/PromotionsFeed.php +++ b/includes/Feed/Promotions/PromotionsFeed.php @@ -13,7 +13,6 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\ActionSchedulerJobFramework\Proxies\ActionScheduler; -use WooCommerce\Facebook\Utilities\Heartbeat; /** * Promotions Feed Class diff --git a/includes/Feed/Promotions/PromotionsFeedGenerator.php b/includes/Feed/Promotions/PromotionsFeedGenerator.php index 912f14402..794706809 100644 --- a/includes/Feed/Promotions/PromotionsFeedGenerator.php +++ b/includes/Feed/Promotions/PromotionsFeedGenerator.php @@ -15,7 +15,7 @@ /** * Promotions Feed Generator Class * - * * Promotions Feed Generator Class. This file is responsible for the new-style feed generation for promotions + * Promotions Feed Generator Class. This file is responsible for the new-style feed generation for promotions * * @package WooCommerce\Facebook\Feed * @since 3.5.0 @@ -26,6 +26,7 @@ class PromotionsFeedGenerator extends FeedGenerator { * * @param int $batch_number The batch number. * @param array $args Additional arguments. + * * @return array The items for the batch. Format matches headers defined in PromotionsFeed::PROMOTIONS_FEED_HEADER * @inheritdoc * @since 3.5.0 diff --git a/includes/Feed/Promotions/PromotionsFeedHandler.php b/includes/Feed/Promotions/PromotionsFeedHandler.php index 2d33e18c2..322e25e97 100644 --- a/includes/Feed/Promotions/PromotionsFeedHandler.php +++ b/includes/Feed/Promotions/PromotionsFeedHandler.php @@ -23,9 +23,9 @@ class PromotionsFeedHandler extends AbstractFeedHandler { /** * Constructor. * - * @param FeedFileWriter $feed_writer An instance of the CSV feed file writer. + * @param AbstractFeedFileWriter $feed_writer An instance of the CSV feed file writer. */ - public function __construct( FeedFileWriter $feed_writer ) { + public function __construct( AbstractFeedFileWriter $feed_writer ) { $this->feed_writer = $feed_writer; $this->feed_type = FeedManager::PROMOTIONS; } diff --git a/includes/Feed/RatingsAndReviews/RatingsAndReviewsFeed.php b/includes/Feed/RatingsAndReviews/RatingsAndReviewsFeed.php index 2be50eaae..22191c838 100644 --- a/includes/Feed/RatingsAndReviews/RatingsAndReviewsFeed.php +++ b/includes/Feed/RatingsAndReviews/RatingsAndReviewsFeed.php @@ -1,5 +1,6 @@ feed_writer = $feed_writer; $this->feed_type = FeedManager::RATINGS_AND_REVIEWS; } diff --git a/includes/Feed/ShippingProfiles/ShippingProfilesFeedGenerator.php b/includes/Feed/ShippingProfiles/ShippingProfilesFeedGenerator.php index 397c22f39..51308bb33 100644 --- a/includes/Feed/ShippingProfiles/ShippingProfilesFeedGenerator.php +++ b/includes/Feed/ShippingProfiles/ShippingProfilesFeedGenerator.php @@ -1,4 +1,12 @@ feed_writer = $feed_writer; $this->feed_type = FeedManager::SHIPPING_PROFILES; } diff --git a/tests/Unit/Feed/AbstractFeedTest.php b/tests/Unit/Feed/AbstractFeedTest.php index 0eaeff3e3..ce751f300 100644 --- a/tests/Unit/Feed/AbstractFeedTest.php +++ b/tests/Unit/Feed/AbstractFeedTest.php @@ -15,7 +15,7 @@ use WooCommerce\Facebook\Tests\AbstractWPUnitTestWithOptionIsolationAndSafeFiltering; class TestFeed extends AbstractFeed { - public function __construct(FeedFileWriter $file_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator) { + public function __construct(AbstractFeedFileWriter $file_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator) { $this->init( $file_writer, $feed_handler, @@ -51,7 +51,7 @@ class AbstractFeedTest extends AbstractWPUnitTestWithOptionIsolationAndSafeFilte public function setUp(): void { parent::setUp(); - $file_writer = $this->createMock( FeedFileWriter::class ); + $file_writer = $this->createMock( AbstractFeedFileWriter::class ); $feed_handler = $this->createMock( AbstractFeedHandler::class ); $feed_generator = $this->createMock( FeedGenerator::class ); $this->feed = new TestFeed($file_writer, $feed_handler, $feed_generator); diff --git a/tests/Unit/Feed/FeedUploadUtilsTest.php b/tests/Unit/Feed/FeedUploadUtilsTest.php index cd66803f0..f380104e9 100644 --- a/tests/Unit/Feed/FeedUploadUtilsTest.php +++ b/tests/Unit/Feed/FeedUploadUtilsTest.php @@ -7,6 +7,8 @@ * @package FacebookCommerce */ + require_once __DIR__ . '/FeedDataTestBase.php'; + /** * Class FeedUploadUtilsTest */ @@ -46,6 +48,10 @@ public function test_get_ratings_and_reviews_data_valid_review() { 'title' => null, 'content' => 'Awesome product!', 'created_at' => '2023-10-01 10:00:00', + 'updated_at' => null, + 'review_image_urls' => null, + 'incentivized' => 'false', + 'has_verified_purchase' => 'false', 'reviewer.name' => 'John Doe', 'reviewer.reviewerID' => "0", 'reviewer.isAnonymous' => 'true', @@ -610,4 +616,49 @@ public function test_get_coupons_data_invalid_coupon_excluded_brand_targeting() $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); $this->assertEmpty($result, 'Expected coupon to be invalid if excluded product_brands targeting is used.'); } + + public function test_build_category_tree() { + // Mock categories + $categories = [ + (object) ['term_taxonomy_id' => 1, 'name' => 'Category 1', 'parent' => 0], + (object) ['term_taxonomy_id' => 2, 'name' => 'Category 2', 'parent' => 0], + (object) ['term_taxonomy_id' => 3, 'name' => 'Subcategory 1', 'parent' => 1], + (object) ['term_taxonomy_id' => 4, 'name' => 'Subcategory 2', 'parent' => 1], + ]; + + // Use reflection to access the private method + $reflection = new \ReflectionClass(\WooCommerce\Facebook\Feed\FeedUploadUtils::class); + $method = $reflection->getMethod('build_category_tree'); + $method->setAccessible(true); + + // Invoke the private method + $category_tree = $method->invokeArgs(null, [$categories]); + + $expected = [ + [ + 'title' => 'Category 1', + 'resourceType' => 'collection', + 'retailerID' => 1, + 'items' => [ + [ + 'title' => 'Subcategory 1', + 'resourceType' => 'collection', + 'retailerID' => 3, + ], + [ + 'title' => 'Subcategory 2', + 'resourceType' => 'collection', + 'retailerID' => 4, + ], + ] + ], + [ + 'title' => 'Category 2', + 'resourceType' => 'collection', + 'retailerID' => 2, + ] + ]; + + $this->assertEquals($expected, $category_tree, 'Category tree does not match expected structure.'); + } } diff --git a/tests/Unit/Feed/ShippingProfiles/ShippingProfilesFeedTest.php b/tests/Unit/Feed/ShippingProfiles/ShippingProfilesFeedTest.php index c7ba6185e..a85fb8200 100644 --- a/tests/Unit/Feed/ShippingProfiles/ShippingProfilesFeedTest.php +++ b/tests/Unit/Feed/ShippingProfiles/ShippingProfilesFeedTest.php @@ -9,6 +9,8 @@ use WooCommerce\Facebook\Feed\ShippingProfilesFeed; +require_once __DIR__ . '/../FeedDataTestBase.php'; + /** * Class ShippingProfilesFeedUploadTest */