v1.0.0
This commit is contained in:
commit
6708bbb67f
43 changed files with 8342 additions and 0 deletions
175
includes/Internal/AttachmentIndexer.php
Normal file
175
includes/Internal/AttachmentIndexer.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
/**
|
||||
* Indexes image attachments into mra_media_index.
|
||||
*
|
||||
* @package MediaRightsAudit\Internal
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Internal;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Populates mra_media_index with one row per image attachment.
|
||||
*
|
||||
* Processing order: oldest-first (ASC by attachment ID).
|
||||
* Idempotent: safely re-runnable; existing rows are never overwritten.
|
||||
*/
|
||||
class AttachmentIndexer {
|
||||
|
||||
/**
|
||||
* Action Scheduler hook name for background indexing.
|
||||
*/
|
||||
const AS_HOOK = 'mra_internal_index_batch';
|
||||
|
||||
/**
|
||||
* Indexes one batch of not-yet-indexed image attachments.
|
||||
*
|
||||
* @param int $batch_size Maximum number of attachments to process.
|
||||
*
|
||||
* @return int Number of attachments newly added to the index.
|
||||
*/
|
||||
public static function index_batch( int $batch_size = 50 ): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
464
includes/Internal/UsageScanner.php
Normal file
464
includes/Internal/UsageScanner.php
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
<?php
|
||||
/**
|
||||
* Scans where each indexed attachment is used across the site.
|
||||
*
|
||||
* @package MediaRightsAudit\Internal
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Internal;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Populates mra_media_usage with one row per attachment–post reference.
|
||||
*
|
||||
* Three context types are detected:
|
||||
* - featured : the post uses this attachment as its featured image (_thumbnail_id).
|
||||
* - content : the attachment ID appears in post_content (block editor class wp-image-{id}).
|
||||
* - meta : a custom field value contains the attachment filename.
|
||||
*
|
||||
* Scanning is idempotent: existing usage rows are deleted before each attachment's scan.
|
||||
*
|
||||
* Extension hooks:
|
||||
* - mra/internal/scanner/post_types filter to override scanned post types.
|
||||
* - mra/internal/scanner/meta_keys filter to add private meta keys to the meta scan.
|
||||
*/
|
||||
class UsageScanner {
|
||||
|
||||
/**
|
||||
* Action Scheduler hook name for background scanning.
|
||||
*/
|
||||
const AS_HOOK = 'mra_internal_scan_batch';
|
||||
|
||||
/**
|
||||
* Scans one batch of indexed-but-unscanned attachments for usage.
|
||||
*
|
||||
* @param int $batch_size Maximum number of attachments to scan per call.
|
||||
*
|
||||
* @return int Number of attachments scanned.
|
||||
*/
|
||||
public static function scan_batch( int $batch_size = 50 ): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->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<int> 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<string>
|
||||
*/
|
||||
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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue