indexer = $indexer ?? new Indexer(); } /** * Register hooks. */ public function register(): void { add_action( 'unbreak_start_full_scan', array( $this, 'start' ) ); add_action( self::HOOK_POSTS, array( $this, 'run_posts_batch' ) ); add_action( self::HOOK_COMMENT, array( $this, 'run_comments_batch' ) ); } /** * Start (or restart) a full scan. Only counts rows and enqueues the first batch. */ public function start(): void { global $wpdb; $types = Indexer::post_types(); $total = 0; if ( ! empty( $types ) ) { $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholders generated above. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ({$in}) AND post_status IN ('publish','private')", $types ) ); } self::save_state( array( 'status' => 'extracting', 'started_at' => Clock::now(), 'finished_at' => null, 'total' => $total, 'processed' => 0, ) ); Jobs::cancel( self::HOOK_POSTS ); Jobs::cancel( self::HOOK_COMMENT ); Jobs::enqueue_unique( self::HOOK_POSTS, array( 0 ) ); } /** * Process up to BATCH_SIZE posts with ID greater than $after_id. * * @param int $after_id Last processed post ID. */ public function run_posts_batch( $after_id = 0 ): void { global $wpdb; $after_id = (int) $after_id; $types = Indexer::post_types(); $ids = array(); if ( ! empty( $types ) ) { $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $params = array_merge( array( $after_id ), $types, array( self::BATCH_SIZE ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholders generated above. $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type IN ({$in}) AND post_status IN ('publish','private') ORDER BY ID ASC LIMIT %d", $params ) ) ); } $started = microtime( true ); $last = $after_id; $done = 0; foreach ( $ids as $id ) { $this->indexer->index_post( $id ); $last = $id; ++$done; if ( microtime( true ) - $started > self::TIME_BUDGET ) { break; } } self::release_memory(); $state = self::state(); $state['processed'] = min( (int) $state['total'], (int) $state['processed'] + $done ); self::save_state( $state ); if ( count( $ids ) === self::BATCH_SIZE || $done < count( $ids ) ) { Jobs::enqueue_unique( self::HOOK_POSTS, array( $last ) ); return; } $this->indexer->index_menus(); $this->indexer->index_widgets(); if ( Settings::source_enabled( 'comment' ) ) { Jobs::enqueue_unique( self::HOOK_COMMENT, array( 0 ) ); return; } $this->finish(); } /** * Process up to BATCH_SIZE approved comments with ID greater than $after_id. * * @param int $after_id Last processed comment ID. */ public function run_comments_batch( $after_id = 0 ): void { global $wpdb; $after_id = (int) $after_id; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Batched background scan. $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID > %d AND comment_approved = '1' ORDER BY comment_ID ASC LIMIT %d", $after_id, self::BATCH_SIZE ) ) ); $last = $after_id; foreach ( $ids as $id ) { $this->indexer->index_comment( $id ); $last = $id; } self::release_memory(); if ( count( $ids ) === self::BATCH_SIZE ) { Jobs::enqueue_unique( self::HOOK_COMMENT, array( $last ) ); return; } $this->finish(); } /** * Mark extraction finished. */ private function finish(): void { $state = self::state(); $state['status'] = 'done'; $state['finished_at'] = Clock::now(); $state['processed'] = $state['total']; self::save_state( $state ); /** * Fires when a full extraction pass has finished. */ do_action( 'unbreak_extraction_done' ); } /** * Current scan state. * * @return array{status:string,started_at:int|null,finished_at:int|null,total:int,processed:int} */ public static function state(): array { $state = get_option( self::STATE_OPTION, array() ); $state = is_array( $state ) ? $state : array(); return array( 'status' => isset( $state['status'] ) ? (string) $state['status'] : 'idle', 'started_at' => isset( $state['started_at'] ) ? (int) $state['started_at'] : null, 'finished_at' => isset( $state['finished_at'] ) ? (int) $state['finished_at'] : null, 'total' => isset( $state['total'] ) ? (int) $state['total'] : 0, 'processed' => isset( $state['processed'] ) ? (int) $state['processed'] : 0, ); } /** * Persist scan state. * * @param array $state State. */ private static function save_state( array $state ): void { update_option( self::STATE_OPTION, $state, false ); } /** * Free per-request caches between batches so long queues stay under the memory budget. */ private static function release_memory(): void { if ( function_exists( 'wp_cache_flush_runtime' ) && ! wp_using_ext_object_cache() ) { wp_cache_flush_runtime(); } } }