File manager - Edit - /home/verseaumee/maze/wp-content/plugins/reddit-for-woocommerce/includes/CsvExporter/ProductExportService.php
Back
<?php /** * Action Scheduler service for exporting the Reddit product catalog. * * Coordinates the batch-based export process using Action Scheduler. This service: * - Registers async hooks for cache building and export initiation. * - Clears previous export data. * - Delegates product scanning to a cache builder. * - Triggers paginated batch exports using {@see BatchExportJob}. * * @package RedditForWooCommerce\CsvExporter * @since 0.1.0 */ namespace RedditForWooCommerce\CsvExporter; use RedditForWooCommerce\Config; use RedditForWooCommerce\Utils\Helper; use RedditForWooCommerce\Admin\Export\BatchExportJob; use RedditForWooCommerce\Utils\Storage\Options; use RedditForWooCommerce\Utils\Storage\OptionDefaults; use RedditForWooCommerce\API\AdPartner\AdPartnerApi; use WC_Product; /** * Handles batch-based product export via Action Scheduler. * * This service controls the full export pipeline. It begins by clearing any previous * export results, then invokes the cache builder to compute eligible product IDs. * Once the caching is completed (signaled via a custom hook), it initiates a sequence * of export batches using the {@see BatchExportJob} class. * * Each batch is offset-based and runs as an independent async job. * * @since 0.1.0 */ class ProductExportService { /** * Action Scheduler hook name for individual export batches. * * @since 0.1.0 */ public const ACTION_HOOK = 'export_product_catalog'; /** * Encapsulates the logic for scanning product IDs, building export rows, and writing to file. * * This reusable job runner handles each export batch, and is invoked * during both cache completion and asynchronous Action Scheduler execution. * * @since 0.1.0 * * @var BatchExportJob */ public BatchExportJob $job; /** * Provides access to the Ad Partner APIs. * * @var AdPartnerApi */ public AdPartnerApi $ad_partner_api; /** * Constructor. * * @since 0.1.0 * * @param BatchExportJob $job Export job instance that handles caching, entity export, and file generation. * @param AdPartnerApi $ad_partner_api Exposes Ad Partner APIs. */ public function __construct( BatchExportJob $job, AdPartnerApi $ad_partner_api ) { $this->job = $job; $this->ad_partner_api = $ad_partner_api; } /** * Registers Action Scheduler hooks for the export process. * * - Hook to begin export after product caching is complete. * - Hook to handle each export batch. * - Delegates cache hook registration to the cache builder. * * @since 0.1.0 * * @return void */ public function register_hooks(): void { $this->job->cache_builder->register(); add_filter( Helper::with_prefix( 'filter_builder_row' ), array( $this, 'allow_only_physical_products' ), 10, 2 ); add_action( Helper::with_prefix( 'onboarding_complete' ), array( $this, 'start_export_after_onboarding' ) ); add_action( Helper::with_prefix( 'reddit_disconnected' ), array( $this, 'maybe_unschedule_export_jobs' ) ); add_action( Helper::with_prefix( 'recurring_catalog_export' ), array( $this, 'start_export' ) ); add_action( Helper::with_prefix( 'export_products_cache_completed' ), array( $this, 'start_writing' ) ); add_action( Helper::with_prefix( self::ACTION_HOOK ), array( $this, 'handle_batch' ), 10, 2 ); add_action( 'wp_ajax_' . Helper::with_prefix( 'generate_feed' ), array( $this, 'trigger_export_callback' ) ); add_filter( 'wp_ajax_' . Helper::with_prefix( 'export_status' ), array( $this, 'check_export_status' ), 10 ); add_action( Helper::with_prefix( 'batch_export_job_complete' ), array( $this, 'maybe_create_catalog' ), 10 ); add_action( Helper::with_prefix( 'batch_export_job_complete' ), array( $this, 'create_feed' ), 20 // Run after the catalog creation checks. ); Helper::register_ajax_action( 'create_catalog', array( $this, 'create_catalog_manually' ), true ); } /** * Initiates the export process immediately after onboarding and schedules daily recurring exports. * * This method is triggered via the {@see Helper::with_prefix( 'onboarding_complete' )} hook. * It performs the following: * - Initiates a one-time product catalog export by calling {@see self::start_export()}. * - Schedules a daily recurring export job using Action Scheduler if not already scheduled. * * This ensures that merchants begin with a fresh export and that exports continue automatically. * * @since 0.2.0 * * @return void */ public function start_export_after_onboarding(): void { $this->start_export(); $this->maybe_schedule_recurring_export(); } /** * Schedules the recurring catalog export job if not already scheduled. * * This method registers a daily recurring job via Action Scheduler using the hook: * {@see Helper::with_prefix( 'recurring_catalog_export' )}. * * The job is set to begin one day after the current time and repeats every 24 hours. * * @since 0.2.0 * * @return void */ public function maybe_schedule_recurring_export(): void { if ( ! as_has_scheduled_action( Helper::with_prefix( 'recurring_catalog_export' ) ) ) { as_schedule_recurring_action( time() + DAY_IN_SECONDS, DAY_IN_SECONDS, Helper::with_prefix( 'recurring_catalog_export' ), array(), Config::PLUGIN_SLUG ); } } /** * Unschedules all export-related Action Scheduler jobs. * * This method clears any scheduled export actions, including: * - The main export job. * - Recurring export jobs. * * It is typically called when the Reddit connection is removed or during plugin deactivation. * * @since 0.1.0 * * @return void */ public function maybe_unschedule_export_jobs(): void { as_unschedule_all_actions( Helper::with_prefix( self::ACTION_HOOK ) ); as_unschedule_all_actions( Helper::with_prefix( get_class( $this->job->cache_builder )::ACTION_HOOK ) ); as_unschedule_all_actions( Helper::with_prefix( 'recurring_catalog_export' ) ); } /** * Validates that the export writer can create and delete a file. * * This method ensures the filesystem is writable before any export jobs begin. * It creates a temporary file using the export writer and removes it using * the WordPress filesystem API. * * @since 0.1.0 * * @return void * * @throws \RuntimeException If the filesystem is not writable or cleanup fails. */ protected function validate_export_environment(): void { try { $file_path = $this->job->writer->create_file(); global $wp_filesystem; if ( ! $wp_filesystem || ! is_a( $wp_filesystem, \WP_Filesystem_Base::class ) ) { require_once ABSPATH . '/wp-admin/includes/file.php'; WP_Filesystem(); } if ( $wp_filesystem->exists( $file_path ) ) { $wp_filesystem->delete( $file_path ); } } catch ( \RuntimeException $e ) { throw new \RuntimeException( //phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped 'Export aborted. Filesystem is not ready: ' . $e->getMessage(), 0, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped $e ); } } /** * Initiates the product export by clearing previous state and triggering product scanning. * * This method: * - Deletes previously saved file path and URL options. * - Triggers the cache builder to compute exportable products. * - Does not immediately begin file writing — that is triggered once caching completes. * * @since 0.1.0 * * @return bool|null */ public function start_export() { try { $this->validate_export_environment(); } catch ( \RuntimeException $e ) { return false; } if ( $this->job->is_job_in_progress( self::ACTION_HOOK ) ) { return null; } Options::delete( OptionDefaults::EXPORT_FILE_PATH ); Options::delete( OptionDefaults::EXPORT_FILE_URL ); Options::delete( OptionDefaults::EXPORT_PRODUCT_IDS ); if ( method_exists( $this->job->cache_builder, 'build_and_cache' ) ) { $this->job->cache_builder->build_and_cache(); } return true; } /** * Starts writing the export file from offset 0. * * This method is hooked to run after exportable product IDs have been fully cached. * It schedules the first export batch asynchronously. * * @since 0.1.0 * * @return void */ public function start_writing(): void { as_enqueue_async_action( Helper::with_prefix( self::ACTION_HOOK ), array( 'offset' => 0 ), Config::PLUGIN_SLUG ); } /** * Handles a single export batch. * * Each call creates and executes one instance of {@see BatchExportJob}. * If this is the first batch, it creates a new file and stores its path. * If additional batches are needed, this method schedules the next offset. * * @since 0.1.0 * * @param int $offset Offset into the cached product ID list. * @param string|null $existing_file Optional existing file path to continue writing to. * @return void */ public function handle_batch( int $offset = 0, ?string $existing_file = null ): void { $is_first_batch = ( 0 === $offset ); $result = $this->job->run( $offset, $is_first_batch, $existing_file ); $is_complete = $result['complete']; $on_complete = function () use ( $result ) { Options::set( OptionDefaults::EXPORT_FILE_PATH, $result['file_path'] ); Options::set( OptionDefaults::EXPORT_FILE_URL, $result['file_url'] ); $this->job->set_timestamp(); }; if ( $is_first_batch && $is_complete ) { $this->job->on_complete( $on_complete ); return; } if ( ! $is_complete ) { as_enqueue_async_action( Helper::with_prefix( self::ACTION_HOOK ), array( 'offset' => $offset + BatchExportJob::BATCH_SIZE, 'existing_file' => $result['file_path'], ), Config::PLUGIN_SLUG ); } else { $this->job->on_complete( $on_complete ); } } /** * Handles the AJAX request to initiate the product catalog export. * * This method: * - Verifies the security nonce. * - Attempts to start the export process. * - Sends a JSON success or error response. * * @since 0.1.0 */ public function trigger_export_callback(): void { check_ajax_referer( 'admin_nonce', 'security' ); if ( ! Helper::has_products() ) { wp_send_json_error( array( 'code' => Helper::with_prefix( 'no_products_found' ) ) ); } $status = $this->start_export(); if ( true === $status || null === $status ) { wp_send_json_success(); } wp_send_json_error(); } /** * Responds to Heartbeat API requests to report the current export status. * * This is used by the frontend to check if an export is idle, in progress, * or completed, and to return the file URL if available. * * @since 0.1.0 * * @return void */ public function check_export_status() { check_ajax_referer( 'admin_nonce', 'security' ); $is_job_in_progress = $this->job->is_job_in_progress( self::ACTION_HOOK ); $file_url = Options::get( OptionDefaults::EXPORT_FILE_URL ); $status = 'idle'; if ( $is_job_in_progress && empty( $file_url ) ) { $status = 'in-progress'; } elseif ( ! empty( $file_url ) && ! $is_job_in_progress ) { $status = 'completed'; } $response = array( 'status' => $status, 'fileUrl' => $file_url, 'lastExport' => Helper::get_formatted_timestamp( Options::get( OptionDefaults::LAST_EXPORT_TIMESTAMP ) ), ); wp_send_json( $response ); } /** * Creates the product catalog if it not exists on Reddit. * * This method checks if the catalog exists on Reddit and if not, it creates it. * * This method is hooked to {@see Helper::with_prefix( 'batch_export_job_complete' )}. * It is triggered automatically after the final export batch has finished and the * CSV feed file has been successfully written. * * @since 1.0.3 */ public function maybe_create_catalog() { $catalog_id = Options::get( OptionDefaults::CATALOG_ID ); // Check if the catalog is exists on Reddit. if ( $catalog_id ) { $response = $this->ad_partner_api->catalog->get( $catalog_id ); if ( ! is_wp_error( $response ) ) { // The catalog is exists on Reddit, return. return; } } // The catalog not exists on Reddit, create it. $logger = wc_get_logger(); $logger->info( 'Catalog not exists on Reddit, creating it.' ); $response = $this->ad_partner_api->catalog->create(); if ( is_wp_error( $response ) ) { $error_data = $response->get_error_data(); $error_body = isset( $error_data['body'] ) ? json_decode( $error_data['body'], true ) : array(); $logger = wc_get_logger(); $logger->alert( 'Catalog creation failed with error code: ' . $response->get_error_code(), $error_body ); } else { $data = $response->get_data(); $catalog_data = $data['data'] ?? array(); if ( ! empty( $catalog_data ) && ! empty( $catalog_data['id'] ) ) { Options::set( OptionDefaults::CATALOG_ID, sanitize_text_field( $catalog_data['id'] ) ); // Delete the feed status to create a new feed. Options::delete( OptionDefaults::FEED_STATUS ); $logger->info( 'Catalog created successfully.' ); } } } /** * Invokes the Ad Partner API to create a product feed after export completes if it hasn't been created yet. * * This method is hooked to {@see Helper::with_prefix( 'batch_export_job_complete' )}. * It is triggered automatically after the final export batch has finished and the * CSV feed file has been successfully written. * * - Calls {@see FeedApi::create()} to register the feed with the Ad Partner. * - Logs an alert if feed creation fails with a WP_Error. * * This final step ensures that the exported product catalog is registered * as a feed with the Ad Partner for ingestion and use in Dynamic Ads. * * @since 0.1.0 * * @return void */ public function create_feed() { /** * Only create the feed if it hasn't been created yet. */ if ( 'empty' === Options::get( OptionDefaults::FEED_STATUS ) ) { $response = $this->ad_partner_api->feed->create(); if ( is_wp_error( $response ) ) { $logger = wc_get_logger(); $logger->alert( 'Feed generation failed with error code: ' . $response->get_error_code(), ); } else { Options::set( OptionDefaults::FEED_STATUS, 'created' ); } } } /** * Creates a product catalog for the current business. * * This method creates a product catalog for the current business and * triggers the feed creation if the CSV export is completed and the export file URL is set. * Triggers the export if the export is not in progress and the export file URL is not set. * * @since 0.1.0 */ public function create_catalog_manually(): void { check_ajax_referer( 'admin_nonce', 'security' ); // Bail early if the catalog already exists. $catalog_id = Options::get( OptionDefaults::CATALOG_ID ); if ( $catalog_id ) { wp_send_json_error( array( 'message' => __( 'Catalog already exists.', 'reddit-for-woocommerce' ), ) ); } // Delete the existing catalog to allow the creation of a new catalog if the delete_existing_catalog parameter is set to true. $delete_existing_catalog = isset( $_POST['delete_existing_catalog'] ) ? 'true' === sanitize_text_field( wp_unslash( $_POST['delete_existing_catalog'] ) ) : false; $pixel_id = Options::get( OptionDefaults::PIXEL_ID ); if ( $delete_existing_catalog && ! empty( $pixel_id ) ) { $delete_response = $this->delete_existing_catalog( $pixel_id ); if ( is_wp_error( $delete_response ) ) { $error_data = $delete_response->get_error_data(); $error_body = isset( $error_data['body'] ) ? json_decode( $error_data['body'], true ) : array(); $logger = wc_get_logger(); $logger->alert( 'Catalog deletion failed with error code' . $delete_response->get_error_code(), $error_body ); wp_send_json_error( array( 'error_code' => 403 === absint( $error_body['error']['code'] ) ? 'PERMISSION_ERROR' : 'CATALOG_NOT_DELETED', 'message' => $delete_response->get_error_message() ?? '', ) ); } } // Create the catalog. $response = $this->ad_partner_api->catalog->create(); if ( is_wp_error( $response ) ) { $error_data = $response->get_error_data(); $error_body = isset( $error_data['body'] ) ? json_decode( $error_data['body'], true ) : array(); $logger = wc_get_logger(); $logger->alert( 'Catalog creation failed with error code' . $response->get_error_code(), $error_body ); $catalog_error = ''; $error_message = esc_html( $error_body['error']['message'] ?? $response->get_error_message() ); if ( isset( $error_body['error']['code'] ) ) { $error_code = absint( $error_body['error']['code'] ); if ( 403 === $error_code ) { $catalog_error = 'PERMISSION_ERROR'; $error_message = __( 'You do not have permission to create a catalog.', 'reddit-for-woocommerce' ); } elseif ( 400 === $error_code && strpos( $error_body['error']['message'] ?? '', 'pixels already attached to a catalog' ) !== false ) { $error_message = __( 'The pixel is already associated with an existing catalog.', 'reddit-for-woocommerce' ); $catalog_error = 'CATALOG_ALREADY_EXISTS'; } Options::set( OptionDefaults::CATALOG_ERROR, $catalog_error ); } wp_send_json_error( array( 'code' => (string) $error_body['error']['code'] ?? '', 'error_code' => $catalog_error, 'message' => $error_message, ) ); } $data = $response->get_data(); $catalog_data = $data['data'] ?? array(); if ( ! empty( $catalog_data ) ) { Options::set( OptionDefaults::CATALOG_ID, $catalog_data['id'] ); Options::delete( OptionDefaults::CATALOG_ERROR ); $is_job_in_progress = $this->job->is_job_in_progress( self::ACTION_HOOK ); $file_url = Options::get( OptionDefaults::EXPORT_FILE_URL ); if ( ! $is_job_in_progress && ! empty( $file_url ) ) { // Trigger feed creation if the export is completed. $this->create_feed(); } elseif ( ! $is_job_in_progress && empty( $file_url ) ) { // Trigger export if the export is not in progress and the export file URL is not set. $this->start_export(); } } wp_send_json_success( array( 'status' => 'success', 'id' => $catalog_data['id'], 'message' => __( 'Catalog created successfully.', 'reddit-for-woocommerce' ), ) ); } /** * Deletes the existing catalog for the current business. * * This method deletes the existing catalog which is associated with the current pixel to allow the creation of a new catalog. * * @since 1.0.3 * * @param string $pixel_id The ID of the pixel to delete the catalog for. * * @return bool|\WP_Error True if the catalog is deleted, false if no catalog is found, WP_Error if the catalog is not deleted. */ public function delete_existing_catalog( $pixel_id ) { // Get the list of catalogs and get through each catalog to find the one that is associated with the current pixel. $catalogs = $this->ad_partner_api->catalog->list(); if ( is_wp_error( $catalogs ) ) { return $catalogs; } $catalogs = $catalogs->get_data(); foreach ( $catalogs['data'] ?? array() as $catalog ) { if ( ! empty( $catalog['event_sources'] ) && in_array( $pixel_id, $catalog['event_sources'], true ) ) { $delete_catalog_response = $this->ad_partner_api->catalog->delete( $catalog['id'] ); if ( is_wp_error( $delete_catalog_response ) ) { // Return the error if the catalog is not deleted. return $delete_catalog_response; } // Return true if the catalog is deleted. return true; } } // Return false if no catalog is found. return false; } /** * Virtual and downloaded products are excluded from CSV exports because * DPA (Dynamic Product Ads) only allows physical products. * * {@see https://business.reddithelp.com/s/article/dynamic-product-ads} * * @param array $row Row of a CSV describing product properties. * @param WC_Product $product A WooCommerce product. * * @return array */ public function allow_only_physical_products( $row, $product ) { if ( ! $product instanceof WC_Product ) { return null; } if ( $product->is_virtual() || $product->is_downloadable() ) { return null; } return $row; } }
| ver. 1.4 |
Github
|
.
| PHP 8.5.7 | Generation time: 0 |
proxy
|
phpinfo
|
Settings