get_results( $wpdb->prepare( "SELECT attachment_id, file_url FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NULL ORDER BY attachment_id ASC LIMIT %d", $batch_size ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery if ( ! $rows ) { return 0; } foreach ( $rows as $row ) { self::scan_attachment( intval( $row['attachment_id'] ), strval( $row['file_url'] ) ); } return count( $rows ); } /** * Scans a single attachment for usage and marks it as scanned. * * Deletes existing mra_media_usage rows first (idempotent). * * @param int $attachment_id WordPress attachment ID. * @param string $file_url Canonical URL of the original file. * * @return void */ private static function scan_attachment( int $attachment_id, string $file_url ): void { global $wpdb; $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_media_usage', array( 'attachment_id' => $attachment_id ), array( '%d' ) ); self::scan_featured( $attachment_id ); self::scan_content( $attachment_id ); self::scan_meta( $attachment_id, $file_url ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_media_index', array( 'internal_scanned_at' => current_time( 'mysql', true ) ), array( 'attachment_id' => $attachment_id ), array( '%s' ), array( '%d' ) ); } /** * Finds posts that use this attachment as a featured image. * * @param int $attachment_id Attachment ID to search for. * * @return void */ private static function scan_featured( int $attachment_id ): void { global $wpdb; $raw_types = apply_filters( 'mra/internal/scanner/post_types', get_post_types( array( 'public' => true ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores $post_types = array(); if ( is_array( $raw_types ) ) { foreach ( $raw_types as $type ) { if ( is_string( $type ) ) { $post_types[] = $type; } } } if ( empty( $post_types ) ) { return; } $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( $wpdb->prepare( "SELECT pm.post_id, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %d AND p.post_status NOT IN ('trash','auto-draft') AND p.post_type IN ({$placeholders})", array_merge( array( $attachment_id ), $post_types ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! $results ) { return; } foreach ( $results as $row ) { self::insert_usage( $attachment_id, intval( $row['post_id'] ), strval( $row['post_type'] ), 'featured' ); } } /** * Finds posts whose post_content references this attachment. * * Detects the block-editor class `wp-image-{id}` written by Gutenberg. * * @param int $attachment_id Attachment ID to search for. * * @return void */ private static function scan_content( int $attachment_id ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $results = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type FROM {$wpdb->posts} WHERE post_content LIKE %s AND post_status NOT IN ('trash','auto-draft') AND post_type NOT IN ('attachment','revision')", '%wp-image-' . $attachment_id . '%' ), ARRAY_A ); if ( ! $results ) { return; } foreach ( $results as $row ) { self::insert_usage( $attachment_id, intval( $row['ID'] ), strval( $row['post_type'] ), 'content' ); } } /** * Finds custom field values that reference this attachment by filename. * * Scans non-private meta keys by default. Supports basic serialized values (ACF, etc.). * The mra/internal/scanner/meta_keys filter can add private (underscore-prefixed) keys. * * @param int $attachment_id Attachment ID. * @param string $file_url Canonical URL, used to extract the filename for matching. * * @return void */ private static function scan_meta( int $attachment_id, string $file_url ): void { global $wpdb; $filename = self::url_basename( $file_url ); if ( '' === $filename ) { return; } $like = '%' . $wpdb->esc_like( $filename ) . '%'; // esc_like('_') returns '\' + '_'; appending '%' produces the LIKE pattern '\_%' // which matches any meta_key beginning with a literal underscore. $meta_key_like = $wpdb->esc_like( '_' ) . '%'; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key $results = $wpdb->get_results( $wpdb->prepare( "SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key NOT LIKE %s AND pm.meta_value LIKE %s AND p.post_status NOT IN ('trash','auto-draft') AND p.post_type NOT IN ('attachment','revision')", $meta_key_like, $like ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key $raw_extra_keys = apply_filters( 'mra/internal/scanner/meta_keys', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores $extra_keys = array(); if ( is_array( $raw_extra_keys ) ) { foreach ( $raw_extra_keys as $key ) { if ( is_string( $key ) ) { $extra_keys[] = $key; } } } if ( $extra_keys ) { $placeholders = implode( ',', array_fill( 0, count( $extra_keys ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key $extra_results = $wpdb->get_results( $wpdb->prepare( "SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value LIKE %s AND p.post_status NOT IN ('trash','auto-draft') AND p.post_type NOT IN ('attachment','revision')", array_merge( $extra_keys, array( $like ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key $results = $results ? array_merge( $results, (array) $extra_results ) : (array) $extra_results; } if ( ! $results ) { return; } // Deduplicate by post_id + meta_key. $seen = array(); foreach ( $results as $row ) { $post_id = intval( $row['post_id'] ); $meta_key = strval( $row['meta_key'] ); $key = $post_id . '|' . $meta_key; if ( isset( $seen[ $key ] ) ) { continue; } // Verify match survives unserialisation (handles ACF arrays, etc.). $meta_value = strval( $row['meta_value'] ); $values = self::flatten_meta_value( $meta_value ); $matched = false; foreach ( $values as $v ) { if ( false !== strpos( $v, $filename ) ) { $matched = true; break; } } if ( $matched ) { $seen[ $key ] = true; self::insert_usage( $attachment_id, $post_id, strval( $row['post_type'] ), 'meta', $meta_key ); } } } /** * Inserts a single usage record. * * @param int $attachment_id Attachment ID. * @param int $post_id Post that references the attachment. * @param string $post_type Post type. * @param string $context 'featured', 'content', or 'meta'. * @param string|null $meta_key Meta key; only populated when context is 'meta'. * * @return void */ private static function insert_usage( int $attachment_id, int $post_id, string $post_type, string $context, ?string $meta_key = null ): void { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key $wpdb->insert( $wpdb->prefix . 'mra_media_usage', array( 'attachment_id' => $attachment_id, 'post_id' => $post_id, 'post_type' => $post_type, 'context' => $context, 'meta_key' => $meta_key, 'created_at' => current_time( 'mysql', true ), ), array( '%d', '%d', '%s', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key } // ------------------------------------------------------------------------- // Public helpers (also used in tests) // ------------------------------------------------------------------------- /** * Extracts attachment IDs referenced in post content via the wp-image-{id} class. * * Pure PHP — no WordPress dependency; safe to call in unit tests. * * @param string $content Post content string. * * @return array Unique attachment IDs found. */ public static function extract_attachment_ids_from_content( string $content ): array { if ( ! preg_match_all( '/wp-image-(\d+)/', $content, $matches ) ) { return array(); } return array_values( array_unique( array_map( 'intval', $matches[1] ) ) ); } /** * Flattens a meta value (including serialized arrays) to a list of strings. * * Pure PHP — no WordPress dependency; safe to call in unit tests. * * @param mixed $value Raw meta value. * * @return array */ public static function flatten_meta_value( $value ): array { if ( is_string( $value ) ) { // All PHP serialized strings (except the literal "N;") have ':' as the second character. if ( 'N;' === $value || ( strlen( $value ) >= 2 && ':' === $value[1] ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize $unserialized = unserialize( $value, array( 'allowed_classes' => false ) ); if ( false !== $unserialized && is_array( $unserialized ) ) { return self::flatten_meta_value( $unserialized ); } } return array( $value ); } if ( is_array( $value ) ) { $flat = array(); foreach ( $value as $item ) { foreach ( self::flatten_meta_value( $item ) as $s ) { $flat[] = $s; } } return $flat; } return array(); } /** * Returns the filename component of a URL. * * Pure PHP — no WordPress dependency; safe to call in unit tests. * * @param string $url Full URL. * * @return string Filename, or empty string on failure. */ public static function url_basename( 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 ) : ''; } // ------------------------------------------------------------------------- // Scheduling helpers // ------------------------------------------------------------------------- /** * Returns the number of indexed attachments not yet usage-scanned. * * @return int */ public static function get_pending_count(): int { global $wpdb; $result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NULL" ); return intval( $result ); } /** * Processes one scheduled batch and re-queues if more attachments remain. * * Called by Action Scheduler via the mra_internal_scan_batch hook. * * @return void */ public static function process_scheduled_batch(): void { self::scan_batch(); if ( self::get_pending_count() > 0 ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Enqueues a background scan 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 ); } } }