diff --git a/changelog.txt b/changelog.txt index bb2f71c..701c3f2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,33 @@ == Changelog == += 1.3.0 = + +_Release date: 2026-05-05_ + +**Added** + +* Per-provider scan tracking table (`mra_provider_status`) with composite primary key `(attachment_id, provider)`. Each API provider now has an independent row tracking its scan status, so adding a new provider only queues the missing scans rather than resetting everything. +* "Sync providers" operation in the Tools page: inserts rows for any provider that has no existing entry for a given attachment, then schedules a background scan batch for the new work only. + +**Changed** + +* `mra_external_results.provider` column changed from `ENUM('google_vision','tineye')` to `varchar(100)` to allow third-party providers registered via the `mra/external/providers` filter to store results. +* External scan batch processing now reads from `mra_provider_status` and processes each provider independently. Aggregate `external_status` on `mra_media_index` is recomputed after each scan and used only for display. +* `ExternalScanner::get_pending_count()` counts queued rows from `mra_provider_status`. +* DB schema version bumped to `1.1.0`. Migration runs automatically on admin_init when updating from an older version; existing data is backfilled into `mra_provider_status`. + +**Compatibility** + +* WordPress: 6.8 - 7.0 +* PHP: 8.2 - 8.4 +* WP-CLI: 2.x + +**Tests** + +* PHP Coding Standards: WPCS 3.x / PHPCS 3.x +* PHPStan: level 9 +* PHPCompatibility: PHP 8.2 - 8.4 + = 1.2.0 = _Release date: 2026-05-02_ diff --git a/includes/Admin/ToolsPage.php b/includes/Admin/ToolsPage.php index a3e00d8..8ab7765 100644 --- a/includes/Admin/ToolsPage.php +++ b/includes/Admin/ToolsPage.php @@ -203,6 +203,10 @@ class ToolsPage { $count = ExternalScanner::requeue_errors(); break; + case 'sync_providers': + $count = ExternalScanner::sync_providers(); + break; + case 'rescan_usage': $count = UsageScanner::reset_all(); UsageScanner::schedule(); @@ -358,6 +362,7 @@ class ToolsPage { 'schedule_internal' => __( 'Internal scan scheduled.', 'robotstxt-mediaaudit' ), 'schedule_external' => __( 'External scan scheduled.', 'robotstxt-mediaaudit' ), 'requeue_errors' => __( 'Failed scans re-queued. Background scan scheduled.', 'robotstxt-mediaaudit' ), + 'sync_providers' => __( 'Provider registry synced. New scans scheduled.', 'robotstxt-mediaaudit' ), 'rescan_usage' => __( 'Usage data cleared. Background scan scheduled.', 'robotstxt-mediaaudit' ), 'reindex_all' => __( 'Index cleared. Fresh indexing scheduled.', 'robotstxt-mediaaudit' ), 'purge_external' => __( 'External scan data purged.', 'robotstxt-mediaaudit' ), @@ -632,6 +637,13 @@ class ToolsPage { 'class' => 'button', 'confirm' => '', ), + array( + 'op' => 'sync_providers', + 'label' => __( 'Sync provider registry', 'robotstxt-mediaaudit' ), + 'desc' => __( 'Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected.', 'robotstxt-mediaaudit' ), + 'class' => 'button', + 'confirm' => '', + ), ); $destructive = array( diff --git a/includes/Core/Database.php b/includes/Core/Database.php index 7f74413..337450a 100644 --- a/includes/Core/Database.php +++ b/includes/Core/Database.php @@ -23,6 +23,7 @@ class Database { public static function create_tables(): void { self::migration_100(); self::migration_101(); + self::migration_110(); } /** @@ -43,6 +44,10 @@ class Database { if ( version_compare( $current, '1.0.1', '<' ) ) { self::migration_101(); } + + if ( version_compare( $current, '1.1.0', '<' ) ) { + self::migration_110(); + } } /** @@ -52,6 +57,7 @@ class Database { */ public static function drop_tables(): void { global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -68,6 +74,7 @@ class Database { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery + $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_provider_status`" ); $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_external_results`" ); $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_media_usage`" ); $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_media_index`" ); @@ -125,7 +132,7 @@ class Database { "CREATE TABLE {$wpdb->prefix}mra_external_results ( result_id bigint(20) unsigned NOT NULL auto_increment, attachment_id bigint(20) unsigned NOT NULL, - provider enum('google_vision','tineye') NOT NULL, + provider varchar(100) NOT NULL, raw_response json DEFAULT NULL, match_count int(10) unsigned NOT NULL DEFAULT 0, top_domains json DEFAULT NULL, @@ -155,4 +162,57 @@ class Database { ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange } + + /** + * Migration 1.1.0 — widens provider column and adds mra_provider_status table. + * + * Changes: + * - ALTER mra_external_results.provider from ENUM to varchar(100) for extensibility. + * - CREATE mra_provider_status for per-provider scan tracking. + * - Backfill mra_provider_status from existing mra_external_results data. + * + * Safe to re-run: ALTER TABLE is a no-op if already varchar(100); + * dbDelta skips existing tables; INSERT IGNORE skips duplicate rows. + * + * @return void + */ + private static function migration_110(): void { + global $wpdb; + + $charset_collate = $wpdb->get_charset_collate(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange + $wpdb->query( + "ALTER TABLE `{$wpdb->prefix}mra_external_results` + MODIFY COLUMN provider varchar(100) NOT NULL" + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + dbDelta( + "CREATE TABLE {$wpdb->prefix}mra_provider_status ( + attachment_id bigint(20) unsigned NOT NULL, + provider varchar(100) NOT NULL, + status enum('pending','queued','scanned','error') NOT NULL DEFAULT 'pending', + scanned_at datetime DEFAULT NULL, + PRIMARY KEY (attachment_id,provider), + KEY status (status) +) {$charset_collate};" + ); + + // Backfill from existing data. Idempotent: INSERT IGNORE skips duplicates. + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + "INSERT IGNORE INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status, scanned_at) + SELECT er.attachment_id, er.provider, + CASE WHEN mi.external_status = 'error' THEN 'error' + WHEN mi.external_status = 'queued' THEN 'queued' + ELSE 'scanned' END, + mi.external_scanned_at + FROM {$wpdb->prefix}mra_external_results er + JOIN {$wpdb->prefix}mra_media_index mi ON mi.attachment_id = er.attachment_id" + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } } diff --git a/includes/External/ExternalScanner.php b/includes/External/ExternalScanner.php index 3d7600c..cce1451 100644 --- a/includes/External/ExternalScanner.php +++ b/includes/External/ExternalScanner.php @@ -13,6 +13,9 @@ use MediaRightsAudit\Core\Queue\Scheduler; * Processes the external scan queue: runs each pending attachment through * all active providers and stores results in mra_external_results. * + * Per-provider scan state is tracked in mra_provider_status. The aggregate + * external_status in mra_media_index is updated after each scan for display. + * * Active providers are resolved at runtime via the mra/external/providers filter. * Scanning is triggered manually by an administrator (Run External Scan bulk action * or wp mra scan-external CLI) — never automatically, to control API costs. @@ -27,11 +30,14 @@ class ExternalScanner { const AS_HOOK = 'mra_external_scan_batch'; /** - * Scans one batch of pending attachments with all active providers. + * Scans one batch of queued attachments with their pending providers. * - * @param int $batch_size Maximum number of attachments to process. + * Queries mra_provider_status for queued entries and processes each + * attachment with the intersection of active providers and queued slugs. * - * @return int Number of attachments attempted. + * @param int $batch_size Maximum number of attachment rows to process. + * + * @return int Number of attachment rows attempted. */ public static function scan_batch( int $batch_size = 10 ): int { global $wpdb; @@ -41,13 +47,20 @@ class ExternalScanner { return 0; } + // Index providers by slug for fast intersection lookup. + $provider_map = array(); + foreach ( $providers as $provider ) { + $provider_map[ $provider->provider_slug() ] = $provider; + } + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT attachment_id, file_url - FROM {$wpdb->prefix}mra_media_index - WHERE external_status = 'queued' - ORDER BY attachment_id ASC + "SELECT DISTINCT ps.attachment_id, mi.file_url + FROM {$wpdb->prefix}mra_provider_status ps + JOIN {$wpdb->prefix}mra_media_index mi ON mi.attachment_id = ps.attachment_id + WHERE ps.status = 'queued' + ORDER BY ps.attachment_id ASC LIMIT %d", $batch_size ), @@ -69,14 +82,41 @@ class ExternalScanner { continue; } - self::process_attachment( $aid, $url, $providers ); + // Get queued provider slugs for this attachment. + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $queued_slugs = $wpdb->get_col( + $wpdb->prepare( + "SELECT provider FROM {$wpdb->prefix}mra_provider_status + WHERE attachment_id = %d AND status = 'queued'", + $aid + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( empty( $queued_slugs ) ) { + continue; + } + + // Intersect queued slugs with currently active providers. + $batch_providers = array(); + foreach ( $queued_slugs as $slug ) { + if ( is_string( $slug ) && isset( $provider_map[ $slug ] ) ) { + $batch_providers[] = $provider_map[ $slug ]; + } + } + + if ( empty( $batch_providers ) ) { + continue; + } + + self::process_attachment( $aid, $url, $batch_providers ); } return count( $rows ); } /** - * Returns the count of attachments still waiting for external scanning. + * Returns the count of provider-attachment rows still waiting for external scanning. * * @return int */ @@ -84,7 +124,7 @@ class ExternalScanner { global $wpdb; $result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery - "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'queued'" + "SELECT COUNT(*) FROM {$wpdb->prefix}mra_provider_status WHERE status = 'queued'" ); return is_numeric( $result ) ? (int) $result : 0; @@ -106,40 +146,22 @@ class ExternalScanner { } /** - * Promotes all attachments with external_status = 'pending' to 'queued'. + * Marks all pending provider rows as queued and schedules a background scan batch. * - * Does not schedule an Action Scheduler action. Useful in CLI context where - * processing runs synchronously in a loop rather than via background jobs. - * - * @return void - */ - public static function queue_all_pending(): void { - global $wpdb; - - $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery - "UPDATE {$wpdb->prefix}mra_media_index - SET external_status = 'queued' - WHERE external_status = 'pending'" - ); - } - - /** - * Marks all pending attachments as queued and schedules a background scan batch. - * - * Moves every attachment with external_status = 'pending' to 'queued' so the - * status change is visible immediately in the admin list. Only schedules the - * Action Scheduler action if there are now queued items and no batch is already running. + * Calls queue_provider_attachments() for each active provider to populate + * mra_provider_status, then syncs the aggregate status on mra_media_index. + * Only schedules the Action Scheduler action if there are queued items and + * no batch is already running. * * @return void */ public static function schedule(): void { - global $wpdb; + $providers = self::get_active_providers(); + foreach ( $providers as $provider ) { + self::queue_provider_attachments( $provider->provider_slug() ); + } - $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery - "UPDATE {$wpdb->prefix}mra_media_index - SET external_status = 'queued' - WHERE external_status = 'pending'" - ); + self::sync_aggregate_queued_status(); if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); @@ -147,10 +169,75 @@ class ExternalScanner { } /** - * Marks specific attachments as pending and schedules a scan batch. + * Promotes all pending provider rows to queued for all active providers. * - * Idempotent: already-pending attachments are left unchanged; - * previously-scanned or error attachments are reset to pending. + * Does not schedule an Action Scheduler action. Useful in CLI context where + * processing runs synchronously in a loop rather than via background jobs. + * + * @return void + */ + public static function queue_all_pending(): void { + $providers = self::get_active_providers(); + foreach ( $providers as $provider ) { + self::queue_provider_attachments( $provider->provider_slug() ); + } + + self::sync_aggregate_queued_status(); + } + + /** + * Creates scan entries for newly registered providers. + * + * For each active provider, inserts mra_provider_status rows for attachments + * that have no existing row for that provider. Already-scanned or error rows + * are not touched. If new rows are created, syncs aggregate status and + * schedules a background scan batch. + * + * @return int Total number of new rows inserted. + */ + public static function sync_providers(): int { + global $wpdb; + + $providers = self::get_active_providers(); + $total = 0; + + foreach ( $providers as $provider ) { + $slug = $provider->provider_slug(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) + SELECT mi.attachment_id, %s, 'queued' + FROM {$wpdb->prefix}mra_media_index mi + LEFT JOIN {$wpdb->prefix}mra_provider_status ps + ON ps.attachment_id = mi.attachment_id AND ps.provider = %s + WHERE ps.attachment_id IS NULL", + $slug, + $slug + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $total += (int) $wpdb->rows_affected; + } + + if ( $total > 0 ) { + self::sync_aggregate_queued_status(); + + if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { + Scheduler::schedule_single( self::AS_HOOK ); + } + } + + return $total; + } + + /** + * Marks specific attachments as queued for all active providers and schedules a scan batch. + * + * Idempotent: already-queued or scanned rows are left unchanged; + * error rows are reset to queued. * * @param array $attachment_ids Attachment IDs to queue. Must be non-empty. * @@ -165,49 +252,58 @@ class ExternalScanner { $ids = array_map( 'intval', $attachment_ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); + $providers = self::get_active_providers(); - // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare - $wpdb->query( - $wpdb->prepare( - "UPDATE {$wpdb->prefix}mra_media_index - SET external_status = 'queued', external_scanned_at = NULL - WHERE attachment_id IN ({$placeholders}) - AND external_status != 'queued'", - ...$ids - ) - ); - // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + foreach ( $providers as $provider ) { + $slug = $provider->provider_slug(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) + SELECT attachment_id, %s, 'queued' + FROM {$wpdb->prefix}mra_media_index + WHERE attachment_id IN ({$placeholders}) + ON DUPLICATE KEY UPDATE status = IF(status NOT IN ('scanned','queued'),'queued',status)", + array_merge( array( $slug ), $ids ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + } + + self::sync_aggregate_queued_status(); - // Schedule directly — do not call schedule() which would also mark all 'pending' items. if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** - * Re-queues all attachments that previously failed with external_status = 'error'. + * Re-queues all provider rows that previously failed with status = 'error'. * - * Does not touch pending, queued, scanned, or matched attachments. + * Does not touch pending, queued, or scanned rows. * Schedules a background scan batch if any items were re-queued and * no batch is already pending. * - * @return int Number of attachments re-queued. + * @return int Number of provider rows re-queued. */ public static function requeue_errors(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'error'" + "SELECT COUNT(*) FROM {$wpdb->prefix}mra_provider_status WHERE status = 'error'" ); if ( $count > 0 ) { $wpdb->query( - "UPDATE {$wpdb->prefix}mra_media_index - SET external_status = 'queued', external_scanned_at = NULL - WHERE external_status = 'error'" + "UPDATE {$wpdb->prefix}mra_provider_status + SET status = 'queued', scanned_at = NULL + WHERE status = 'error'" ); + self::sync_aggregate_queued_status(); + if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } @@ -243,6 +339,14 @@ class ExternalScanner { ) ); + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}mra_provider_status + WHERE attachment_id IN ({$placeholders})", + ...$ids + ) + ); + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mra_media_index @@ -257,7 +361,7 @@ class ExternalScanner { /** * Deletes all external scan results and resets every attachment's external_status to pending. * - * @return int Number of result rows deleted. + * @return int Number of result rows deleted from mra_external_results. */ public static function purge_all(): int { global $wpdb; @@ -265,6 +369,7 @@ class ExternalScanner { // phpcs:disable WordPress.DB.DirectDatabaseQuery $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_external_results" ); $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_external_results" ); + $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_provider_status" ); $wpdb->query( "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'pending', external_scanned_at = NULL" @@ -277,6 +382,8 @@ class ExternalScanner { /** * Returns a count of attachments grouped by external_status. * + * Reads from the mra_media_index aggregate column for display purposes. + * * @return array */ public static function get_status_counts(): array { @@ -315,35 +422,55 @@ class ExternalScanner { // ------------------------------------------------------------------------- /** - * Scans a single attachment with all providers and updates the index. + * Scans a single attachment with specified providers and updates per-provider status. + * + * After scanning all providers, calls update_aggregate_status() to sync + * the mra_media_index aggregate column. * * @param int $attachment_id WordPress attachment ID. * @param string $file_url Public URL of the image. - * @param array $providers Resolved provider list. + * @param array $providers Resolved provider list for this attachment. * * @return void */ private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void { global $wpdb; - $any_success = false; - $any_match = false; - foreach ( $providers as $provider ) { + $slug = $provider->provider_slug(); + try { $result = $provider->scan( $attachment_id, $file_url ); - self::save_result( $attachment_id, $provider->provider_slug(), $result ); - $any_success = true; - if ( $result->match_count() > 0 ) { - $any_match = true; - } + self::save_result( $attachment_id, $slug, $result ); + + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$wpdb->prefix}mra_provider_status + SET status = 'scanned', scanned_at = NOW() + WHERE attachment_id = %d AND provider = %s", + $attachment_id, + $slug + ) + ); } catch ( \RuntimeException $e ) { + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$wpdb->prefix}mra_provider_status + SET status = 'error', scanned_at = NOW() + WHERE attachment_id = %d AND provider = %s", + $attachment_id, + $slug + ) + ); + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( '[MRA] %s scan failed for attachment %d: %s', - $provider->provider_slug(), + $slug, $attachment_id, $e->getMessage() ) @@ -352,14 +479,69 @@ class ExternalScanner { } } - if ( ! $any_success ) { - $new_status = 'error'; - } elseif ( $any_match ) { - $new_status = 'matches'; - } else { - $new_status = 'scanned'; + self::update_aggregate_status( $attachment_id ); + } + + /** + * Recomputes and persists the aggregate external_status for a single attachment. + * + * Logic: + * - Any provider still queued → 'queued' + * - No provider succeeded → 'error' + * - At least one match found → 'matches' + * - Otherwise → 'scanned' + * + * @param int $attachment_id WordPress attachment ID. + * + * @return void + */ + private static function update_aggregate_status( int $attachment_id ): void { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT + SUM(status = 'queued') AS queued_count, + SUM(status = 'error') AS error_count, + SUM(status = 'scanned') AS scanned_count + FROM {$wpdb->prefix}mra_provider_status + WHERE attachment_id = %d", + $attachment_id + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( ! is_array( $row ) ) { + return; } + $queued_count = isset( $row['queued_count'] ) && is_numeric( $row['queued_count'] ) ? (int) $row['queued_count'] : 0; + $error_count = isset( $row['error_count'] ) && is_numeric( $row['error_count'] ) ? (int) $row['error_count'] : 0; + $scanned_count = isset( $row['scanned_count'] ) && is_numeric( $row['scanned_count'] ) ? (int) $row['scanned_count'] : 0; + + if ( $queued_count > 0 ) { + $new_status = 'queued'; + } elseif ( 0 === $scanned_count ) { + $new_status = 'error'; + } else { + // Check for matches across scanned providers. + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $match_sum = $wpdb->get_var( + $wpdb->prepare( + "SELECT SUM(match_count) FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d", + $attachment_id + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $new_status = ( is_numeric( $match_sum ) && (int) $match_sum > 0 ) ? 'matches' : 'scanned'; + } + + // Suppress unused variable inspection: $error_count is available for future use. + unset( $error_count ); + $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_media_index', array( @@ -372,6 +554,57 @@ class ExternalScanner { ); } + /** + * Updates mra_media_index.external_status to 'queued' for all attachments + * that have at least one queued row in mra_provider_status. + * + * Only updates rows not already marked 'queued', to avoid unnecessary writes. + * + * @return void + */ + private static function sync_aggregate_queued_status(): void { + global $wpdb; + + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$wpdb->prefix}mra_media_index mi + INNER JOIN ( + SELECT DISTINCT attachment_id + FROM {$wpdb->prefix}mra_provider_status + WHERE status = 'queued' + ) AS q ON q.attachment_id = mi.attachment_id + SET mi.external_status = 'queued' + WHERE mi.external_status != 'queued'" + ); + } + + /** + * Inserts or updates mra_provider_status rows for all indexed attachments. + * + * For attachments without an existing row for this provider, inserts 'queued'. + * For attachments with an existing non-scanned, non-queued row, resets to 'queued'. + * Scanned and already-queued rows are left unchanged. + * + * @param string $provider_slug Provider machine-readable slug. + * + * @return void + */ + private static function queue_provider_attachments( string $provider_slug ): void { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) + SELECT attachment_id, %s, 'queued' + FROM {$wpdb->prefix}mra_media_index + ON DUPLICATE KEY UPDATE status = IF(status NOT IN ('scanned','queued'),'queued',status)", + $provider_slug + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + /** * Persists a provider's scan result (upsert via REPLACE INTO). * diff --git a/languages/robotstxt-mediaaudit-ca.mo b/languages/robotstxt-mediaaudit-ca.mo index 90b83b0..65f8a2d 100644 Binary files a/languages/robotstxt-mediaaudit-ca.mo and b/languages/robotstxt-mediaaudit-ca.mo differ diff --git a/languages/robotstxt-mediaaudit-ca.po b/languages/robotstxt-mediaaudit-ca.po index 07b72d0..4b416d8 100644 --- a/languages/robotstxt-mediaaudit-ca.po +++ b/languages/robotstxt-mediaaudit-ca.po @@ -973,6 +973,18 @@ msgstr "Última exploració (externa)" msgid "Scanned At" msgstr "Explorat el" +#: includes/Admin/ToolsPage.php:365 +msgid "Provider registry synced. New scans scheduled." +msgstr "Registre de proveïdors sincronitzat. Nous escanejos programats." + +#: includes/Admin/ToolsPage.php:642 +msgid "Sync provider registry" +msgstr "Sincronitza el registre de proveïdors" + +#: includes/Admin/ToolsPage.php:643 +msgid "Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected." +msgstr "Crea entrades d'escaneig per als proveïdors externs registrats recentment. Executa-ho després d'afegir un nou proveïdor mitjançant el filtre mra/external/providers. Els fitxers ja escanejats no es veuen afectats." + #~ msgid "https://robotstxt.es/plugins/media-audit/" #~ msgstr "https://robotstxt.es/plugins/media-audit/" diff --git a/languages/robotstxt-mediaaudit-es_ES.mo b/languages/robotstxt-mediaaudit-es_ES.mo index bd685f0..97518fa 100644 Binary files a/languages/robotstxt-mediaaudit-es_ES.mo and b/languages/robotstxt-mediaaudit-es_ES.mo differ diff --git a/languages/robotstxt-mediaaudit-es_ES.po b/languages/robotstxt-mediaaudit-es_ES.po index 3b5e1d5..77d3d8c 100644 --- a/languages/robotstxt-mediaaudit-es_ES.po +++ b/languages/robotstxt-mediaaudit-es_ES.po @@ -969,6 +969,18 @@ msgstr "Último escaneo (externo)" msgid "Scanned At" msgstr "Escaneado el" +#: includes/Admin/ToolsPage.php:365 +msgid "Provider registry synced. New scans scheduled." +msgstr "Registro de proveedores sincronizado. Nuevos escaneos programados." + +#: includes/Admin/ToolsPage.php:642 +msgid "Sync provider registry" +msgstr "Sincronizar registro de proveedores" + +#: includes/Admin/ToolsPage.php:643 +msgid "Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected." +msgstr "Crea entradas de escaneo para los proveedores externos recién registrados. Ejecútalo después de añadir un nuevo proveedor mediante el filtro mra/external/providers. Los archivos ya escaneados no se ven afectados." + #~ msgid "https://robotstxt.es/plugins/media-audit/" #~ msgstr "https://robotstxt.es/plugins/media-audit/" diff --git a/readme.txt b/readme.txt index b9c4a00..3668246 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Requires at least: 6.8 Tested up to: 7.0 Requires PHP: 8.2 Requires Plugins: action-scheduler -Stable tag: 1.2.0 +Stable tag: 1.3.0 License: GPL-3.0-or-later License URI: https://www.gnu.org/licenses/gpl-3.0.txt @@ -98,6 +98,13 @@ By default, no. Enable **Settings → Delete data on uninstall** if you want all Only the 3 latest versions. The full changelog is in [changelog.txt](changelog.txt). += 1.3.0 = + +* Added per-provider scan tracking table (`mra_provider_status`) so each API is tracked independently. +* Added "Sync providers" operation: registers newly configured providers for all existing attachments without rescanning already-completed work. +* Changed `mra_external_results.provider` from a fixed ENUM to `varchar(100)` for extensibility. +* DB schema bumped to 1.1.0 with an automatic migration on update. + = 1.2.0 = * Added browser-based AJAX scan runner in the Tools page (runs without WP-Cron or Action Scheduler). diff --git a/robotstxt-mediaaudit.php b/robotstxt-mediaaudit.php index 9874634..de6e183 100644 --- a/robotstxt-mediaaudit.php +++ b/robotstxt-mediaaudit.php @@ -3,7 +3,7 @@ * Plugin Name: Media Audit (by ROBOTSTXT) * Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-mediaaudit * Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues. - * Version: 1.2.0 + * Version: 1.3.0 * Requires at least: 6.8 * Tested up to: 7.0 * Requires PHP: 8.2 @@ -23,8 +23,8 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.2.0' ); -define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.0.1' ); +define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.3.0' ); +define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.1.0' ); define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ ); define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/uninstall.php b/uninstall.php index b4afef9..19ca27f 100644 --- a/uninstall.php +++ b/uninstall.php @@ -23,6 +23,7 @@ if ( ! $delete ) { global $wpdb; // Drop tables in reverse dependency order. +$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery