index = $index; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( self::HOOK, array( $this, 'run' ) ); add_action( 'admin_init', array( $this, 'maybe_start' ) ); } /** * Starts a scan when the index is missing or in an older format. A scan * that is already running is resumed instead, in case its cron event was lost. * * @return void */ public function maybe_start(): void { if ( null !== $this->progress() ) { $this->resume(); return; } if ( (int) get_option( self::VERSION_OPTION, 0 ) >= self::VERSION ) { return; } $this->start(); } /** * Schedules the next batch of a running scan when no cron event is waiting * for it. WP-Cron can lose an event when requests overlap, e.g. a cron run * saving a cron array it read before the event was scheduled, which would * leave the scan stuck. * * @return void */ public function resume(): void { if ( null === $this->progress() || false !== wp_next_scheduled( self::HOOK ) ) { return; } wp_schedule_single_event( time(), self::HOOK ); } /** * Starts a scan from the first post, replacing any scan that is running. * * @return void */ public function start(): void { update_option( self::STATE_OPTION, array( 'last_id' => 0, 'done' => 0, 'total' => $this->count(), ), false ); wp_clear_scheduled_hook( self::HOOK ); wp_schedule_single_event( time(), self::HOOK ); } /** * Indexes posts until the time budget is used, then schedules the next run, * or finishes the scan. * * @param int $budget Seconds to keep going. * @return void */ public function run( $budget = self::TIME_BUDGET ): void { $state = get_option( self::STATE_OPTION ); if ( ! is_array( $state ) ) { return; } $until = microtime( true ) + (int) $budget; do { $rows = $this->batch( (int) $state['last_id'] ); foreach ( $rows as $row ) { $this->index->update( (int) $row->ID, (string) $row->post_content ); $state['last_id'] = (int) $row->ID; ++$state['done']; } $more = self::BATCH === count( $rows ); } while ( $more && microtime( true ) < $until ); if ( ! $more ) { delete_option( self::STATE_OPTION ); update_option( self::VERSION_OPTION, self::VERSION ); update_option( self::LAST_OPTION, array( 'finished' => time(), 'scanned' => (int) $state['done'], ), false ); return; } update_option( self::STATE_OPTION, $state, false ); wp_schedule_single_event( time(), self::HOOK ); } /** * A running scan's progress, or null when none is running. * * @return array{done: int, total: int}|null */ public function progress(): ?array { $state = get_option( self::STATE_OPTION ); if ( ! is_array( $state ) ) { return null; } $total = max( (int) ( $state['total'] ?? 0 ), (int) ( $state['done'] ?? 0 ) ); return array( 'done' => min( (int) ( $state['done'] ?? 0 ), $total ), 'total' => $total, ); } /** * What the index holds (see ContentIndex::summary()), for the Dashboard. * * @return array{posts: int, blocks: int, menu_items: int, schedules: int, shortcodes: int, unpublished: int} */ public function summary(): array { return $this->index->summary(); } /** * When the last scan finished and how many posts it read, or null when no * scan has finished since this was first recorded. * * @return array{finished: int, scanned: int}|null */ public function last_scan(): ?array { $last = get_option( self::LAST_OPTION ); if ( ! is_array( $last ) || empty( $last['finished'] ) ) { return null; } return array( 'finished' => (int) $last['finished'], 'scanned' => max( 0, (int) ( $last['scanned'] ?? 0 ) ), ); } /** * Posts after an ID, with their content. Revisions (and autosaves, which are * revisions) and auto-drafts are left out. * * @param int $last_id Last post ID scanned. * @return array */ private function batch( int $last_id ): array { global $wpdb; return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE ID > %d AND post_type <> 'revision' AND post_status <> 'auto-draft' ORDER BY ID ASC LIMIT %d", $last_id, self::BATCH ) ); } /** * How many posts a scan goes through. * * @return int */ private function count(): int { global $wpdb; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type <> 'revision' AND post_status <> 'auto-draft'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } }