post_content === $before->post_content && $post->post_status === $before->post_status && $post->post_name === $before->post_name && $post->post_password === $before->post_password ) { return; } try { self::enqueue( (string) get_permalink( $id ) ); } catch ( \RuntimeException $e ) { // Another Image Manager writer holds the owner lock: not a failure. The page is queued once it is free (F-IM-18). if ( ! Reviewed_Store::busy( $e ) || ! Tracking::defer( array( 'discovery:' . (int) $id ) ) ) { throw $e; } } } ); } /** * The active theme was installed or updated (upgrader_process_complete at 20): its pages are queued for a recheck. * * @param mixed $upgrader The upgrader. * @param mixed $extra What it installed or updated. */ public static function theme_updated( $upgrader, $extra ): void { self::observe( static function () use ( $upgrader, $extra ) { if ( ! self::enabled() || 'theme' !== ( $extra['type'] ?? '' ) || ! in_array( $extra['action'] ?? '', array( 'install', 'update' ), true ) ) { return; } $themes = (array) ( $extra['themes'] ?? array( $extra['theme'] ?? '' ) ); // Upload-and-replace uses the install action without a theme slug in hook_extra. if ( 'install' === $extra['action'] && is_object( $upgrader ) && is_array( $upgrader->result ?? null ) ) { $themes[] = $upgrader->result['destination_name'] ?? ''; } if ( array_intersect( $themes, array( get_template(), get_stylesheet() ) ) ) { self::seed(); } } ); } /** Another theme was switched on (after_switch_theme at 20): its pages are queued for a recheck. */ public static function theme_switched(): void { self::observe( static function () { if ( self::enabled() ) { self::seed(); } } ); } /** * Optional tracking must never break an importer, native save, or theme update. * * @param callable $action The tracking work to do. */ private static function observe( callable $action ): void { try { $action(); } catch ( \Throwable $e ) { Reviewed_Store::update_option( self::NOTICE, 'queue_write_failed', false ); } } /** * A page saved while Image Manager was busy (Tracking replays it under the owner lock). * * @param int $id The post. */ public static function recheck( int $id ): void { self::observe( static function () use ( $id ) { if ( self::enabled() && Sources::public_post( $id ) ) { self::enqueue( (string) get_permalink( $id ) ); } } ); } /** * Whether rechecks are on: a single site, the setting on and the public-pictures confirmation given. * * @return bool True when they are. */ public static function enabled(): bool { $s = Settings::fresh(); return ! is_multisite() && ! empty( $s['auto_discovery'] ) && ! empty( $s['public_policy'] ); } /** * An empty queue. * * @return array Jobs, last results, issues and revision. */ private static function empty_state(): array { return array( 'jobs' => array(), 'last' => array(), 'issues' => array(), 'revision' => '', ); } /** * The queue as stored (read past every cache). * * @return array The stored text (null when there is none) and the queue. * @throws \RuntimeException With invalid_auto_queue when the stored queue cannot be read. */ private static function read(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/CAS the authoritative discovery state and live page index; stale option/object-cache data cannot arbitrate concurrent workers. $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, self::OPTION ) ); $state = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( null !== $raw && ( ! is_array( $state ) || ! isset( $state['jobs'] ) || ! is_array( $state['jobs'] ) || count( $state['jobs'] ) > self::LIMIT ) ) { throw new \RuntimeException( 'invalid_auto_queue' ); } return array( $raw, $state ?: self::empty_state() ); } /** * Compare-and-swap the bounded, non-autoloaded queue. Concurrent page saves cannot overwrite each other. * * @param callable $edit Changes the queue in place; returns false to change nothing. * @return bool True when the change was saved, false when the edit made none. * @throws \RuntimeException With auto_queue_busy when the queue cannot be written in eight attempts. */ private static function change( callable $edit ): bool { global $wpdb; for ( $i = 0; $i < 8; ++$i ) { list( $raw, $state ) = self::read(); if ( null === $raw ) { Reviewed_Store::add_option( self::OPTION, wp_json_encode( self::empty_state() ), '', false ); continue; } if ( false === $edit( $state ) ) { return false; } $state['revision'] = bin2hex( random_bytes( 16 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read/CAS the authoritative discovery state and live page index; stale option/object-cache data cannot arbitrate concurrent workers. $saved = Reviewed_Store::database_query( $wpdb->prepare( 'UPDATE %i SET option_value=%s WHERE option_name=%s AND option_value=%s', $wpdb->options, wp_json_encode( $state ), self::OPTION, $raw ) ); if ( 1 === $saved ) { wp_cache_delete( self::OPTION, 'options' ); return true; } if ( false === $saved ) { break; } } throw new \RuntimeException( 'auto_queue_busy' ); } /** * Existing Home/Shop and one-hop scope only; this does not expand a site's crawl. * * @param string $url The page's address. * @return bool True when it is the home page, the shop or a scanned page at most one step from them. */ private static function known( string $url ): bool { global $wpdb; $home = Urls::page( home_url( '/' ) ); if ( ! is_wp_error( $home ) && $url === $home ) { return true; } if ( function_exists( 'wc_get_page_id' ) ) { $shop = (int) wc_get_page_id( 'shop' ); if ( $shop > 0 && Sources::public_post( $shop ) && $url === Urls::page( get_permalink( $shop ) ) ) { return true; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/CAS the authoritative discovery state and live page index; stale option/object-cache data cannot arbitrate concurrent workers. return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE url_key=%s AND depth<=1 LIMIT 1', Repository::table( 'pages' ), hash( 'sha256', $url ) ) ); } /** * Queue a known public page for a recheck. * * @param string $url The page's address. * @return bool True when it was queued. */ public static function enqueue( string $url ): bool { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! self::enabled() || strlen( $url ) > 2048 ) { return false; } $url = Urls::page( $url ); if ( is_wp_error( $url ) || ! self::known( $url ) ) { return false; } $key = hash( 'sha256', $url ); $now = time(); $full = false; $saved = self::change( static function ( &$state ) use ( $key, $url, $now, &$full ) { if ( ! isset( $state['jobs'][ $key ] ) && count( $state['jobs'] ) >= self::LIMIT ) { $full = true; return false; } $previous = $state['jobs'][ $key ] ?? array(); // Coalesce bursts but do not let a continuously edited page postpone work forever. $first = (int) ( $previous['first'] ?? $now ); $state['jobs'][ $key ] = array( 'url' => $url, 'token' => bin2hex( random_bytes( 16 ) ), 'first' => $first, 'due' => min( $now + self::DELAY, max( $now, $first + 300 ) ), 'attempts' => 0, ); return true; } ); if ( $full ) { Reviewed_Store::update_option( self::NOTICE, 'queue_full', false ); } if ( $saved ) { Scheduler::needed(); } return $saved; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Queue every known page: the home page, the shop and the scanned pages one step from them. */ public static function seed(): void { global $wpdb; if ( ! self::enabled() ) { return; } $urls = array( home_url( '/' ) ); if ( function_exists( 'wc_get_page_id' ) && (int) wc_get_page_id( 'shop' ) > 0 ) { $urls[] = get_permalink( wc_get_page_id( 'shop' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/CAS the authoritative discovery state and live page index; stale option/object-cache data cannot arbitrate concurrent workers. $urls = array_unique( array_merge( $urls, $wpdb->get_col( $wpdb->prepare( 'SELECT url FROM %i WHERE depth<=1 ORDER BY depth,id LIMIT %d', Repository::table( 'pages' ), self::LIMIT + 1 ) ) ) ); foreach ( $urls as $url ) { self::enqueue( (string) $url ); } } /** * After the settings changed: empty the queue when rechecks are off; queue the known pages when rechecks (or theme * pictures) were just switched on. * * @param array $before The settings before the change. */ public static function configured( array $before ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { self::observe( static function () use ( $before ) { if ( ! self::enabled() ) { if ( get_option( self::OPTION, false ) !== false ) { self::change( static function ( &$state ) { $state['jobs'] = array(); $state['issues'] = array(); return true; } ); } Reviewed_Store::delete_option( self::NOTICE ); } elseif ( empty( $before['auto_discovery'] ) || empty( $before['public_policy'] ) || ( empty( $before['theme_images'] ) && Settings::get()['theme_images'] ) ) { Reviewed_Store::delete_option( self::NOTICE ); self::seed(); } } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The rechecks' state for the screens. * * @return array Whether on, pending and retrying counts, next and oldest times, the last results, issues and notice. */ public static function snapshot(): array { $enabled = self::enabled(); try { $state = self::read()[1]; } catch ( \Throwable $e ) { return array( 'enabled' => $enabled, 'pending' => 0, 'retrying' => 0, 'last' => array(), 'issues' => 0, 'notice' => 'queue_write_failed', ); } return array( 'enabled' => $enabled, 'pending' => $enabled ? count( $state['jobs'] ) : 0, 'next_due' => $state['jobs'] ? min( array_column( $state['jobs'], 'due' ) ) : 0, 'oldest' => $state['jobs'] ? min( array_column( $state['jobs'], 'first' ) ) : 0, 'retrying' => $enabled ? count( array_filter( $state['jobs'], static function ( $job ) { return ! empty( $job['attempts'] ); } ) ) : 0, 'last' => (array) ( $state['last'] ?? array() ), 'issues' => count( $state['issues'] ?? array() ), 'notice' => (string) get_option( self::NOTICE, '' ), ); } /** * Whether rechecks wait. * * @return bool True when they do. */ public static function pending(): bool { return self::enabled() && ! empty( self::snapshot()['pending'] ); } /** * Whether a queued job is still the one this step took. * * @param string $key The job's key. * @param array $job The job as taken. * @return bool True when it is. */ private static function current( string $key, array $job ): bool { $now = self::read()[1]['jobs'][ $key ] ?? array(); return ( $now['token'] ?? '' ) === $job['token'] && ( $now['attempts'] ?? -1 ) === $job['attempts']; } /** * Record a recheck's result, when the job is still the one taken: it leaves the queue unless it is to be retried, and * a failed or limited page is kept as an issue. * * @param string $key The job's key. * @param array $job The job as taken. * @param string $status Its result. * @param string $reason Why, when it did not complete. * @param int $images Pictures found. * @param int $queued Pictures queued. */ private static function finish( string $key, array $job, string $status, string $reason = '', int $images = 0, int $queued = 0 ): void { self::change( static function ( &$state ) use ( $key, $job, $status, $reason, $images, $queued ) { $current = $state['jobs'][ $key ] ?? array(); if ( ( $current['token'] ?? '' ) !== $job['token'] || ( $current['attempts'] ?? -1 ) !== $job['attempts'] ) { return false; } if ( 'retry' !== $status ) { unset( $state['jobs'][ $key ] ); } if ( in_array( $status, array( 'failed', 'limited' ), true ) ) { $state['issues'][ $key ] = $reason ?: 'page_limit'; $state['issues'] = array_slice( $state['issues'], -self::LIMIT, null, true ); } elseif ( in_array( $status, array( 'checked', 'skipped' ), true ) ) { unset( $state['issues'][ $key ] ); } $state['last'] = array( 'at' => time(), 'status' => $status, 'reason' => $reason, 'images' => $images, 'queued' => $queued, ); return true; } ); } /** * One anonymous page per existing worker lease; manual/bulk scan records are never reset. * * @param string $token The worker's lease. * @return bool True when a page was rechecked. */ public static function step( string $token ): bool { try { return self::advance( $token ); } catch ( \Throwable $e ) { Reviewed_Store::update_option( self::NOTICE, 'queue_write_failed', false ); return false; } } /** * Recheck the next due page, when no run or scan is active and processing is not paused. * * @param string $token The worker's lease. * @return bool True when a page was rechecked. */ private static function advance( string $token ): bool { if ( ! Repository::owns( $token ) || ! self::enabled() || Settings::fresh()['paused'] || Workflow::job()['active'] || Scanner::pending() ) { return false; } $selected = null; foreach ( self::read()[1]['jobs'] as $key => $job ) { if ( (int) $job['due'] <= time() ) { $selected = array( $key, $job ); break; } } if ( ! $selected ) { return false; } list( $key, $job ) = $selected; $reserved = $job; ++$reserved['attempts']; $reserved['due'] = time() + 60 * ( 2 ** min( 3, $reserved['attempts'] ) ); $claimed = self::change( static function ( &$state ) use ( $key, $job, $reserved ) { if ( ( $state['jobs'][ $key ]['token'] ?? '' ) !== $job['token'] || ( $state['jobs'][ $key ]['attempts'] ?? -1 ) !== $job['attempts'] ) { return false; } $state['jobs'][ $key ] = $reserved; return true; } ); if ( ! $claimed ) { return false; } $job = $reserved; try { if ( $job['attempts'] > 3 ) { self::finish( $key, $job, 'failed', 'retry_limit' ); return true; } $url = Urls::page( $job['url'] ); if ( is_wp_error( $url ) || ! self::known( $url ) ) { self::finish( $key, $job, 'skipped', 'page_no_longer_allowed' ); return true; } $response = Urls::fetch( $url ); if ( ! Repository::owns( $token ) || ! self::current( $key, $job ) || ! self::enabled() || Settings::fresh()['paused'] ) { return true; } if ( is_wp_error( $response ) ) { $reason = sanitize_key( $response->get_error_code() ); $retry = in_array( $reason, array( 'http_request_blocked', 'dns_unavailable', 'http_status_408', 'http_status_429', 'http_status_500', 'http_status_502', 'http_status_503', 'http_status_504' ), true ) && $job['attempts'] < 3; self::finish( $key, $job, $retry ? 'retry' : 'failed', $reason ); return true; } // Revalidate after the fetch as native content/visibility may have changed while it was in flight. if ( is_wp_error( Urls::page( $url ) ) || is_wp_error( Urls::page( $response['url'] ) ) ) { self::finish( $key, $job, 'skipped', 'page_no_longer_allowed' ); return true; } $scan = array( 'capped' => false, 'skipped_links' => 0, 'unresolved_images' => 0, 'external_images' => 0, ); $observed = array(); $images = Scanner::parse( $response['html'], $response['url'], 1, 0, $scan, $observed ); $queued = 0; foreach ( array_keys( $observed ) as $id ) { if ( ! Repository::owns( $token ) || ! self::enabled() || Settings::fresh()['paused'] ) { return true; } $row = Repository::row( (int) $id ); if ( $row && ! $row['excluded'] && $row['delivery'] && empty( $row['manifest']['draft'] ) && in_array( $row['state'], array( 'discovered', 'held' ), true ) && Sources::eligible( $row ) ) { Repository::request( (int) $id, 'upload' ); ++$queued; } } if ( ! Repository::owns( $token ) ) { return true; } self::finish( $key, $job, $scan['capped'] ? 'limited' : 'checked', '', $images, $queued ); } catch ( \Throwable $e ) { // A persisted reservation bounds retries even after an interrupted request. Do not leak paths/errors. if ( Repository::owns( $token ) ) { self::finish( $key, $job, $job['attempts'] < 3 ? 'retry' : 'failed', 'check_interrupted' ); } } return true; } /** * The rechecks' panel. * * @param array $view The snapshot. * @return string The panel's HTML, or an empty string. */ public static function render( array $view ): string { if ( ! $view ) { return ''; } $html = '

' . esc_html__( 'Automatic discovery', 'affstackdev-image-manager' ) . '

'; if ( ! $view['enabled'] ) { $html .= '

' . esc_html__( 'Off. Enable automatic page rechecks in Settings to pick up new images added through supported page saves and active-theme updates.', 'affstackdev-image-manager' ) . '

'; } else { /* translators: %d: Number of pending public-page rechecks. */ $html .= '

' . esc_html( sprintf( __( '%d page rechecks waiting. Rechecks wait for the current run to finish and respect Pause.', 'affstackdev-image-manager' ), $view['pending'] ) ) . '

'; if ( ( $view['next_due'] ?? 0 ) > time() ) { /* translators: %s: UTC time in hours, minutes and seconds. */ $html .= '

' . esc_html( sprintf( __( 'Next page becomes eligible after %s UTC; its start depends on background processing.', 'affstackdev-image-manager' ), gmdate( 'H:i:s', $view['next_due'] ) ) ) . '

'; } if ( ! empty( $view['retrying'] ) ) { $html .= '

' . esc_html__( 'A previous request is waiting for its retry time. Progress resumes through background processing or the Optimize workspace.', 'affstackdev-image-manager' ) . '

'; } $last = $view['last']; if ( ! empty( $last['at'] ) ) { $labels = array( 'checked' => __( 'Public page checked', 'affstackdev-image-manager' ), 'limited' => __( 'Page check reached its limit; coverage is incomplete', 'affstackdev-image-manager' ), 'skipped' => __( 'Page skipped because its public scope changed', 'affstackdev-image-manager' ), 'retry' => __( 'Page check will retry', 'affstackdev-image-manager' ), 'failed' => __( 'Page check failed; run optimization to try again', 'affstackdev-image-manager' ), ); $html .= '

' . esc_html( ( $labels[ $last['status'] ] ?? __( 'Last check', 'affstackdev-image-manager' ) ) . ' · ' . gmdate( 'Y-m-d H:i:s', $last['at'] ) . ' UTC' ) . '

'; if ( in_array( $last['status'], array( 'checked', 'limited' ), true ) ) { /* translators: 1: Observed image-element count; 2: Eligible queued image count. */ $html .= '

' . esc_html( sprintf( __( 'Last page: %1$d image elements observed; %2$d eligible images queued. Previously prepared copies keep their settings.', 'affstackdev-image-manager' ), $last['images'], $last['queued'] ) ) . '

'; } elseif ( ! empty( $last['reason'] ) ) { $html .= '

' . esc_html( __( 'Reason:', 'affstackdev-image-manager' ) . ' ' . $last['reason'] ) . '

'; } } } if ( ! empty( $view['notice'] ) ) { $html .= '

' . esc_html( 'queue_full' === $view['notice'] ? __( 'The automatic queue reached 64 pages. Run optimization to check remaining pages.', 'affstackdev-image-manager' ) : __( 'An automatic update could not be saved. Run optimization and review background processing.', 'affstackdev-image-manager' ) ) . '

'; } if ( ! empty( $view['issues'] ) ) { /* translators: %d: Number of recent unsuccessful or limited page rechecks. */ $html .= '

' . esc_html( sprintf( __( '%d recent page rechecks failed or reached a limit. Later successes do not clear another page’s problem. Check scanned pages in Tools and run optimization for a broader check.', 'affstackdev-image-manager' ), $view['issues'] ) ) . '

'; } return $html . '
'; } }