get_results( $wpdb->prepare( "SELECT p.ID, p.guid, p.post_mime_type FROM {$wpdb->posts} p LEFT JOIN {$wpdb->prefix}mra_media_index i ON p.ID = i.attachment_id WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND p.post_status != 'trash' AND i.attachment_id IS NULL ORDER BY p.ID ASC LIMIT %d", 'image/%', $batch_size ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery if ( ! $rows ) { return 0; } // Prime postmeta cache so get_attached_file() and wp_get_attachment_url() are fast. $ids = array_map( 'intval', array_column( $rows, 'ID' ) ); update_meta_cache( 'post', $ids ); $now = current_time( 'mysql', true ); $count = 0; foreach ( $rows as $row ) { $attachment_id = intval( $row['ID'] ); $guid = strval( $row['guid'] ); $mime_type = strval( $row['post_mime_type'] ); $file_url = wp_get_attachment_url( $attachment_id ); $file_url = $file_url ? $file_url : $guid; $file_path = get_attached_file( $attachment_id ); $file_size = ( $file_path && file_exists( $file_path ) ) ? intval( filesize( $file_path ) ) : 0; $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_media_index', array( 'attachment_id' => $attachment_id, 'file_url' => $file_url, 'file_name' => self::extract_filename( $file_url ), 'mime_type' => $mime_type, 'file_size' => $file_size, 'created_at' => $now, ), array( '%d', '%s', '%s', '%s', '%d', '%s' ) ); if ( false !== $inserted ) { ++$count; } } return $count; } /** * Extracts the bare filename from a URL. * * Uses PHP's parse_url() so this method is testable without WordPress. * * @param string $url Absolute or relative URL. * * @return string Filename, or the original string if parsing fails. */ public static function extract_filename( string $url ): string { $path = parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url return is_string( $path ) ? basename( $path ) : basename( $url ); } /** * Returns the number of image attachments not yet in the index. * * @return int */ public static function get_pending_count(): int { global $wpdb; $result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT COUNT(p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->prefix}mra_media_index i ON p.ID = i.attachment_id WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%' AND p.post_status != 'trash' AND i.attachment_id IS NULL" ); return intval( $result ); } /** * Returns the number of attachments currently in the index. * * @return int */ public static function get_indexed_count(): int { global $wpdb; $result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" ); return intval( $result ); } /** * Processes one scheduled batch and re-queues or hands off to UsageScanner. * * Called by Action Scheduler via the mra_internal_index_batch hook. * * @return void */ public static function process_scheduled_batch(): void { self::index_batch(); if ( self::get_pending_count() > 0 ) { Scheduler::schedule_single( self::AS_HOOK ); } else { Scheduler::schedule_single( UsageScanner::AS_HOOK ); } } /** * Enqueues a background indexing job if one is not already pending. * * @return void */ public static function schedule(): void { if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Deletes all rows from mra_media_index and mra_media_usage, forcing a full re-index on the next run. * * @return int Number of index rows removed. */ public static function reset_all(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" ); $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_usage" ); $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_index" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery return $count; } }