*/ private array $feeds = array(); /** * Registered feed generators implementing FeedInterface. * * @var array */ private array $generators = array(); /** * Constructor. * * @param ChangeDetector $change_detector Change detector. * @param ProductDataExtractor $extractor Product data extractor. * @param FeedFileManager $file_manager File manager. * @param FeedHealthMonitor $health_monitor Health monitor. * @param \wpdb $wpdb WordPress database. */ public function __construct( ChangeDetector $change_detector, ProductDataExtractor $extractor, FeedFileManager $file_manager, FeedHealthMonitor $health_monitor, \wpdb $wpdb ) { $this->change_detector = $change_detector; $this->extractor = $extractor; $this->file_manager = $file_manager; $this->health_monitor = $health_monitor; $this->wpdb = $wpdb; } /** * Register a feed type and persist to options. * * @param AbstractFeed $feed The feed implementation. * * @return void */ public function register_feed_type( AbstractFeed $feed ): void { $this->feeds[ $feed->get_type() ] = $feed; // Persist feed types to options for ChangeDetector. $registered = get_option( 'aicommerce_registered_feed_types', array() ); if ( ! is_array( $registered ) ) { $registered = array(); } if ( ! in_array( $feed->get_type(), $registered, true ) ) { $registered[] = $feed->get_type(); update_option( 'aicommerce_registered_feed_types', $registered, false ); } } /** * Get all registered feed formats. * * @return array */ public function get_feeds(): array { return $this->feeds; } /** * Register a concrete feed generator implementing FeedInterface. * * @param string $type Feed type slug. * @param FeedInterface $generator The feed generator. * * @return void */ public function register_feed_generator( string $type, FeedInterface $generator ): void { $this->generators[ $type ] = $generator; } /** * Get a registered feed generator by type. * * @param string $type Feed type slug. * * @return FeedInterface|null */ public function get_feed_generator( string $type ): ?FeedInterface { return $this->generators[ $type ] ?? null; } /** * Schedule a full feed generation via Action Scheduler. * * Enqueues the first chunk; each chunk schedules the next until done. * * @param bool $force Force full regeneration (ignore change detection). * * @return void */ public function schedule_full_generation( bool $force = false ): void { if ( ! aicommerce_is_pro() ) { throw new \AICommerce\Core\LicenseException( 'Pro', 'Feed generation' ); } foreach ( array_keys( $this->feeds ) as $feed_type ) { $this->update_feed_status( $feed_type, 'generating' ); } $args = array( 'page' => 1, 'force' => $force, 'mode' => 'full', ); if ( $this->has_action_scheduler() ) { as_enqueue_async_action( self::CHUNK_ACTION, array( $args ), 'aicommerce' ); } else { wp_schedule_single_event( time(), self::CHUNK_ACTION, array( $args ) ); } } /** * Schedule incremental feed generation (dirty products only). * * @return void */ public function schedule_incremental(): void { $args = array( 'page' => 1, 'force' => false, 'mode' => 'incremental', ); if ( $this->has_action_scheduler() ) { as_enqueue_async_action( self::CHUNK_ACTION, array( $args ), 'aicommerce' ); } else { wp_schedule_single_event( time(), self::CHUNK_ACTION, array( $args ) ); } } /** * Schedule recurring feed generation. * * @param int $interval_seconds Interval between generations. Default: 6 hours. * * @return void */ public function schedule_recurring( int $interval_seconds = 0 ): void { if ( 0 === $interval_seconds ) { $interval_seconds = self::DEFAULT_INTERVAL; } if ( $this->has_action_scheduler() ) { if ( ! as_has_scheduled_action( self::GENERATE_ACTION ) ) { as_schedule_recurring_action( time() + $interval_seconds, $interval_seconds, self::GENERATE_ACTION, array(), 'aicommerce' ); } } elseif ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) { wp_schedule_event( time() + $interval_seconds, 'aicommerce_feed_interval', self::GENERATE_ACTION ); } } /** * Process a single chunk of products for feed generation. * * Called by Action Scheduler. Processes CHUNK_SIZE products, * then schedules the next chunk if more remain. * * @param array $args Chunk arguments: page, force, mode. * * @return void */ public function process_chunk( array $args ): void { if ( ! aicommerce_is_pro() ) { return; } $page = $args['page'] ?? 1; $force = $args['force'] ?? false; $mode = $args['mode'] ?? 'full'; if ( 'incremental' === $mode ) { $this->process_incremental_chunk(); return; } $product_ids = $this->get_product_ids_batch( $page ); if ( empty( $product_ids ) ) { $this->finalize_feeds(); return; } $cache_entries = array(); foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { continue; } if ( 'publish' !== $product->get_status() ) { unset( $product ); continue; } $feed_data = $this->extractor->extract( $product ); $hash = $this->change_detector->compute_hash( $product ); $cache_entries[] = array( 'product_id' => $product_id, 'hash' => $hash, 'feed_data' => $feed_data, ); // Memory management. unset( $product, $feed_data ); if ( memory_get_usage( true ) > 33554432 ) { wp_cache_flush(); } } // Batch insert cached data. if ( ! empty( $cache_entries ) ) { $this->change_detector->update_cache_batch( $cache_entries ); } // Schedule next chunk. $next_args = array( 'page' => $page + 1, 'force' => $force, 'mode' => 'full', ); if ( $this->has_action_scheduler() ) { as_enqueue_async_action( self::CHUNK_ACTION, array( $next_args ), 'aicommerce' ); } else { wp_schedule_single_event( time(), self::CHUNK_ACTION, array( $next_args ) ); } } /** * Process incremental updates — only dirty products. * * @return void */ private function process_incremental_chunk(): void { foreach ( $this->feeds as $feed_type => $feed ) { $dirty_ids = $this->change_detector->get_dirty_products( $feed_type, self::CHUNK_SIZE ); if ( empty( $dirty_ids ) ) { continue; } foreach ( $dirty_ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product || 'publish' !== $product->get_status() ) { unset( $product ); continue; } $feed_data = $this->extractor->extract( $product ); $hash = $this->change_detector->compute_hash( $product ); $this->change_detector->mark_clean( $product_id, $feed_type, $hash ); $this->change_detector->update_cache( $product_id, $hash, $feed_data, $feed_type ); unset( $product, $feed_data ); if ( memory_get_usage( true ) > 33554432 ) { wp_cache_flush(); } } } $this->finalize_feeds(); } /** * Finalize all feeds — collect all cached data and write feed files. * * Uses concrete FeedInterface generators when registered, falling * back to the AbstractFeed generate() method. * * @return void */ private function finalize_feeds(): void { $all_products = $this->collect_all_cached_products(); foreach ( $this->feeds as $type => $feed ) { // Use the FeedInterface generator if registered. $generator = $this->generators[ $type ] ?? null; if ( $generator instanceof FeedInterface ) { $content = $generator->generate( $all_products ); $filename = $type . '-feed.' . $generator->get_file_extension(); $result = $this->file_manager->write_feed( $filename, $content ); if ( false === $result ) { $this->update_feed_status( $type, 'error', 'Failed to write feed file.' ); continue; } $this->record_feed_success( $type, $result, $content, $all_products ); continue; } // Fallback to AbstractFeed. $this->generate_feed( $feed, $all_products ); } /** * Fires after all feeds have been finalized. * * @param int $product_count Number of products in feeds. */ do_action( 'aicommerce_feeds_generated', count( $all_products ) ); } /** * Record a successful feed generation in the database. * * @param string $feed_type Feed type slug. * @param array $result Write result from FeedFileManager. * @param string $content Generated feed content. * @param array $all_products All products included. * * @return void */ private function record_feed_success( string $feed_type, array $result, string $content, array $all_products ): void { $file_hash = hash( 'sha256', $content ); $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $feed_table, array( 'feed_type' => $feed_type, 'status' => 'complete', 'file_path' => $result['path'], 'file_url' => $result['url'], 'product_count' => count( $all_products ), 'file_hash' => $file_hash, 'error_message' => null, 'generated_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Collect all cached product data from the database. * * @return array Array of normalized product data. */ private function collect_all_cached_products(): array { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $this->wpdb->get_results( "SELECT DISTINCT product_id, feed_data FROM {$this->wpdb->prefix}aicommerce_product_cache WHERE feed_data IS NOT NULL AND feed_data != '' ORDER BY product_id ASC", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $products = array(); $seen_ids = array(); foreach ( ( $rows ?: array() ) as $row ) { $pid = (int) $row['product_id']; if ( isset( $seen_ids[ $pid ] ) ) { continue; } $seen_ids[ $pid ] = true; $data = json_decode( $row['feed_data'], true ); if ( is_array( $data ) ) { $products[] = $data; } } return $products; } /** * Get a batch of product IDs. * * @param int $page Page number (1-based). * * @return array Array of product IDs. */ private function get_product_ids_batch( int $page ): array { $offset = ( $page - 1 ) * self::CHUNK_SIZE; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $results = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT ID FROM {$this->wpdb->posts} WHERE post_type IN ('product', 'product_variation') AND post_status = 'publish' ORDER BY ID ASC LIMIT %d OFFSET %d", self::CHUNK_SIZE, $offset ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return array_map( 'intval', $results ); } /** * Generate a single feed and write it to disk. * * @param AbstractFeed $feed The feed implementation. * @param array $products Normalized product data. * * @return array Result with status and file info. */ private function generate_feed( AbstractFeed $feed, array $products ): array { try { $content = $feed->generate( $products ); $result = $this->file_manager->write_feed( $feed->get_filename(), $content ); if ( false === $result ) { $this->update_feed_status( $feed->get_type(), 'error', 'Failed to write feed file.' ); return array( 'status' => 'error', 'error' => 'Failed to write feed file.', ); } $file_hash = hash( 'sha256', $content ); $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $feed_table, array( 'feed_type' => $feed->get_type(), 'status' => 'complete', 'file_path' => $result['path'], 'file_url' => $result['url'], 'product_count' => count( $products ), 'file_hash' => $file_hash, 'error_message' => null, 'generated_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $health = $this->health_monitor->check_feed( $feed, $products ); return array( 'status' => 'complete', 'file_url' => $result['url'], 'file_size' => $result['size'], 'product_count' => count( $products ), 'health' => $health, ); } catch ( \Throwable $e ) { $this->update_feed_status( $feed->get_type(), 'error', $e->getMessage() ); return array( 'status' => 'error', 'error' => $e->getMessage(), ); } } /** * Update feed status in the database. * * @param string $feed_type Feed type slug. * @param string $status Status value. * @param string $error_message Optional error message. * * @return void */ private function update_feed_status( string $feed_type, string $status, string $error_message = '' ): void { $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $feed_table, array( 'feed_type' => $feed_type, 'status' => $status, 'error_message' => $error_message, 'generated_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Get the status of all feeds. * * @return array Feed status data. */ public function get_status(): array { $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $this->wpdb->get_results( "SELECT feed_type, status, file_url, product_count, file_hash, error_message, generated_at FROM {$feed_table} ORDER BY feed_type ASC", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $status = array(); foreach ( ( $rows ?: array() ) as $row ) { $status[ $row['feed_type'] ] = $row; } foreach ( $this->feeds as $type => $feed ) { if ( ! isset( $status[ $type ] ) ) { $status[ $type ] = array( 'feed_type' => $type, 'status' => 'pending', 'file_url' => '', 'product_count' => 0, 'generated_at' => null, ); } $status[ $type ]['name'] = $feed->get_name(); } return $status; } /** * Check if Action Scheduler is available. * * @return bool */ public function has_action_scheduler(): bool { return function_exists( 'as_enqueue_async_action' ); } /** * Register custom cron interval as fallback. * * @param array $schedules Existing cron schedules. * * @return array Modified schedules. */ public static function add_cron_interval( array $schedules ): array { $schedules['aicommerce_feed_interval'] = array( 'interval' => 6 * HOUR_IN_SECONDS, 'display' => __( 'Tyralix AI Discoverability Feed Generation Interval', 'ai-discoverability-kit' ), ); return $schedules; } }