'', 'active' => false, 'mode' => 'optimize', 'cursor' => 0, 'started' => 0, 'finished' => 0, 'resumed' => 0, 'cancelled' => false, ), (array) get_option( self::OPTION, array() ) ); } /** * Start a run: an optimization (with the public-pictures confirmation) or a scan only. * * @param bool $optimize True to optimize, false to scan only. * @param bool $policy The public-pictures confirmation given with this start. * @param bool $delivery The delivery switch saved with the start. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function start( bool $optimize, bool $policy, bool $delivery ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $s = Settings::fresh(); if ( $optimize && ! $policy && ! $s['public_policy'] ) { throw new \RuntimeException( 'Please confirm that these are public website images.' ); } $token = Repository::acquire(); if ( ! $token ) { throw new \RuntimeException( 'worker_busy' ); } try { Settings::fresh_option( self::OPTION, array() ); if ( self::job()['active'] ) { throw new \RuntimeException( 'An existing run is saved. Resume it or stop it before starting another.' ); } Scanner::start( (bool) $s['catalog'], (bool) $s['comments'], $token ); $values = array( 'paused' => false, 'pause_reason' => '', 'generation' => (int) $s['generation'] + 1, ); if ( $optimize ) { $values['public_policy'] = true; $values['delivery'] = $delivery; } Settings::patch( $values ); Reviewed_Store::update_option( 'afsd_im_failures', 0, false ); Reviewed_Store::update_option( self::OPTION, array( 'id' => bin2hex( random_bytes( 12 ) ), 'active' => true, 'mode' => $optimize ? 'optimize' : 'scan', 'cursor' => 0, 'started' => time(), 'finished' => 0, 'resumed' => 0, 'cancelled' => false, ), false ); Activity::add( $optimize ? 'started' : 'scan_started' ); } finally { Repository::release( $token ); } Scheduler::check(); Scheduler::needed(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Pause processing and stop scheduled work; prepared copies and originals stay. */ public static function pause(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { Settings::pause( __( 'Paused by you. Prepared copies and originals are retained.', 'affstackdev-image-manager' ) ); Scheduler::stop_work(); Activity::add( 'paused' ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Resume processing and schedule work again. */ public static function resume(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { Settings::patch( array( 'paused' => false, 'pause_reason' => '', ) ); Reviewed_Store::update_option( 'afsd_im_failures', 0, false ); Reviewed_Store::update_option( 'afsd_im_last_resume', time(), false ); Scheduler::check(); Scheduler::needed(); Activity::add( 'resumed' ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Stop the run: pause processing, mark the run cancelled, stop the scan, hold the queued pictures (reason cancelled) * and skip the bulk items not finished; prepared copies and originals stay. * * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function cancel(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { global $wpdb; $token = Repository::acquire(); if ( ! $token ) { throw new \RuntimeException( 'worker_busy' ); } try { Settings::pause( __( 'Run stopped. Originals and prepared copies are retained.', 'affstackdev-image-manager' ) ); $job = self::job(); $job['active'] = false; $job['cancelled'] = true; $job['finished'] = time(); Reviewed_Store::update_option( self::OPTION, $job, false ); $scan = Scanner::state(); $scan['active'] = false; $scan['catalog_page'] = 0; $scan['comment_offset'] = -1; Scanner::save( $scan ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded reads/writes of plugin job, cursor and source totals; independent workers update these tables throughout the run. Reviewed_Store::database_query( $wpdb->prepare( "UPDATE %i SET state='held',requested=0,reason='cancelled' WHERE state='queued'", Repository::table( 'sources' ) ) ); Batch::cancel( $job ); Activity::add( 'cancelled' ); } finally { Repository::release( $token ); } Scheduler::reconcile(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Bounded inventory traversal; only the worker holding the lease calls this. * * @param string $owned_token The worker's lease. */ public static function pump( string $owned_token = '' ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { global $wpdb; Settings::fresh_option( self::OPTION, array() ); $job = self::job(); if ( ! $job['active'] || $job['cancelled'] || Settings::fresh()['paused'] ) { return; } if ( 'bulk' === $job['mode'] ) { Batch::pump( $owned_token ); return; } $scan = Scanner::state(); $pages = Repository::counts( 'pages' ); if ( ! $scan['active'] && ! empty( $pages['waiting'] ) ) { Scanner::continue_scan(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded reads/writes of plugin job, cursor and source totals; independent workers update these tables throughout the run. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id>%d ORDER BY id ASC LIMIT 25', Repository::table( 'sources' ), (int) $job['cursor'] ), ARRAY_A ); foreach ( $rows as $raw ) { $r = Repository::decode( $raw ); $job['cursor'] = (int) $r['id']; if ( 'optimize' !== $job['mode'] || $r['excluded'] || ! in_array( $r['state'], array( 'discovered', 'held' ), true ) ) { continue; } if ( ! empty( $r['attachment_id'] ) || Theme::is_source( $r ) ) { Repository::request( (int) $r['id'] ); } } if ( ! $rows && ! Scanner::pending() && empty( $pages['waiting'] ) && 'optimize' === $job['mode'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded reads/writes of plugin job, cursor and source totals; independent workers update these tables throughout the run. $late = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE state='discovered' AND excluded=0 ORDER BY id ASC LIMIT 25", Repository::table( 'sources' ) ), ARRAY_A ); foreach ( $late as $raw ) { $r = Repository::decode( $raw ); Repository::request( (int) $r['id'] ); } } $counts = Repository::counts(); if ( ! $rows && ! Scanner::pending() && empty( $pages['waiting'] ) && empty( $counts['queued'] ) ) { $job['active'] = false; $job['finished'] = time(); Activity::add( 'completed' ); } Reviewed_Store::update_option( self::OPTION, $job, false ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Whether there is work left: a migration, an active run, the scan, queued pictures or page rechecks. * * @return bool True when there is. */ public static function pending(): bool { $counts = Repository::counts(); return ! get_option( 'afsd_im_totals_migrated', false ) || ! get_option( 'afsd_im_records_migrated', false ) || self::job()['active'] || Scanner::pending() || ! empty( $counts['queued'] ) || Auto_Discovery::pending(); } /** * A soft budget between indivisible operations, NOT an interrupt guarantee for a native encoder. * * @param string $origin browser, cli or background (background steps are shorter). */ public static function run_batch( string $origin = 'browser' ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $background = 'background' === $origin; $started = microtime( true ); $deadline = $started + ( $background ? 4.0 : 6.0 ); $units = 0; for ( $i = 0; $i < ( $background ? 4 : 8 ) && microtime( true ) < $deadline; ++$i ) { if ( Settings::fresh()['paused'] || ! self::pending() ) { break; } Sources::forget_cache(); if ( ! Engine::tick() ) { break; } ++$units; } Reviewed_Store::update_option( 'afsd_im_last_batch', array( 'origin' => $background ? 'background' : ( 'cli' === $origin ? 'manual CLI' : 'browser assistance' ), 'finished' => time(), 'units' => $units, 'milliseconds' => (int) round( 1000 * ( microtime( true ) - $started ) ), 'peak_memory' => memory_get_peak_usage( true ), ), false ); if ( ! $background ) { Scheduler::reconcile(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The figures and words the Optimize screen and the status command show for the run. * * @return array The snapshot. */ public static function snapshot(): array { global $wpdb; $counts = Repository::counts(); $pages = Repository::counts( 'pages' ); $job = self::job(); $s = Settings::get(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded reads/writes of plugin job, cursor and source totals; independent workers update these tables throughout the run. $totals = $wpdb->get_row( $wpdb->prepare( 'SELECT SUM(saved_bytes) AS saved,SUM(copy_count) AS copies,SUM(copy_bytes) AS bytes FROM %i WHERE state<>%s', Repository::table( 'sources' ), 'grouped' ), ARRAY_A ) ?: array(); $buckets = Results::buckets( $counts ); $found = array_sum( $counts ); $ready = $buckets['ready']; $kept = $buckets['no_saving'] + $buckets['unsupported'] + $buckets['held']; $done = $ready + $kept + $buckets['failed'] + $buckets['excluded']; $scanning = Scanner::pending() || ( $job['active'] && ! empty( $pages['waiting'] ) ); $automatic = Auto_Discovery::snapshot(); $active = $job['active'] || $scanning || ! empty( $counts['queued'] ) || ! empty( $automatic['pending'] ); $percent = $found ? min( 100, (int) floor( 100 * $done / $found ) ) : 0; if ( $active ) { $percent = min( 99, $percent ); } elseif ( $job['finished'] && ! $job['cancelled'] ) { $percent = 100; } $label = $job['cancelled'] && ! $active ? __( 'Run stopped — your files are retained', 'affstackdev-image-manager' ) : ( $s['paused'] && $active ? __( 'Optimization paused', 'affstackdev-image-manager' ) : ( $scanning ? __( 'Finding your public site images', 'affstackdev-image-manager' ) : ( $active ? __( 'Preparing smaller image copies', 'affstackdev-image-manager' ) : ( $job['finished'] ? ( 'scan' === $job['mode'] ? __( 'Scan complete — review the results', 'affstackdev-image-manager' ) : ( ( $buckets['failed'] || ! empty( $pages['blocked'] ) ) ? __( 'Run finished — some images need attention', 'affstackdev-image-manager' ) : __( 'Finished — results are ready', 'affstackdev-image-manager' ) ) ) : __( 'Ready when you are', 'affstackdev-image-manager' ) ) ) ) ); $detail = $scanning /* translators: 1: pages checked; 2: pages found so far. */ ? sprintf( _n( 'Pages checked: %1$d of %2$d found so far. Discovery stays within the selected public sources.', 'Pages checked: %1$d of %2$d found so far. Discovery stays within the selected public sources.', array_sum( $pages ), 'affstackdev-image-manager' ), (int) ( $pages['scanned'] ?? 0 ) + (int) ( $pages['blocked'] ?? 0 ), array_sum( $pages ) ) : ( $active ? sprintf( /* translators: 1: images reviewed; 2: images found. */ _n( '%1$d of %2$d images reviewed. Progress is saved automatically.', '%1$d of %2$d images reviewed. Progress is saved automatically.', $found, 'affstackdev-image-manager' ), $done, $found ) : ( $job['cancelled'] ? __( 'Stopped work has not been deleted. Start a new run when ready.', 'affstackdev-image-manager' ) : ( 'scan' === $job['mode'] && $job['finished'] ? __( 'Discovery finished. Scan only does not add images to the optimization queue. Previously queued image work is handled separately.', 'affstackdev-image-manager' ) : ( $found ? __( 'See the result breakdown below and the reason on each image. Prepared-file savings are not browser-transfer measurements.', 'affstackdev-image-manager' ) : __( 'Ready to scan your public pages and prepare smaller copies.', 'affstackdev-image-manager' ) ) ) ) ); if ( ! $active && $job['finished'] && ! $job['cancelled'] ) { if ( ! $found ) { $detail = __( 'No images were found in the scanned public sources. Review scanned pages in Tools before changing the scan scope.', 'affstackdev-image-manager' ); } /* translators: %d: number of pages checked. */ $detail .= ' ' . sprintf( _n( 'Pages checked: %d.', 'Pages checked: %d.', (int) ( ( $pages['scanned'] ?? 0 ) + ( $pages['blocked'] ?? 0 ) ), 'affstackdev-image-manager' ), (int) ( ( $pages['scanned'] ?? 0 ) + ( $pages['blocked'] ?? 0 ) ) ); if ( ! empty( $pages['blocked'] ) ) { /* translators: %d: number of pages that could not be scanned. */ $detail .= ' ' . sprintf( _n( '%d page(s) could not be scanned; review Tools.', '%d page(s) could not be scanned; review Tools.', (int) $pages['blocked'], 'affstackdev-image-manager' ), (int) $pages['blocked'] ); } } $summary = null; if ( 'bulk' === $job['mode'] ) { $summary = Batch::summary( $job ); $done = $summary['done']; $total = $summary['total']; $percent = $total ? min( 100, (int) floor( 100 * $done / $total ) ) : ( ! $active && ! $job['cancelled'] && $job['finished'] ? 100 : 0 ); if ( $active ) { $percent = min( 99, $percent ); } $label = $s['paused'] && $active ? __( 'Bulk run paused', 'affstackdev-image-manager' ) : ( $scanning ? __( 'Finding public images for your selected policy', 'affstackdev-image-manager' ) : ( $active ? __( 'Applying your bulk choices', 'affstackdev-image-manager' ) : ( $job['cancelled'] ? __( 'Bulk run stopped', 'affstackdev-image-manager' ) : __( 'Bulk run finished', 'affstackdev-image-manager' ) ) ) ); $detail = $scanning /* translators: 1: Checked pages; 2: Pages found so far; 3: Waiting pages; 4: Unavailable pages. */ ? sprintf( __( 'Pages checked: %1$d of %2$d found so far. %3$d waiting; %4$d unavailable. Eligible images are selected after discovery finishes.', 'affstackdev-image-manager' ), (int) ( $pages['scanned'] ?? 0 ) + (int) ( $pages['blocked'] ?? 0 ), array_sum( $pages ), (int) ( $pages['waiting'] ?? 0 ), (int) ( $pages['blocked'] ?? 0 ) ) : sprintf( /* translators: 1: selected images finished; 2: images selected; 3: completed; 4: ready for review; 5: skipped; 6: need attention. */ _n( '%1$d of %2$d selected images finished. %3$d completed; %4$d ready for review; %5$d skipped; %6$d need attention.', '%1$d of %2$d selected images finished. %3$d completed; %4$d ready for review; %5$d skipped; %6$d need attention.', $total, 'affstackdev-image-manager' ), $done, $total, $summary['complete'], $summary['review'], $summary['skipped'], $summary['failed'] ); if ( 'optimize' === $job['action'] ) { $detail .= ' ' . __( 'Compression and size choices apply to this run, not your global defaults.', 'affstackdev-image-manager' ); } } if ( 'bulk' === $job['mode'] && ! $job['active'] && $active ) { $label = $s['paused'] ? __( 'Other queued image work is paused', 'affstackdev-image-manager' ) : __( 'Processing other queued image work', 'affstackdev-image-manager' ); $detail = __( 'The saved bulk run has ended. Separately queued image work remains; last-run counts do not measure that work.', 'affstackdev-image-manager' ); $percent = 0; } if ( ! $job['active'] && ! $scanning && $automatic['pending'] ) { $label = $s['paused'] ? __( 'Automatic page rechecks paused', 'affstackdev-image-manager' ) : __( 'Checking changed public pages', 'affstackdev-image-manager' ); /* translators: %d: Number of pending automatic page rechecks. */ $detail = sprintf( __( '%d automatic page rechecks waiting. New eligible images join the queue; saved-run counts remain separate.', 'affstackdev-image-manager' ), $automatic['pending'] ); $percent = 0; } $health = Scheduler::status( $active && ! $s['paused'] ); $run = Progress::describe( $job, $pages, $counts, $summary, $active, $scanning, (bool) $s['paused'], $health ); if ( ! $job['active'] && $automatic['pending'] && 'no_recent_checkpoint' === $run['wait_code'] ) { // An old run checkpoint does not age a newly queued automatic recheck. if ( max( $automatic['oldest'], (int) ( $automatic['last']['at'] ?? 0 ) ) >= time() - 300 ) { $run['wait_code'] = ''; $run['wait_detail'] = ''; } else { $run['wait_detail'] = __( 'Automatic page rechecks have been waiting with no recent automatic checkpoint. Review background processing or use Process here now; the saved run is separate.', 'affstackdev-image-manager' ); } } return array( 'run' => $run, 'automatic' => $automatic, 'found' => $found, 'ready' => $ready, 'kept' => $kept, 'done' => $done, 'buckets' => $buckets, 'saved' => max( 0, (int) ( $totals['saved'] ?? 0 ) ), 'copy_bytes' => max( 0, (int) ( $totals['bytes'] ?? 0 ) ), 'copies' => max( 0, (int) ( $totals['copies'] ?? 0 ) ), 'active' => $active, 'paused' => (bool) $s['paused'], 'scanning' => $scanning, 'cancelled' => (bool) $job['cancelled'], 'percent' => $percent, 'label' => $label, 'detail' => $detail, 'delivery' => (bool) $s['delivery'], 'counts' => $counts, 'page_counts' => $pages, 'background' => $health, 'assist' => $health['assist'], 'watch' => $active || $health['watch'], /* translators: %s: the run's status, such as "Finished — results are ready". */ 'run_notice' => $job['finished'] && ! $active ? sprintf( __( '%s. Original files are unchanged.', 'affstackdev-image-manager' ), $label ) : '', 'browser_verified' => false, ); } /** * Recount each source's saved bytes and copies, 50 rows per step, after an update from a release that kept no totals. */ public static function migrate_totals(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { global $wpdb; if ( get_option( 'afsd_im_totals_migrated', false ) ) { return; } $cursor = (int) get_option( 'afsd_im_totals_cursor', 0 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded reads/writes of plugin job, cursor and source totals; independent workers update these tables throughout the run. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,manifest FROM %i WHERE id>%d ORDER BY id ASC LIMIT 50', Repository::table( 'sources' ), $cursor ), ARRAY_A ); foreach ( $rows as $row ) { Repository::update( (int) $row['id'], array( 'manifest' => json_decode( $row['manifest'], true ) ?: array() ) ); $cursor = (int) $row['id']; } Reviewed_Store::update_option( 'afsd_im_totals_cursor', $cursor, false ); if ( count( $rows ) < 50 ) { Reviewed_Store::update_option( 'afsd_im_totals_migrated', true, false ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } }