__( 'Choose a folder first.', 'affstackdev-image-manager' ), /* translators: 1: files sent so far; 2: files to send. */ 'progress' => __( 'Sent %1$d of %2$d files…', 'affstackdev-image-manager' ), /* translators: 1: files now in the library; 2: files chosen. */ 'finished' => __( 'Done: %1$d of %2$d files are in the library.', 'affstackdev-image-manager' ), /* translators: %s: a file name. */ 'tooBig' => __( '%s is larger than this site accepts for one upload.', 'affstackdev-image-manager' ), 'failed' => __( 'A group of files could not be sent. The files named here are not in the library; upload them again.', 'affstackdev-image-manager' ), 'open' => __( 'Open the library', 'affstackdev-image-manager' ), ) ); // Measuring pictures on the pages needs this browser, so the script loads with or without the enhanced interface. wp_enqueue_script( 'afsd-im-fit', plugins_url( 'assets/fit.js', FILE ), array(), VERSION, true ); wp_localize_script( 'afsd-im-fit', 'afsdIMFit', array( 'timeout' => __( 'The page did not finish loading.', 'affstackdev-image-manager' ), 'blocked' => __( 'This browser could not read the page from this screen.', 'affstackdev-image-manager' ), 'foreign' => __( 'The address of this screen is not on this website.', 'affstackdev-image-manager' ), 'notSaved' => __( 'The measurement could not be saved.', 'affstackdev-image-manager' ), 'noPages' => __( 'No public pages are known yet. Run a scan on the Optimize tab first.', 'affstackdev-image-manager' ), /* translators: 1: number of the page being measured; 2: number of pages; 3: page address. */ 'measuring' => __( 'Measuring page %1$d of %2$d: %3$s', 'affstackdev-image-manager' ), 'failed' => __( 'This page could not be measured.', 'affstackdev-image-manager' ), /* translators: 1: pages measured; 2: pages that could not be measured. */ 'finished' => __( 'Measured %1$d page(s); %2$d could not be measured. Reloading the report…', 'affstackdev-image-manager' ), 'stopping' => __( 'Stopping after this page…', 'affstackdev-image-manager' ), ) ); if ( Suite::enhanced() ) { wp_enqueue_script( 'afsd-im-admin', plugins_url( 'assets/admin.js', FILE ), array(), VERSION, true ); wp_enqueue_script( 'afsd-im-media-usage', plugins_url( 'assets/media-usage.js', FILE ), array( 'afsd-im-admin' ), VERSION, true ); wp_localize_script( 'afsd-im-admin', 'afsdImageManagerStrings', array( 'unsaved' => __( 'Leave this screen and discard your unsaved form changes?', 'affstackdev-image-manager' ), /* translators: %d: Selected media items on the current page. */ 'selection' => __( '%d checked on this page', 'affstackdev-image-manager' ), 'finishing' => __( 'Your current action is still finishing. Please wait.', 'affstackdev-image-manager' ), 'details' => __( 'Media details', 'affstackdev-image-manager' ), 'previous' => __( 'Previous', 'affstackdev-image-manager' ), 'next' => __( 'Next', 'affstackdev-image-manager' ), 'close' => __( 'Close', 'affstackdev-image-manager' ), 'loadingDetails' => __( 'Loading media details…', 'affstackdev-image-manager' ), 'invalidAddress' => __( 'The site action address is invalid. Reload this screen.', 'affstackdev-image-manager' ), 'detailsFailed' => __( 'Media details could not be loaded.', 'affstackdev-image-manager' ), 'detailsTimeout' => __( 'This request took too long. You can open the details page or try again.', 'affstackdev-image-manager' ), 'openDetailsPage' => __( 'Open details page', 'affstackdev-image-manager' ), 'updating' => __( 'Updating…', 'affstackdev-image-manager' ), 'unexpected' => __( 'The server returned an unexpected response. Reload to check the saved state before retrying.', 'affstackdev-image-manager' ), 'requestFailed' => __( 'The request could not finish. Reload to check the saved state before retrying.', 'affstackdev-image-manager' ), 'unreadable' => __( 'The updated interface could not be read. Reload this screen to continue.', 'affstackdev-image-manager' ), 'changedWhileLoading' => __( 'Your form changed while this screen was loading. Your changes are retained; choose the destination again when ready.', 'affstackdev-image-manager' ), 'pdfPreviewFailed' => __( 'The saved preview could not be loaded. Use Open PDF to view the document.', 'affstackdev-image-manager' ), /* translators: %d: Content items checked so far. */ 'usageRunning' => __( 'Checking… %d content items checked so far.', 'affstackdev-image-manager' ), 'usageResume' => __( 'Resume check', 'affstackdev-image-manager' ), 'usageContinue' => __( 'Continue checking', 'affstackdev-image-manager' ), 'usageManual' => __( 'Automatic checking stopped. PHP mode uses Continue checking for each batch.', 'affstackdev-image-manager' ), 'usagePausing' => __( 'Pausing after the current batch saves…', 'affstackdev-image-manager' ), 'usagePaused' => __( 'Paused. Choose Resume check to continue.', 'affstackdev-image-manager' ), /* translators: %d: Total content items checked. */ 'usageDone' => __( 'Check finished. %d content items checked.', 'affstackdev-image-manager' ), 'usageLimit' => __( 'Stopped at the 100-result limit. This check is incomplete.', 'affstackdev-image-manager' ), 'usageFailed' => __( 'The usage check could not finish. Reload the report before retrying.', 'affstackdev-image-manager' ), 'usageBusy' => __( 'A usage check is still finishing. Reload the report in a moment.', 'affstackdev-image-manager' ), 'viewTimeout' => __( 'This screen took too long to load. Your current view is retained; try again.', 'affstackdev-image-manager' ), /* translators: 1: number of media items checked; 2: name of the folder they were dropped on. */ 'reviewMove' => __( 'Review moving %1$d item(s) to %2$s. Confirm and apply in Bulk actions.', 'affstackdev-image-manager' ), 'chooseMediaFirst' => __( 'Check media items first, then choose a destination folder.', 'affstackdev-image-manager' ), 'confirmCancel' => __( 'Stop this run? Originals and prepared copies will be kept. You can start a new run later.', 'affstackdev-image-manager' ), 'confirmBackupCancel' => __( 'Stop this backup? Incomplete backup work will be removed. Existing media and completed snapshots stay unchanged.', 'affstackdev-image-manager' ), 'confirmRemoteCancel' => __( 'Stop this remote transfer? Saved local snapshots and completed remote backups stay unchanged.', 'affstackdev-image-manager' ), ) ); } } /** * The address of an Image Manager tab, in the Suite or under Media. * * @param string $tab The tab. * @param array $args More query arguments. * @return string The address. */ public static function url( string $tab = 'optimize', array $args = array() ): string { $base = Suite::available() ? admin_url( 'admin.php?page=affstackdev-suite-product-' . Suite::ID ) : admin_url( 'upload.php?page=' . self::SLUG ); return add_query_arg( array_merge( array( 'im_tab' => $tab ), $args ), $base ); } /** * Stop the request unless the user may manage options and upload files. */ private static function authorize(): void { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { wp_die( esc_html__( 'You do not have permission to manage site images.', 'affstackdev-image-manager' ), '', array( 'response' => 403 ) ); } } /** * Read the screen's tab, list, library, backup and preview choices from a request, each kept to its allowed values. * * @param array $data The request's fields. */ private function read_view( array $data ): void { $tab = isset( $data['im_tab'] ) && is_string( $data['im_tab'] ) ? sanitize_key( $data['im_tab'] ) : 'optimize'; $this->image = isset( $data['im_image'] ) && is_scalar( $data['im_image'] ) ? absint( $data['im_image'] ) : 0; $this->size = isset( $data['im_size'] ) && is_string( $data['im_size'] ) && preg_match( '/^[a-f0-9]{64}$/D', $data['im_size'] ) ? $data['im_size'] : ''; $this->format = isset( $data['im_format'] ) && is_string( $data['im_format'] ) && in_array( $data['im_format'], array( 'image/png', 'image/jpeg', 'image/webp', 'image/avif' ), true ) ? $data['im_format'] : ''; $tabs = array_merge( array( 'optimize', 'images', 'backups', 'settings', 'tools', 'ai' ), array_keys( Hooks::sections() ) ); $this->tab = in_array( $tab, $tabs, true ) ? $tab : 'optimize'; $view = isset( $data['im_media_view'] ) && is_string( $data['im_media_view'] ) ? sanitize_key( $data['im_media_view'] ) : 'results'; $this->media_view = in_array( $view, array( 'results', 'library', 'protected', 'editor', 'details', 'convert', 'replace', 'compress' ), true ) ? $view : 'results'; $this->media_layout = isset( $data['im_media_layout'] ) && is_string( $data['im_media_layout'] ) && 'list' === sanitize_key( (string) $data['im_media_layout'] ) ? 'list' : 'grid'; $this->media_folder = isset( $data['im_media_folder'] ) && is_scalar( $data['im_media_folder'] ) ? absint( $data['im_media_folder'] ) : 0; $this->media_collection = isset( $data['im_media_collection'] ) && is_scalar( $data['im_media_collection'] ) ? absint( $data['im_media_collection'] ) : 0; $type = isset( $data['im_media_type'] ) && is_string( $data['im_media_type'] ) ? sanitize_key( $data['im_media_type'] ) : 'all'; $this->media_type = array_key_exists( $type, Media_Display::types() ) ? $type : 'all'; $sort = isset( $data['im_media_sort'] ) && is_string( $data['im_media_sort'] ) ? sanitize_key( $data['im_media_sort'] ) : 'newest'; $this->media_sort = array_key_exists( $sort, Media_Display::sorts() ) ? $sort : 'newest'; $this->media_id = isset( $data['im_media_id'] ) && is_scalar( $data['im_media_id'] ) ? absint( $data['im_media_id'] ) : 0; $this->backup = isset( $data['im_backup'] ) && is_string( $data['im_backup'] ) && preg_match( '/^[a-f0-9]{32}$/D', $data['im_backup'] ) ? $data['im_backup'] : ''; $this->backup_page = isset( $data['im_backup_page'] ) && is_scalar( $data['im_backup_page'] ) ? min( 100000, max( 1, absint( $data['im_backup_page'] ) ) ) : 1; $this->page = isset( $data['im_page'] ) && is_scalar( $data['im_page'] ) ? min( 100000, max( 1, absint( $data['im_page'] ) ) ) : 1; $this->filter = isset( $data['im_filter'] ) && is_string( $data['im_filter'] ) ? sanitize_key( $data['im_filter'] ) : ''; $this->search = isset( $data['im_search'] ) && is_string( $data['im_search'] ) ? substr( sanitize_text_field( $data['im_search'] ), 0, 100 ) : ''; } /** * Open a form posted to admin-post.php, carrying the operation, the current view and the nonce. * * @param string $operation The operation. * @param bool $download Whether it starts a download (posted as a plain form in the same tab, outside the enhanced screen's requests; D-214). * @param string $id The form's HTML id ('' for none). */ private function begin( string $operation, bool $download = false, string $id = '' ): void { echo '
'; echo ''; foreach ( array( 'im_tab' => $this->tab, 'im_page' => $this->page, 'im_filter' => $this->filter, 'im_search' => $this->search, 'im_image' => $this->image, 'im_size' => $this->size, 'im_format' => $this->format, 'im_media_view' => $this->media_view, 'im_media_layout' => $this->media_layout, 'im_media_folder' => $this->media_folder, 'im_media_collection' => $this->media_collection, 'im_media_type' => $this->media_type, 'im_media_sort' => $this->media_sort, 'im_media_id' => $this->media_id, 'im_backup' => $this->backup, 'im_backup_page' => $this->backup_page, ) as $k => $v ) { echo ''; } wp_nonce_field( 'afsd_im_action' ); } /** * Print a form with one button that runs an operation. * * @param string $operation The operation. * @param string $label The button's text. * @param bool $primary Whether it is the main button. */ private function button_form( string $operation, string $label, bool $primary = false ): void { $this->begin( $operation ); echo '
'; } /** * Print a checkbox with its label and help text. * * @param string $name The field's name. * @param string $label Its label. * @param bool $checked Whether it is checked. * @param string $help Help text ('' for none). */ private static function check( string $name, string $label, bool $checked = false, string $help = '' ): void { echo ''; } /** * Show Image Manager's page: a tab other code adds through its own callback, otherwise the screen with any notice * waiting for this user. */ public function render(): void { self::authorize(); $this->read_view( Plugin::fields( self::VIEW_FIELDS, true ) ); // Read-only navigation fields. $sections = Hooks::sections(); if ( isset( $sections[ $this->tab ] ) ) { call_user_func( $sections[ $this->tab ]['render'] ); return; } // That tab prints its own form with this page's header and tabs. $notice = get_transient( 'afsd_im_notice_' . get_current_user_id() ); if ( $notice ) { Reviewed_Store::delete_transient( 'afsd_im_notice_' . get_current_user_id() ); } $this->screen( is_string( $notice ) ? $notice : '' ); } /** Media browsing is intentionally passive: it must not compete with image/backup workers. */ private function passive_media_view(): bool { return 'images' === $this->tab && ! $this->image && in_array( $this->media_view, array( 'library', 'protected', 'editor', 'details', 'convert', 'replace', 'compress' ), true ); } /** Passive workspaces do not need site-wide totals or job status. */ private function media_inventory_snapshot(): array { return array( 'found' => 0, 'ready' => 0, 'kept' => 0, 'saved' => 0, 'active' => false, 'paused' => false, 'scanning' => false, 'assist' => false, 'watch' => false, 'run_notice' => '', ); } /** * The library's address: the current view with the given values over it, values at their defaults left out. * * @param array $overrides Values to change. * @return string The address. */ private function library_url( array $overrides = array() ): string { $args = array_merge( array( 'im_media_view' => 'library', 'im_media_layout' => $this->media_layout, 'im_media_folder' => $this->media_folder, 'im_media_collection' => $this->media_collection, 'im_media_type' => $this->media_type, 'im_media_sort' => $this->media_sort, 'im_page' => $this->page, 'im_search' => $this->search, ), $overrides ); foreach ( array( 'im_media_layout' => 'grid', 'im_media_folder' => 0, 'im_media_collection' => 0, 'im_media_type' => 'all', 'im_media_sort' => 'newest', 'im_page' => 1, 'im_search' => '', ) as $key => $default ) { if ( isset( $args[ $key ] ) && (string) $args[ $key ] === (string) $default ) { unset( $args[ $key ] ); } } return self::url( 'images', $args ); } /** * Send a media item's details panel to the library as JSON (a POST with the nonce, for users who may manage options * and upload files). */ public function ajax_media_details(): void { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { wp_send_json_error( array( 'message' => __( 'You cannot view these media details.', 'affstackdev-image-manager' ) ), 403 ); } if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { wp_send_json_error( array( 'message' => __( 'POST required.', 'affstackdev-image-manager' ) ), 405 ); } if ( ! check_ajax_referer( 'afsd_im_action', '_wpnonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Your session expired. Reload this screen to continue.', 'affstackdev-image-manager' ) ), 403 ); } $this->read_view( Plugin::fields( self::VIEW_FIELDS ) ); try { $html = Plugin::capture( function (): void { Media_Details::render( $this->media_id, $this->library_url(), $this->library_url( array( 'im_media_view' => 'editor', 'im_media_id' => $this->media_id, ) ) ); } ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => self::error_message( $e ) ), 403 ); } wp_send_json_success( array( 'html' => $html, 'id' => $this->media_id, ) ); } /** * A backup status with nothing running, for views that do not read the real one. * * @return array The status. */ private static function idle_backup_status(): array { return array( 'active' => false, 'paused' => false, 'assist' => false, 'watch' => false, 'stage' => 'idle', 'percent' => 0, 'title' => '', 'detail' => '', 'last_error' => '', ); } /** * Persist only occasional/slow Media timing samples so diagnostics do not add a write to every page view. * * @param array $timing The timing sample. */ private function record_media_timing( array $timing ): void { $timing['recorded_utc'] = gmdate( 'c' ); $previous = get_option( 'afsd_im_media_timing', array() ); $last = is_array( $previous ) ? (int) ( $previous['recorded_at'] ?? 0 ) : 0; $timing['recorded_at'] = time(); if ( (int) ( $timing['total_ms'] ?? 0 ) >= 500 || time() - $last >= 60 || ! $last ) { Reviewed_Store::update_option( 'afsd_im_media_timing', $timing, false ); } } /** * Print the page: its header and tabs, the notice, the image totals on the Optimize and Images tabs, and the tab's * content. * * @param string $notice A notice to show ('' for none). */ private function screen( string $notice = '' ): void { $s = Settings::get(); $passive_media = $this->passive_media_view(); $v = $passive_media ? $this->media_inventory_snapshot() : Workflow::snapshot(); $b = $passive_media ? self::idle_backup_status() : Backup::status(); $rb = $passive_media ? self::idle_backup_status() : Remote_Backup::status(); $run_notice = str_starts_with( $notice, __( 'Optimization started.', 'affstackdev-image-manager' ) ) || str_starts_with( $notice, __( 'Scan started.', 'affstackdev-image-manager' ) ) || str_starts_with( $notice, __( 'Optimization resumed.', 'affstackdev-image-manager' ) ); if ( $run_notice && $v['run_notice'] ) { $notice = $v['run_notice']; } echo '
'; $section = $passive_media ? 'library' : ( 'images' === $this->tab ? 'optimize' : $this->tab ); Admin_Layout::header( $section, Suite::available(), $passive_media ? $this->library_url() : '' ); echo '
' . esc_html( $notice ) . '
'; if ( ! $passive_media && in_array( $this->tab, array( 'optimize', 'images' ), true ) ) { echo '

' . esc_html__( 'Known image inventory — includes results from earlier runs', 'affstackdev-image-manager' ) . '

'; foreach ( array( 'found' => __( 'Images found', 'affstackdev-image-manager' ), 'ready' => __( 'Images with optimized files', 'affstackdev-image-manager' ), 'kept' => __( 'Retained / unsupported', 'affstackdev-image-manager' ), 'saved' => __( 'Same-size file savings', 'affstackdev-image-manager' ), ) as $key => $label ) { echo '
' . esc_html( 'saved' === $key ? size_format( $v[ $key ], 1 ) : number_format_i18n( $v[ $key ] ) ) . '' . esc_html( $label ) . '
'; } echo '
'; } echo '
'; if ( 'images' === $this->tab ) { if ( $this->image ) { $this->preview(); } else { $this->images(); } } elseif ( 'backups' === $this->tab ) { $this->backups(); } elseif ( 'settings' === $this->tab ) { $this->settings(); } elseif ( 'ai' === $this->tab ) { AI_Proposals::render(); } elseif ( 'tools' === $this->tab ) { $this->tools(); } else { $this->optimize( $v ); } echo '

'; } /** * Print the Optimize tab: the run's progress, the form that starts a run or a scan, the pause, resume, stop and * process-here controls, the result breakdown, the background, delivery and quality cards, and the page-fit panel. * * @param array $v The workflow's state (Workflow::snapshot()). */ private function optimize( array $v ): void { $this->media_subnav(); $s = Settings::get(); echo '

' . esc_html__( 'SITE OPTIMIZATION', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Optimize the images your visitors see', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Originals retained', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Start once. Image Manager finds your public site images, prepares smaller validated copies, and moves through the work automatically.', 'affstackdev-image-manager' ) . '

'; echo '
' . esc_html__( 'What is included', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Home and Shop, pages linked directly from them, public product images, and supported review images. Images supplied by the active theme are included when found on these pages.', 'affstackdev-image-manager' ) . '

'; echo '
' . wp_kses( $this->progress( $v ), Admin_Layout::allowed_html() ) . '
'; echo '
'; $this->begin( 'start' ); self::check( 'public_policy', __( 'These are public website images. I will keep private or member-only media excluded.', 'affstackdev-image-manager' ), (bool) $s['public_policy'] ); $this->run_fields( true ); self::check( 'delivery', __( 'Offer optimized images on supported website output', 'affstackdev-image-manager' ), (bool) $s['delivery'], __( 'Your original images remain as the fallback. This does not replace or delete them.', 'affstackdev-image-manager' ) ); echo '
'; echo '
'; $this->button_form( 'pause', __( 'Pause optimization', 'affstackdev-image-manager' ) ); echo ''; $this->button_form( 'resume', __( 'Resume optimization', 'affstackdev-image-manager' ), true ); echo ''; $this->button_form( 'cancel', __( 'Stop this run', 'affstackdev-image-manager' ) ); $this->button_form( 'step', __( 'Process here now', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'Progress is saved between requests. Safe skips do not stop the rest of the job.', 'affstackdev-image-manager' ) . '

'; echo '
' . wp_kses( $this->breakdown( $v ), Admin_Layout::allowed_html() ) . '
'; echo '
'; $this->fit_panel(); } /** Optimize tab: pictures sized to fit the page (1.8.0). */ private function fit_panel(): void { $s = Settings::get(); $r = Fit::report(); echo '

' . esc_html__( 'SIZES FOR THE PAGE', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Pictures sized to fit the page', 'affstackdev-image-manager' ) . '

'; echo '

' . esc_html__( 'Image Manager opens your public pages one at a time in a hidden frame of this screen and measures how wide each picture is shown, at every screen width from phones to large monitors. Pictures whose theme left WordPress’s standard sizes then tell the browser their real width, so it downloads a file close to that width; the sizes those widths need can be made below. Nothing leaves your site, and no visitor is measured.', 'affstackdev-image-manager' ) . '

'; echo '

' . esc_html( $s['fit'] ? __( 'Fit pictures to the page is on.', 'affstackdev-image-manager' ) : __( 'Fit pictures to the page is off. Measuring changes nothing on the website until you switch it on in Settings.', 'affstackdev-image-manager' ) ) . '

'; if ( $r['pages'] ) { /* translators: 1: pages measured; 2: date and time of the last measurement. */ echo '

' . esc_html( sprintf( _n( '%1$d page measured, last on %2$s.', '%1$d pages measured, last on %2$s.', $r['pages'], 'affstackdev-image-manager' ), $r['pages'], wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $r['last'] ) ) ) . '

'; /* translators: 1: pictures fitted; 2: pictures the theme sizes itself; 3: pictures left as they are. */ echo '

' . esc_html( sprintf( __( 'Pictures fitted: %1$d. Sized by the theme itself (left as the theme chose): %2$d. Left as they are (no choice of files, a product gallery or a picture chosen by screen width): %3$d.', 'affstackdev-image-manager' ), $r['fitted'], $r['theme'], $r['skipped'] ) ) . '

'; echo '
' . esc_html__( 'Measured pages', 'affstackdev-image-manager' ) . '
'; foreach ( array_slice( $r['list'], 0, Fit::MAX_PAGES ) as $page ) { /* translators: 1: pictures fitted; 2: pictures the theme sizes; 3: pictures left as they are. */ echo '

' . esc_html( wp_parse_url( $page['url'], PHP_URL_PATH ) ?: '/' ) . '' . esc_html( sprintf( __( '%1$d fitted · %2$d by the theme · %3$d left', 'affstackdev-image-manager' ), (int) ( $page['fitted'] ?? 0 ), (int) ( $page['theme'] ?? 0 ), (int) ( $page['skipped'] ?? 0 ) ) ) . '

'; } echo '
'; } echo '

' . esc_html__( 'Measuring needs JavaScript in this browser and takes a few seconds per page. Keep this screen open while it runs.', 'affstackdev-image-manager' ) . '

'; if ( $r['pages'] ) { $this->begin( 'fit_sizes' ); self::check( 'resize_confirm', __( 'Make the display sizes these pages need. Copies are resized; originals stay unchanged.', 'affstackdev-image-manager' ), false, __( 'Up to six extra sizes per picture, only where no existing size is within 20 % above a width the pages need, never larger than the original and never cropped. Starts a run with your compression defaults.', 'affstackdev-image-manager' ) ); if ( 'lossless' !== Policy::current()['mode'] ) { self::check( 'compression_confirm', __( 'Allow controlled pixel changes: your compression default is High Quality or Maximum Savings.', 'affstackdev-image-manager' ) ); } echo '
'; $this->begin( 'fit_forget' ); self::check( 'fit_forget_confirm', __( 'Forget every measurement. Pictures go back to the sizes their theme gives them; files already made stay.', 'affstackdev-image-manager' ) ); echo '
'; } echo '
'; } /** * The Background processing card: the scheduler's state, whether the browser is helping the run along, and the * automatic discovery panel. * * @param array $v The workflow's state. * @return string The card's HTML. */ public function background( array $v ): string { $h = $v['background']; $html = '

' . esc_html__( 'Background processing', 'affstackdev-image-manager' ) . '

' . esc_html( $h['title'] ) . '

' . esc_html( $h['detail'] ) . '

'; if ( $v['active'] && ! $v['paused'] && $h['assist'] ) { $html .= '

' . esc_html( Suite::enhanced() ? __( 'Browser assistance is active while this workspace stays visible. Closing it leaves progress saved; further work depends on the host scheduler.', 'affstackdev-image-manager' ) : __( 'PHP mode: Process here now advances one saved batch. Further work depends on the host scheduler.', 'affstackdev-image-manager' ) ) . '

'; } elseif ( $v['active'] && ! $v['paused'] && 'confirmed' === $h['code'] ) { $html .= '

' . esc_html__( 'Your scheduler recently ran the independent check. You can leave this workspace; completed work is saved.', 'affstackdev-image-manager' ) . '

'; } return $html . '' . esc_html__( 'Check background processing', 'affstackdev-image-manager' ) . '' . Auto_Discovery::render( $v['automatic'] ?? array() ); } /** * The inventory's result breakdown, each count linking to its filter of the results, with the prepared files and their * storage. * * @param array $v The workflow's state. * @return string The HTML ('' when no images are known). */ public function breakdown( array $v ): string { if ( ! $v['found'] ) { return ''; } $html = '

' . esc_html__( 'Inventory result breakdown', 'affstackdev-image-manager' ) . '

'; foreach ( Results::labels() as $key => $label ) { $html .= '' . esc_html( number_format_i18n( $v['buckets'][ $key ] ) ) . '' . esc_html( $label ) . ''; } /* translators: 1: number of prepared files; 2: storage used by the active prepared files, such as 1.5 MB. */ $html .= '

' . esc_html( sprintf( __( 'Prepared files: %1$s · Active prepared-file storage: %2$s. Retired and temporary files are excluded from that storage total. Browser-transfer savings are not measured here.', 'affstackdev-image-manager' ), number_format_i18n( $v['copies'] ), size_format( $v['copy_bytes'], 1 ) ) ) . '

'; return $html; } /** * Run-scoped facts. Inventory totals above intentionally do not masquerade as this batch. * * @param array $v The workflow's state. * @return string The run summary's HTML. */ public function run_summary( array $v ): string { $r = $v['run']; $has_run = $r['id'] || $r['started'] || $r['finished']; $title = $r['active'] ? __( 'Current run', 'affstackdev-image-manager' ) : ( $has_run ? __( 'Last run', 'affstackdev-image-manager' ) : __( 'No run started', 'affstackdev-image-manager' ) ); $html = '

' . esc_html( $title ) . '

'; if ( $has_run && $r['compression'] ) { $html .= '

' . esc_html( $r['compression'] . ' · ' . $r['sizing'] ) . '

'; } if ( $r['pages']['has_scan'] && $has_run ) { /* translators: 1: checked pages; 2: currently known pages; 3: waiting pages; 4: unavailable pages. */ $html .= '

' . esc_html( sprintf( __( 'Pages: %1$d of %2$d checked so far · %3$d waiting · %4$d unavailable', 'affstackdev-image-manager' ), $r['pages']['checked'], $r['pages']['found_so_far'], $r['pages']['waiting'], $r['pages']['blocked'] ) ) . '

'; } if ( $r['selection_known'] ) { $o = $r['outcomes']; $html .= '
'; foreach ( array( 'total' => __( 'Selected', 'affstackdev-image-manager' ), 'complete' => __( 'Completed', 'affstackdev-image-manager' ), 'review' => __( 'For review', 'affstackdev-image-manager' ), 'skipped' => __( 'Skipped', 'affstackdev-image-manager' ), 'failed' => __( 'Attention', 'affstackdev-image-manager' ), ) as $key => $label ) { $html .= '
' . esc_html( number_format_i18n( (int) $o[ $key ] ) ) . '' . esc_html( $label ) . '
'; } $html .= '
'; } elseif ( $r['active'] && 'bulk' === $r['mode'] ) { $html .= '

' . esc_html__( 'Image selection is pending until discovery finishes. The inventory totals above are not this run’s progress.', 'affstackdev-image-manager' ) . '

'; } elseif ( $has_run && 'scan' === $r['mode'] ) { $html .= '

' . esc_html__( 'Scan only: no image recompression is requested by this run.', 'affstackdev-image-manager' ) . '

'; } if ( $r['checkpoint_at'] && $r['checkpoint_label'] ) { $html .= '

' . esc_html__( 'Last saved activity:', 'affstackdev-image-manager' ) . ' ' . esc_html( $r['checkpoint_label'] ) . ' ·

'; } elseif ( $v['active'] ) { $html .= '

' . esc_html__( 'No progress checkpoint recorded for this run yet. Opening this screen does not count as processing.', 'affstackdev-image-manager' ) . '

'; } if ( $r['other_work_pending'] ) { $html .= '

' . esc_html__( 'Other queued image work is pending. Saved-run counts above do not describe that separate work.', 'affstackdev-image-manager' ) . '

'; } if ( $r['wait_detail'] ) { $html .= '

' . esc_html( $r['wait_detail'] ) . '

'; } return $html . '
'; } /** * The progress panel: the run summary, the progress bar (without a percent while scanning or while other work waits) * and the pause reason. * * @param array $v The workflow's state. * @return string The panel's HTML. */ public function progress( array $v ): string { $p = (int) $v['percent']; $indeterminate = ( $v['scanning'] || $v['run']['other_work_pending'] ) && ! $v['paused']; return $this->run_summary( $v ) . '
' . esc_html( $v['label'] ) . '' . ( $indeterminate ? ( $v['scanning'] ? esc_html__( 'Scanning', 'affstackdev-image-manager' ) : esc_html__( 'Working', 'affstackdev-image-manager' ) ) : esc_html( $p . '%' ) ) . '

' . esc_html( $v['detail'] ) . '

' . ( $v['paused'] && Settings::get()['pause_reason'] ? '

' . esc_html( Settings::get()['pause_reason'] ) . '

' : '' ); } /** * Print a one-button form that applies a bulk action to one image. * * @param int $id The source row. * @param string $op The bulk action. * @param string $label The button's text. */ private function selection_form( int $id, string $op, string $label ): void { $this->begin( 'selected' ); echo ''; } /** * Print the Images tab: the protected photos, a media item's details, the editor, the convert, replace or smaller-copy * screen, or the library when one is asked for; otherwise the image results, 20 a page, with their search, filter and * bulk editor. */ private function images(): void { echo '

' . esc_html__( 'Media library', 'affstackdev-image-manager' ) . ' ' . esc_html__( 'Protected Community photos', 'affstackdev-image-manager' ) . '

'; if ( 'protected' === $this->media_view ) { Protected_Library::render(); return; } if ( 'details' === $this->media_view ) { echo '
'; try { Media_Details::render( $this->media_id, $this->library_url(), $this->library_url( array( 'im_media_view' => 'editor', 'im_media_id' => $this->media_id, ) ) ); } catch ( \Throwable $e ) { echo '

' . esc_html( self::error_message( $e ) ) . '

' . esc_html__( 'Back to library', 'affstackdev-image-manager' ) . ''; } echo '
'; return; } if ( 'editor' === $this->media_view ) { $this->editor_screen(); return; } if ( in_array( $this->media_view, array( 'convert', 'replace', 'compress' ), true ) ) { $this->media_operation_screen(); return; } if ( 'library' === $this->media_view ) { $this->library_screen(); return; } global $wpdb; $this->media_subnav(); $total = Batch::count_matching( $this->filter, $this->search ); $pages = max( 1, (int) ceil( $total / 20 ) ); $this->page = min( $this->page, $pages ); $rows = Batch::page_matching( $this->filter, $this->search, ( $this->page - 1 ) * 20 ); echo '

' . esc_html__( 'Your images', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Each image is grouped with its responsive sizes. Originals are never overwritten.', 'affstackdev-image-manager' ) . '

'; $this->bulk_controls( $total ); echo '
'; if ( ! $rows ) { echo '

' . esc_html__( 'No images to show yet', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Run a scan or change your search to see images here.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Go to Optimize', 'affstackdev-image-manager' ) . '
'; } foreach ( $rows as $raw ) { $this->image_card( Repository::decode( $raw ) ); } /* translators: 1: number of images in the results; 2: current page number; 3: number of pages. */ echo '
'; } /** * Print the Run optimization and Optimization results links (not in the library's views). */ private function media_subnav(): void { if ( $this->passive_media_view() ) { return; } echo ''; } /** * Print the Media library: search, file type, order, folder and collection filters; a grid or list of 30 items a page * with their folders and collections and their edit, image-from-PDF and smaller-PDF links; the forms to make, rename * and delete folders and collections, to upload files or a whole folder into a folder, and the bulk actions; and the * website gallery, folder import and no-use panels. Ordering by size first notes the size of up to 300 older files. */ private function library_screen(): void { $this->media_subnav(); $tax_query = array(); if ( $this->media_folder ) { $tax_query[] = array( 'taxonomy' => Library::FOLDER, 'field' => 'term_id', 'terms' => array( $this->media_folder ), ); } if ( $this->media_collection ) { $tax_query[] = array( 'taxonomy' => Library::COLLECTION, 'field' => 'term_id', 'terms' => array( $this->media_collection ), ); } $mime = Media_Display::mime_filter( $this->media_type ); $args = array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 30, 'paged' => $this->page, 'orderby' => 'date', 'order' => 'DESC', 's' => $this->search, 'update_post_term_cache' => false, 'update_post_meta_cache' => true, 'cache_results' => true, ); if ( $mime ) { $args['post_mime_type'] = $mime; } // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. if ( $tax_query ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. $args['tax_query'] = $tax_query; } $media_started = microtime( true ); $queries_before = function_exists( 'get_num_queries' ) ? (int) get_num_queries() : 0; $sizes_pending = false; if ( str_starts_with( $this->media_sort, 'size_' ) ) { // 1.9.0: note the size of up to 300 older files first; when Image Manager is busy the list sorts by what is known. $noted = Reviewed_Store::housekeeping( static function () use ( &$sizes_pending ) { $lock = Reviewed_Store::enter(); try { $sizes_pending = Media_Display::measure(); } finally { Reviewed_Store::leave( $lock ); } } ); $sizes_pending = $sizes_pending || ! $noted; } $query_started = microtime( true ); $q = Media_Display::query( $args, $this->media_sort ); if ( ! $q->posts && $this->page > 1 ) { $this->page = 1; $args['paged'] = 1; $q = Media_Display::query( $args, $this->media_sort ); } $query_ms = (int) round( 1000 * ( microtime( true ) - $query_started ) ); $ids = array_map( static fn( $post ) => (int) $post->ID, $q->posts ); $org_started = microtime( true ); $organization = Library::organization_for_objects( $ids ); $folders = Library::terms( Library::FOLDER ); $collections = Library::terms( Library::COLLECTION ); $organization_ms = (int) round( 1000 * ( microtime( true ) - $org_started ) ); $pages = max( 1, (int) $q->max_num_pages ); $this->page = min( $this->page, $pages ); echo '

' . esc_html__( 'Media library', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Virtual organization for WordPress media. Folder and collection changes never move files or change URLs.', 'affstackdev-image-manager' ) . '

'; echo '
'; echo '
' . esc_html__( 'Folders and collections', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Folders are hierarchical; collections can overlap. Both are virtual WordPress relationships.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'library_create' ); echo ''; $this->begin( 'library_create' ); echo '
' . esc_html__( 'Rename or delete a folder / collection', 'affstackdev-image-manager' ) . ''; $this->begin( 'library_term' ); echo '
'; self::check( 'term_confirm', __( 'Apply this change. Deleting an organization label never deletes media files.', 'affstackdev-image-manager' ) ); echo '
'; $this->gallery_panel( $folders ); $this->import_panel(); echo '
'; $this->unused_panel(); echo '
' . esc_html__( 'Upload media to a folder', 'affstackdev-image-manager' ) . ''; $this->begin( 'library_upload' ); echo '

' . esc_html( sprintf( __( 'Uses your normal WordPress upload rules. Per-file limit: %s.', 'affstackdev-image-manager' ), size_format( wp_max_upload_size() ) ) ) . '

'; // 1.9.0: a whole folder from the computer (assets/folder-upload.js shows this where the browser can choose folders). echo '
'; if ( $folders ) { echo '

' . esc_html__( 'Drag a media card to a folder, then review the move. Keyboard and touch users can use Bulk actions.', 'affstackdev-image-manager' ) . '

'; foreach ( array_slice( $folders, 0, 30 ) as $t ) { echo ''; } echo '

' . esc_html__( 'The first 30 folders are shown here. All folders are available in Bulk actions.', 'affstackdev-image-manager' ) . '

'; } $this->begin( 'library_bulk', false, 'afim-library-bulk' ); echo '
' . esc_html__( 'Bulk actions', 'affstackdev-image-manager' ) /* translators: %d: media items checked on the current page. */ . ' ' . esc_html( sprintf( __( '%d checked on this page', 'affstackdev-image-manager' ), 0 ) ) . '
' . esc_html__( 'Metadata fields', 'affstackdev-image-manager' ) . '
'; self::check( 'set_title', __( 'Set attachment title', 'affstackdev-image-manager' ) ); echo ''; self::check( 'set_caption', __( 'Set attachment caption', 'affstackdev-image-manager' ) ); echo ''; self::check( 'set_alt', __( 'Set image alt-text default', 'affstackdev-image-manager' ), '', __( 'This changes the Media Library default only; it does not rewrite contextual alt text already saved in page content.', 'affstackdev-image-manager' ) ); echo '
'; self::check( 'media_confirm', __( 'Apply this media-library action to the checked items. Files and URLs will not move.', 'affstackdev-image-manager' ) ); echo '
'; $klass = 'grid' === $this->media_layout ? 'afim-media-grid' : 'afim-media-list'; echo '
'; if ( ! $q->posts ) { echo '

' . esc_html__( 'No media matches these filters', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Change a filter or upload media through WordPress.', 'affstackdev-image-manager' ) . '

'; } $cards_started = microtime( true ); foreach ( $q->posts as $post ) { $id = (int) $post->ID; $mime = (string) $post->post_mime_type; $title = (string) $post->post_title; $meta = wp_get_attachment_metadata( $id ); $small = is_array( $meta ) && ( ! empty( $meta['sizes']['thumbnail'] ) || ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) && max( (int) $meta['width'], (int) $meta['height'] ) <= 320 ) ); $thumbnail = str_starts_with( $mime, 'image/' ) && $small ? wp_get_attachment_image_src( $id, 'thumbnail' ) : false; $pdf = 'application/pdf' === $mime; if ( $pdf ) { $thumbnail = Media_Display::pdf_preview( $id, is_array( $meta ) ? $meta : array() ); } $thumb = $thumbnail && max( (int) $thumbnail[1], (int) $thumbnail[2] ) <= 320 ? '' : ''; $org = $organization[ $id ] ?? array( 'folders' => array(), 'collections' => array(), ); $folder_names = $org['folders']; $collection_names = $org['collections']; $fallback = $thumb ? $thumb : '' . esc_html( $pdf ? __( 'PDF', 'affstackdev-image-manager' ) : ( str_starts_with( $mime, 'image/' ) ? __( 'IMG', 'affstackdev-image-manager' ) : __( 'FILE', 'affstackdev-image-manager' ) ) ) . ''; /* translators: %d: attachment ID, shown for media without a title. */ $name = $title ?: sprintf( __( 'Media #%d', 'affstackdev-image-manager' ), $id ); echo '
' . wp_kses_post( $fallback ) . '
' . esc_html( $name ) . '' . esc_html( $pdf ? __( 'PDF document', 'affstackdev-image-manager' ) : $mime ) . '' . ( $pdf && ! $thumb ? '' . esc_html__( 'No small preview available', 'affstackdev-image-manager' ) . '' : '' ) . '

' . esc_html( $folder_names ? sprintf( /* translators: %s: comma-separated folder names. */ __( 'Folder: %s', 'affstackdev-image-manager' ), implode( ', ', $folder_names ) ) : __( 'No folder', 'affstackdev-image-manager' ) ) . ' · ' . esc_html( $collection_names ? sprintf( /* translators: %s: comma-separated collection names. */ __( 'Collections: %s', 'affstackdev-image-manager' ), implode( ', ', $collection_names ) ) : __( 'No collections', 'affstackdev-image-manager' ) ) . '

'; if ( str_starts_with( $mime, 'image/' ) ) { echo '' . esc_html__( 'Edit image', 'affstackdev-image-manager' ) . ''; } if ( $pdf ) { echo '' . esc_html__( 'Create image from PDF', 'affstackdev-image-manager' ) . ''; // 1.9.0 (D-500 gap 15): a smaller copy of the PDF, where Ghostscript runs. if ( PDF_Copy::available() ) { echo '' . esc_html__( 'Make a smaller PDF', 'affstackdev-image-manager' ) . ''; } } echo '' . esc_html__( 'Quick details', 'affstackdev-image-manager' ) . '
'; } $cards_ms = (int) round( 1000 * ( microtime( true ) - $cards_started ) ); /* translators: 1: number of media items found; 2: current page number; 3: number of pages. */ echo '
'; $total_ms = (int) round( 1000 * ( microtime( true ) - $media_started ) ); $queries_after = function_exists( 'get_num_queries' ) ? (int) get_num_queries() : $queries_before; $this->record_media_timing( array( 'total_ms' => $total_ms, 'query_ms' => $query_ms, 'organization_ms' => $organization_ms, 'cards_ms' => $cards_ms, 'query_count' => max( 0, $queries_after - $queries_before ), 'items' => count( $q->posts ), 'found' => (int) $q->found_posts, 'page' => (int) $this->page, 'layout' => $this->media_layout, ) ); echo '

' . esc_html( sprintf( /* translators: 1: milliseconds the server needed for this media page; 2: number of media items shown. */ _n( 'Media page generated in %1$s ms server-side for %2$s visible items. Timing is diagnostic only; browser/network time is separate.', 'Media page generated in %1$s ms server-side for %2$s visible items. Timing is diagnostic only; browser/network time is separate.', count( $q->posts ), 'affstackdev-image-manager' ), number_format_i18n( $total_ms ), number_format_i18n( count( $q->posts ) ) ) ) . '

'; wp_reset_postdata(); } /** * Print the convert, replace or smaller-copy screen of a media item, or the reason it cannot be shown. */ private function media_operation_screen(): void { try { Media_Workspace::render( $this->media_id, $this->media_view, function ( $op ) { $this->begin( $op ); }, $this->library_url() ); } catch ( \Throwable $e ) { echo '

' . esc_html( self::error_message( $e ) ) . '

'; } } /** * Print the image editor: crop, rotate or flip and resize choices to prepare a draft; a prepared draft beside the * original with Save edited copy (a new attachment) and Discard; and the copies saved before. */ private function editor_screen(): void { $this->media_subnav(); $id = $this->media_id; echo '
'; echo '

' . esc_html__( 'Reversible image editor', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Edits are prepared privately first. Saving creates a new Media Library image; the source attachment, its URL, and existing uses stay unchanged.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Back to media library', 'affstackdev-image-manager' ) . '
'; if ( ! $id || ! Editor::supported( $id ) || ! current_user_can( 'edit_post', $id ) ) { echo '
' . esc_html__( 'Choose a supported JPEG, PNG, WebP or AVIF image that you can edit.', 'affstackdev-image-manager' ) . '
'; return; } $path = get_attached_file( $id ); $dim = wp_getimagesize( $path ); $draft = Editor::draft( $id ); $history = Editor::history( $id ); echo '
' . esc_html( get_the_title( $id ) ?: basename( (string) $path ) ) . '' . esc_html( (int) $dim[0] . ' × ' . (int) $dim[1] . ' · ' . size_format( (int) filesize( $path ), 1 ) ) . '
'; if ( $draft ) { echo '
' . esc_html__( 'Original source', 'affstackdev-image-manager' ) . '
' . esc_attr__( 'Original source image', 'affstackdev-image-manager' ) . '
' . esc_html__( 'Prepared edit', 'affstackdev-image-manager' ) . ' · ' . esc_html( $draft['width'] . ' × ' . $draft['height'] . ' · ' . size_format( (int) $draft['output_bytes'], 1 ) ) . '
' . esc_attr__( 'Prepared edited image', 'affstackdev-image-manager' )
				. '
'; echo '

' . esc_html__( 'Both previews are administrator-only. The original attachment has not changed. Saving creates a separate attachment and copies this image’s current Image Manager folder/collection assignments.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'editor_save' ); echo ''; self::check( 'editor_save_confirm', __( 'Create this edited version as a new Media Library image. Existing pages/products will keep using the current source until I deliberately choose the new image.', 'affstackdev-image-manager' ) ); echo ''; $this->begin( 'editor_discard' ); echo ''; echo '
' . esc_html__( 'Edit recipe', 'affstackdev-image-manager' ) . '
' . esc_html( wp_json_encode( $draft['recipe'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ) . '
'; } else { echo '
' . esc_attr__( 'Image selected for editing', 'affstackdev-image-manager' ) . '
'; $this->begin( 'editor_prepare' ); echo '
' . esc_html__( 'Crop', 'affstackdev-image-manager' ) . '
'; echo '
' . esc_html__( 'Rotate / flip', 'affstackdev-image-manager' ) . '
'; echo '
' . esc_html__( 'Resize', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Resize keeps the aspect ratio and fits the picture inside the width and height you give, larger or smaller than it is now (enlarging cannot add detail). Crop/rotate/flip happen before resizing.', 'affstackdev-image-manager' ) . '

'; self::check( 'editor_confirm', __( 'Prepare this edit for review. This does not change the source attachment or website output.', 'affstackdev-image-manager' ) ); echo ''; } if ( $history ) { echo '
' . esc_html__( 'Saved edited copies', 'affstackdev-image-manager' ) . '
'; foreach ( array_reverse( $history ) as $h ) { $hid = (int) $h['attachment_id']; echo '
' . esc_html( get_the_title( $hid ) ?: sprintf( /* translators: %d: attachment ID of an edited copy without a title. */__( 'Edited image #%d', 'affstackdev-image-manager' ), $hid ) ) . '' . esc_html( ( $h['width'] ?? 0 ) . ' × ' . ( $h['height'] ?? 0 ) . ' · ' . gmdate( 'Y-m-d H:i', (int) ( $h['created'] ?? 0 ) ) . ' UTC' ) . '' . esc_html__( 'Attachment details', 'affstackdev-image-manager' ) . '' . esc_html__( 'Edit this copy', 'affstackdev-image-manager' ) . '
'; }echo '

' . esc_html__( 'These are ordinary WordPress attachments. Removing a history entry is not the same as deleting an attachment, so Image Manager does not auto-delete them.', 'affstackdev-image-manager' ) . '

'; } echo '
' . esc_html__( 'Safety model:', 'affstackdev-image-manager' ) . ' ' . esc_html__( 'Editing is copy-first. Existing attachment IDs, URLs, product/gallery relationships and page content are not replaced by Save edited copy. Use WordPress or the owning editor to deliberately choose the new attachment where you want it.', 'affstackdev-image-manager' ) . '
'; } /** * Website galleries: which folders may be shown, and how to place one. * * @param array $folders The library's folders. */ private function gallery_panel( array $folders ): void { $shown = array_values( array_filter( $folders, static fn( $t ) => Folder_Gallery::shown( (int) $t->term_id ) ) ); echo '
' . esc_html__( 'Website galleries', 'affstackdev-image-manager' ) . '

' . esc_html__( 'A page can show the pictures of a folder as a gallery: add the block Image Manager folder gallery, or the shortcode below. Pictures you add to the folder later appear by themselves. Visitors see a folder only after you switch it on here; until then the gallery shows them nothing.', 'affstackdev-image-manager' ) . '

'; if ( $shown ) { echo ''; } else { echo '

' . esc_html__( 'No folder is shown on the website yet.', 'affstackdev-image-manager' ) . '

'; } $this->begin( 'library_gallery' ); echo '
'; self::check( 'gallery_confirm', __( 'Every picture in this folder may be shown to visitors on pages that place its gallery.', 'affstackdev-image-manager' ) ); echo '
'; } /** Import from another folder plugin: what was found, then one confirmed copy. */ private function import_panel(): void { $found = Folder_Import::found(); $state = Folder_Import::state(); echo '
' . esc_html__( 'Import folders from another plugin', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Copies the folders of FileBird, Real Media Library, HappyFiles, Enhanced Media Library or Folders (Premio) into Image Manager folders, with the same tree. Files are added to the folders and never taken out of one. The other plugin’s folders stay as they are; it may be active or not. Running an import again adds nothing twice.', 'affstackdev-image-manager' ) . '

'; if ( $state ) { echo '

' . esc_html( Folder_Import::message( $state ) ) . '

'; } if ( $state && 'more' === $state['status'] ) { $this->begin( 'folder_import' ); echo ''; } if ( ! $found ) { echo '

' . esc_html__( 'No folders of these plugins were found on this site.', 'affstackdev-image-manager' ) . '

'; return; } foreach ( $found as $key => $f ) { /* translators: %d: number of folders in the other plugin. */ $folders_text = sprintf( _n( '%d folder', '%d folders', (int) $f['folders'], 'affstackdev-image-manager' ), (int) $f['folders'] ); /* translators: %d: number of files placed in the other plugin's folders (a file in two folders counts twice). */ $pairs_text = sprintf( _n( '%d file placement', '%d file placements', (int) $f['pairs'], 'affstackdev-image-manager' ), (int) $f['pairs'] ); /* translators: 1: the other plugin's folders, such as "12 folders"; 2: its file placements, such as "340 file placements". */ echo '
' . esc_html( $f['name'] ) . '

' . esc_html( sprintf( __( '%1$s and %2$s.', 'affstackdev-image-manager' ), $folders_text, $pairs_text ) ) . '

'; if ( $f['too_many'] ) { echo '

' . esc_html( Folder_Import::human_error( 'folder_import_too_many' ) ) . '

'; continue; } $this->begin( 'folder_import' ); echo ''; /* translators: %s: other plugin's name. */ self::check( 'import_confirm', sprintf( __( 'Copy the folders of %s into Image Manager. Its own folders are not changed.', 'affstackdev-image-manager' ), $f['name'] ) ); /* translators: %s: other plugin's name. */ echo ''; } echo ''; } /** Files with no use found: a library-wide check that lists, and never deletes. */ /** 1.9.0 (D-500 gap 2): make the sizes pictures are missing and those out of date; nothing is overwritten or deleted. */ private function sizes_panel(): void { $s = Image_Sizes::state(); echo '

' . esc_html__( 'Make missing sizes', 'affstackdev-image-manager' ) . '

' . esc_html__( 'For each picture in the library, WordPress makes the registered sizes it does not have yet, and again those whose dimensions no longer match (after a theme change). Nothing is overwritten or deleted: the file of an out-of-date size stays and is kept with the picture, so pages that use it keep working. Sizes left out under Settings are not made.', 'affstackdev-image-manager' ) . '

'; if ( $s ) { /* translators: 1: pictures checked; 2: pictures that got sizes; 3: sizes made; 4: sizes made again; 5: UTC date and time the check started. */ echo '

' . esc_html( sprintf( __( '%1$d pictures checked; %2$d got sizes: %3$d made, %4$d made again. Started %5$s UTC.', 'affstackdev-image-manager' ), (int) $s['checked'], (int) $s['pictures'], (int) $s['made'], (int) $s['remade'], gmdate( 'Y-m-d H:i', (int) $s['started'] ) ) ) . '

'; if ( ! empty( $s['failed'] ) ) { /* translators: %s: media IDs. */ echo '

' . esc_html( sprintf( __( 'Not possible for media %s: the original file is missing, or you may not edit it.', 'affstackdev-image-manager' ), implode( ', ', array_map( 'intval', array_slice( (array) $s['failed'], 0, 30 ) ) ) ) ) . '

'; } if ( 'done' === $s['status'] && ! empty( $s['unregistered'] ) ) { echo '

' . esc_html__( 'Sizes no theme or plugin registers now (kept, not removed):', 'affstackdev-image-manager' ) . '

'; } } if ( $s && 'more' === $s['status'] ) { $this->begin( 'image_sizes' ); echo ''; } $this->begin( 'image_sizes' ); echo '
'; } /** 1.9.0 (D-500 gap 10): files in the uploads folder that belong to no attachment; a report, nothing is deleted. */ private function unknown_files_panel(): void { $s = Unknown_Files::state(); echo '

' . esc_html__( 'Files the Media Library does not know', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Checks the files in the uploads folder itself and in its year and month folders against every file of the Media Library: each item’s file, its sizes, its original and the files WordPress’s image editor kept. A file that belongs to none of them is listed.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Listed does not mean safe to delete: other plugins can keep files there, for example image optimizers that put a .webp copy beside each picture, and imports or old site moves can leave files that pages still link to. Image Manager deletes and moves nothing.', 'affstackdev-image-manager' ) . '

'; if ( $s ) { /* translators: 1: files compared; 2: folders checked; 3: folders to check; 4: UTC date and time the check started. */ echo '

' . esc_html( sprintf( __( '%1$d files compared in %2$d of %3$d folders. Check started %4$s UTC.', 'affstackdev-image-manager' ), (int) $s['files'], min( (int) $s['cursor'], count( (array) $s['folders'] ) ), count( (array) $s['folders'] ), gmdate( 'Y-m-d H:i', (int) $s['started'] ) ) ) . '

'; if ( ! empty( $s['other'] ) ) { /* translators: %s: folder names, separated by commas. */ echo '

' . esc_html( sprintf( __( 'Not checked (folders of other plugins or of the site): %s.', 'affstackdev-image-manager' ), implode( ', ', array_map( 'strval', (array) $s['other'] ) ) ) ) . '

'; } } if ( $s && 'more' === $s['status'] ) { $this->begin( 'unknown_files' ); echo ''; } if ( $s && 'done' === $s['status'] ) { $total = count( (array) $s['listed'] ) + (int) $s['more']; /* translators: 1: number of files; 2: their total size, such as 12 MB. */ echo '

' . esc_html( sprintf( _n( '%1$d file the Media Library does not know (%2$s).', '%1$d files the Media Library does not know (%2$s).', $total, 'affstackdev-image-manager' ), $total, size_format( (int) $s['bytes'] ) ) ) . '

'; if ( $s['listed'] ) { echo ''; if ( $total > 100 ) { /* translators: %d: files not shown in the list. */ echo '

' . esc_html( sprintf( _n( 'And %d more.', 'And %d more.', $total - 100, 'affstackdev-image-manager' ), $total - 100 ) ) . '

'; } } } $this->begin( 'unknown_files' ); echo '
'; } /** * Print the Files with no use found panel: the check's progress and the files it listed, with forms to start or * continue a check and to put the files listed into a collection. */ private function unused_panel(): void { $s = Unused_Media::state(); echo '
' . esc_html__( 'Files with no use found', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Checks the whole library at once: saved pages, posts, products, templates, reusable blocks, Additional CSS, comments and reviews, featured images, product galleries and category pictures, picture addresses in custom fields and page-builder data, the site icon and logo, the header and background image, WooCommerce’s placeholder and email header, the widgets, and the folders that Image Manager folder galleries show. A file is listed when none of these refer to it by its ID or by any of its file names.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Listed does not mean unused: theme and plugin files, numbers in custom fields, CSS files, emails already sent, other websites and links to your files from elsewhere are not checked. Image Manager deletes nothing; look at each file first, for example with Quick details and its Check usage.', 'affstackdev-image-manager' ) . '

'; if ( $s ) { /* translators: 1: content items read; 2: media files compared; 3: UTC date and time of the check. */ echo '

' . esc_html( sprintf( __( '%1$d content items read, %2$d files compared. Check started %3$s UTC.', 'affstackdev-image-manager' ), (int) $s['checked'], (int) $s['files_checked'], gmdate( 'Y-m-d H:i', (int) $s['started'] ) ) ); if ( $s['skipped'] ) { /* translators: %d: number of content items or settings too large to read. */ echo ' ' . esc_html( sprintf( _n( '%d item was too large to read, so a file used only there can appear in the list.', '%d items were too large to read, so a file used only there can appear in the list.', (int) $s['skipped'], 'affstackdev-image-manager' ), (int) $s['skipped'] ) ); } echo '

'; } if ( $s && 'limit' === $s['status'] ) { echo '

' . esc_html__( 'This site refers to its files in more places than one check can keep track of, so no list is shown: it could name files that are in use.', 'affstackdev-image-manager' ) . '

'; } if ( $s && 'more' === $s['status'] ) { $this->begin( 'unused_check' ); echo ''; } if ( $s && 'done' === $s['status'] ) { $total = count( $s['listed'] ) + (int) $s['more_listed']; /* translators: %d: files with no use found. */ echo '

' . esc_html( sprintf( _n( '%d file with no use found.', '%d files with no use found.', $total, 'affstackdev-image-manager' ), $total ) ) . '

'; if ( $s['listed'] ) { echo ''; if ( $total > 60 ) { /* translators: %d: listed files not shown on this screen. */ echo '

' . esc_html( sprintf( __( '%d more are not shown here. Put the list into a collection to see every one in the library.', 'affstackdev-image-manager' ), $total - 60 ) ) . '

'; } if ( $s['more_listed'] ) { /* translators: %d: files beyond what one collection step takes. */ echo '

' . esc_html( sprintf( __( 'The list keeps the first 5,000; %d more were found. Review those first, then check again.', 'affstackdev-image-manager' ), (int) $s['more_listed'] ) ) . '

'; } if ( $s['collection'] && $s['collected'] >= count( $s['listed'] ) ) { $term = get_term( (int) $s['collection'], Library::COLLECTION ); if ( $term && ! is_wp_error( $term ) ) { echo '

' . esc_html( sprintf( __( 'Show the collection %s', 'affstackdev-image-manager' ), $term->name ) ) . '

'; } } else { $this->begin( 'unused_collect' ); echo ''; } } } $this->begin( 'unused_check' ); echo '

' . esc_html__( 'Each step works for a few seconds and saves its place. No file, page or setting is changed.', 'affstackdev-image-manager' ) . '

'; } /** * Show a folder in website galleries (once confirmed) or stop showing it. * * @param array $data The form's fields. * @return string The notice. * @throws \RuntimeException With choose_media_term, confirm_media or media_term_failed. */ private function library_gallery( array $data ): string { $folder = isset( $data['gallery_folder'] ) && is_scalar( $data['gallery_folder'] ) ? absint( $data['gallery_folder'] ) : 0; $on = 'off' !== ( isset( $data['gallery_action'] ) ? sanitize_key( (string) $data['gallery_action'] ) : 'on' ); if ( ! $folder ) { throw new \RuntimeException( 'choose_media_term' ); } if ( $on && empty( $data['gallery_confirm'] ) ) { throw new \RuntimeException( 'confirm_media' ); } if ( ! Folder_Gallery::set_shown( $folder, $on ) ) { throw new \RuntimeException( 'media_term_failed' ); } return $on ? __( 'The folder is shown in website galleries. Pages that place its gallery show its pictures to visitors.', 'affstackdev-image-manager' ) : __( 'The folder is no longer shown on the website. Its galleries show visitors nothing; the pictures and the folder are unchanged.', 'affstackdev-image-manager' ); } /** * Make a folder or collection. * * @param array $data The form's fields. * @return string The notice. * @throws \RuntimeException With media_term_failed when it cannot be made. */ private function library_create( array $data ): string { $kind = isset( $data['taxonomy'] ) && 'collection' === sanitize_key( (string) $data['taxonomy'] ) ? Library::COLLECTION : Library::FOLDER; $name = isset( $data['term_name'] ) && is_string( $data['term_name'] ) ? $data['term_name'] : ''; $parent = isset( $data['parent'] ) && is_scalar( $data['parent'] ) ? absint( $data['parent'] ) : 0; $result = Library::create_term( $kind, $name, $parent ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'media_term_failed' ); } return Library::FOLDER === $kind ? __( 'Folder created. Existing file paths and URLs are unchanged.', 'affstackdev-image-manager' ) : __( 'Collection created. Existing file paths and URLs are unchanged.', 'affstackdev-image-manager' ); } /** * Rename or delete a folder or collection, once confirmed. * * @param array $data The form's fields. * @return string The notice. * @throws \RuntimeException With confirm_media or media_term_failed. */ private function library_term( array $data ): string { if ( isset( $data['organization_term'] ) ) { if ( ! is_string( $data['organization_term'] ) || ! preg_match( '/^(folder|collection):([1-9][0-9]*)$/D', $data['organization_term'], $match ) ) { throw new \RuntimeException( 'media_term_failed' ); } $data['taxonomy'] = $match[1]; $data['term_id'] = $match[2]; } if ( empty( $data['term_confirm'] ) ) { throw new \RuntimeException( 'confirm_media' ); } $tax = isset( $data['taxonomy'] ) && 'collection' === sanitize_key( (string) $data['taxonomy'] ) ? Library::COLLECTION : Library::FOLDER; $term_id = isset( $data['term_id'] ) && is_scalar( $data['term_id'] ) ? absint( $data['term_id'] ) : 0; $action = isset( $data['term_action'] ) ? sanitize_key( (string) $data['term_action'] ) : ''; $name = isset( $data['term_name'] ) && is_string( $data['term_name'] ) ? $data['term_name'] : ''; if ( ! Library::change_term( $tax, $term_id, $action, $name ) ) { throw new \RuntimeException( 'media_term_failed' ); } return 'delete' === $action ? __( 'Organization label deleted. Media files, URLs and attachments were not deleted.', 'affstackdev-image-manager' ) : __( 'Organization label renamed. Media files and URLs are unchanged.', 'affstackdev-image-manager' ); } /** * Apply a library bulk action to the checked media (at most 100), once confirmed: update their metadata, or add them * to, move them to or remove them from a folder or collection. * * @param array $data The form's fields. * @return string The notice. * @throws \RuntimeException With choose_media, confirm_media, invalid_media_action, choose_media_term or * media_action_folder. */ private function library_bulk( array $data ): string { $raw = isset( $data['ids'] ) && is_array( $data['ids'] ) ? $data['ids'] : array(); $ids = array_values( array_unique( array_filter( array_map( 'absint', $raw ) ) ) ); if ( ! $ids ) { throw new \RuntimeException( 'choose_media' ); } if ( count( $ids ) > 100 ) { $ids = array_slice( $ids, 0, 100 ); } if ( empty( $data['media_confirm'] ) ) { throw new \RuntimeException( 'confirm_media' ); } $action = isset( $data['media_action'] ) ? sanitize_key( (string) $data['media_action'] ) : ''; if ( 'metadata' === $action ) { $n = Library::update_metadata( $ids, $data ); /* translators: %d: number of media items whose metadata was updated. */ return sprintf( _n( '%d media item(s) updated. Contextual page alt text was not rewritten.', '%d media item(s) updated. Contextual page alt text was not rewritten.', $n, 'affstackdev-image-manager' ), $n ); } if ( str_starts_with( $action, 'folder_' ) ) { $tax = Library::FOLDER; $term = absint( $data['folder_id'] ?? 0 ); } elseif ( str_starts_with( $action, 'collection_' ) ) { $tax = Library::COLLECTION; $term = absint( $data['collection_id'] ?? 0 ); } else { throw new \RuntimeException( 'invalid_media_action' ); } if ( ! $term ) { throw new \RuntimeException( 'choose_media_term' ); } $valid_term = get_term( $term, $tax ); if ( ! $valid_term || is_wp_error( $valid_term ) ) { throw new \RuntimeException( 'media_action_folder' ); } foreach ( $ids as $attachment ) { Media_Actions::authorize( $attachment ); } if ( str_ends_with( $action, 'remove' ) ) { $n = Library::detach( $ids, $tax, array( $term ) ); } else { $n = Library::assign( $ids, $tax, array( $term ), 'folder_move' === $action ); } /* translators: %d: number of media items added to, moved to or removed from a folder or collection. */ return sprintf( _n( '%d media item(s) organized. File paths, URLs and attachment IDs are unchanged.', '%d media item(s) organized. File paths, URLs and attachment IDs are unchanged.', $n, 'affstackdev-image-manager' ), $n ); } /** * Print a run's choices: compression, what happens when files qualify, what a scan goes on to, the advanced choices * (content, output, effort, metadata), the public-page check after applying and the confirmations. * * @param bool $scan Whether the form also scans (it then asks what to do after scanning). * @param bool $confirm Whether to ask for the run's own confirmation. */ private function run_fields( bool $scan = false, bool $confirm = true ): void { $recipe = Policy::current(); echo '
'; if ( $scan ) { echo ''; } echo '
' . esc_html__( 'Advanced compression choices', 'affstackdev-image-manager' ) . '
'; foreach ( array( 'content' => array( __( 'Content', 'affstackdev-image-manager' ), 'auto' => __( 'Automatic — protect graphics', 'affstackdev-image-manager' ), 'photo' => __( 'Photography', 'affstackdev-image-manager' ), 'graphics' => __( 'Graphics / screenshot / logo', 'affstackdev-image-manager' ), ), 'formats' => array( __( 'Output', 'affstackdev-image-manager' ), 'auto' => __( 'Choose supported formats', 'affstackdev-image-manager' ), 'native' => __( 'Original format', 'affstackdev-image-manager' ), 'webp' => 'WebP', 'avif' => __( 'AVIF — photographic compression', 'affstackdev-image-manager' ), ), 'effort' => array( __( 'Effort', 'affstackdev-image-manager' ), 'balanced' => __( 'Balanced', 'affstackdev-image-manager' ), 'fast' => __( 'Faster processing', 'affstackdev-image-manager' ), ), 'metadata' => array( __( 'Metadata', 'affstackdev-image-manager' ), 'preserve' => __( 'Preserve supported metadata', 'affstackdev-image-manager' ), 'remove_gps' => __( 'Remove supported EXIF GPS from copies', 'affstackdev-image-manager' ), 'remove_all' => __( 'Remove photo details from copies (camera, dates, location, captions; orientation and colour profile kept)', 'affstackdev-image-manager' ), ), ) as $key => $options ) { $title = array_shift( $options ); echo ''; } echo '

' . esc_html__( 'Extra sizes fill the larger gaps between the sizes a picture already has: at most four per picture, made from its own pixels (never larger than the original, never cropped). They use extra storage and supported responsive output. Pictures with a colour profile or transparency keep the sizes they have.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'These choices affect this run, not your global defaults. Automatic content handling protects graphics and screenshots.', 'affstackdev-image-manager' ) . '

'; self::check( 'verify_after_apply', __( 'After automatic approval, check what a known public page offers.', 'affstackdev-image-manager' ), true, __( 'Checks anonymous page HTML, not browser downloads. A cache or unsupported output can still serve the original.', 'affstackdev-image-manager' ) ); self::check( 'compression_confirm', __( 'Allow controlled pixel changes when High Quality or Maximum Savings is selected.', 'affstackdev-image-manager' ) ); self::check( 'resize_confirm', __( 'Allow additional smaller display sizes when selected above. This resizes copies, not originals.', 'affstackdev-image-manager' ) ); if ( $confirm ) { self::check( 'bulk_confirm', __( 'Apply these choices to the selected scope. Originals are preserved; optimization does not change individual delivery preferences.', 'affstackdev-image-manager' ) ); } } /** * Print the bulk editor of the image results: the action, its scope (checked images, or all that match), the run's * choices, the confirmation and the progress. * * @param int $total How many images match. */ private function bulk_controls( int $total ): void { $v = Workflow::snapshot(); echo '

' . esc_html__( 'Bulk editor', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Choose an action and its scope. Selecting all matching results includes every result page, not just the images shown here.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'bulk_run', false, 'afim-bulk-form' ); echo '
'; $this->run_fields( false, false ); echo '
'; self::check( 'bulk_confirm', __( 'Apply the selected action to this scope. Original files will not be deleted.', 'affstackdev-image-manager' ) ); echo '
' . esc_html__( 'Run status / pause / stop', 'affstackdev-image-manager' ) . '

' . esc_html__( 'All-page selection also works in PHP mode. Finish or discard existing comparisons before replacing them with a new bulk run.', 'affstackdev-image-manager' ) . '

'; echo '
' . wp_kses( $this->progress( $v ), Admin_Layout::allowed_html() ) . '
'; } /** * An image card's status: its result, the bytes saved, its copies in use, its extra display sizes and whether a * comparison waits. * * @param array $r The source row. * @return string The HTML. */ private function card_status( array $r ): string { $result = Results::row( $r ); $totals = Repository::manifest_totals( $r['manifest'] ); $html = '' . esc_html( 'ready' === $r['state'] ? __( 'Optimized files ready', 'affstackdev-image-manager' ) : $result['label'] ) . ''; if ( $totals['saved_bytes'] ) { /* translators: %s: bytes saved, such as 120 KB. */ $html .= '' . esc_html( sprintf( __( '%s saved across same-size copies', 'affstackdev-image-manager' ), size_format( $totals['saved_bytes'], 1 ) ) ) . ''; } /* translators: %s: number of optimized files in use for this image. */ $html .= '' . esc_html( sprintf( _n( '%s active optimized file(s)', '%s active optimized file(s)', (int) $totals['copy_count'], 'affstackdev-image-manager' ), (string) $totals['copy_count'] ) ) . '' . esc_html( $result['delivery'] ) . '' . esc_html( Verification::label( $r ) ) . ''; if ( ! empty( $r['manifest']['responsive'] ) ) { /* translators: %d: number of display sizes Image Manager added for this image. */ $html .= '' . esc_html( sprintf( _n( '%d additional display size(s)', '%d additional display size(s)', count( $r['manifest']['responsive'] ), 'affstackdev-image-manager' ), count( $r['manifest']['responsive'] ) ) ) . ''; } if ( ! empty( $r['manifest']['draft']['complete'] ) ) { $html .= '' . esc_html__( 'Comparison ready — not applied', 'affstackdev-image-manager' ) . ''; } return $html; } /** * An image card's size results: its reason, its delivery coverage, the last public-page check while it still applies, * and the result of each size and extra display size. * * @param array $r The source row. * @return string The HTML. */ private function size_results( array $r ): string { $html = '

' . esc_html( self::friendly_reason( $r['reason'] ) ) . '

'; $html .= '

' . esc_html__( 'Delivery coverage:', 'affstackdev-image-manager' ) . ' ' . esc_html( Delivery::coverage_label( $r ) ) . '

'; $check = $r['details']['delivery_check'] ?? array(); if ( ( $check['signature'] ?? '' ) === Verification::signature( $r ) ) { $html .= '

' . esc_html__( 'Last public-page check:', 'affstackdev-image-manager' ) . ' ' . esc_html( Verification::label( $r ) ) . '

'; } foreach ( $r['manifest']['results'] ?? array() as $rel => $result ) { $html .= '

' . esc_html( basename( $rel ) ) . ' · ' . esc_html( self::friendly_reason( $result['reason'] ) ) . '

'; } foreach ( $r['manifest']['responsive_results'] ?? array() as $key => $result ) { $html .= '

' . esc_html( basename( $key ) ) . ' · ' . esc_html( Results::reason( $result['reason'] ) ) . '

'; } return $html; } /** * Print one image's card: its checkbox for the bulk editor, picture, status and page; for a Media Library image * Compare, Edit image, Use original or Use optimized, Retry optimization, Check public page and Exclude or Include; * and its details, with Delete managed copies only. * * @param array $r The source row. */ private function image_card( array $r ): void { $id = (int) $r['id']; $attachment = (int) $r['attachment_id']; $theme = Theme::is_source( $r ); $url = $r['details']['urls'][0] ?? ''; $title = $attachment ? get_the_title( $attachment ) : basename( wp_parse_url( $url, PHP_URL_PATH ) ?: '' ); if ( ! $title ) { $title = __( 'Website image', 'affstackdev-image-manager' ); } $result = Results::row( $r ); echo '
'; if ( $attachment ) { echo wp_get_attachment_image( $attachment, array( 96, 80 ), false, array( 'loading' => 'lazy' ) ); } elseif ( $theme && Theme::resolve( $url ) ) { echo ''; } else { echo ''; } echo '

' . esc_html( $title ) . '

' . esc_html( $theme ? __( 'Active theme image', 'affstackdev-image-manager' ) : ( $attachment ? __( 'Media Library image', 'affstackdev-image-manager' ) : __( 'Website image', 'affstackdev-image-manager' ) ) ) . '

'; if ( ! empty( $r['details']['pages'][0] ) ) { echo '' . esc_html__( 'View page', 'affstackdev-image-manager' ) . ' '; } echo '
' . wp_kses( $this->card_status( $r ), Admin_Layout::allowed_html() ) . '

' . esc_html( $result['reason'] ) . '

'; if ( $attachment || $theme ) { echo '
' . esc_html__( 'Compare compression', 'affstackdev-image-manager' ) . ''; if ( $attachment ) { echo '' . esc_html__( 'Edit image', 'affstackdev-image-manager' ) . ''; } $this->selection_form( $id, $r['delivery'] ? 'original' : 'optimized', $r['delivery'] ? __( 'Use original', 'affstackdev-image-manager' ) : __( 'Use optimized', 'affstackdev-image-manager' ) ); $this->selection_form( $id, 'retry', __( 'Retry optimization', 'affstackdev-image-manager' ) ); $this->begin( 'delivery_check' ); echo '' . esc_html__( 'Check in browser', 'affstackdev-image-manager' ) . ''; $this->selection_form( $id, $r['excluded'] ? 'include' : 'exclude', $r['excluded'] ? __( 'Include image', 'affstackdev-image-manager' ) : __( 'Exclude image', 'affstackdev-image-manager' ) ); echo '
'; } echo '
' . esc_html__( 'Image details and size results', 'affstackdev-image-manager' ) . '
' . wp_kses( $this->size_results( $r ), Admin_Layout::allowed_html() ) . '
'; if ( $attachment || $theme ) { $this->begin( 'selected' ); echo ''; self::check( 'cleanup_confirm', __( 'Optimized delivery is off and cached pages no longer reference this image’s copies.', 'affstackdev-image-manager' ) ); echo ''; } echo '
'; } /** * The words for a result reason. * * @param string $reason The reason code. * @return string The words. */ private static function friendly_reason( string $reason ): string { return Results::reason( $reason ); } /** * Print a compression recipe's choices (mode, content, output, effort and metadata), set to the recipe's values. * * @param array $recipe The recipe. */ private static function recipe_fields( array $recipe ): void { echo '
'; $fields = array( 'mode' => array( __( 'Compression', 'affstackdev-image-manager' ), array( 'lossless' => __( 'Lossless — preserve pixels', 'affstackdev-image-manager' ), 'high' => __( 'High Quality — controlled compression', 'affstackdev-image-manager' ), 'maximum' => __( 'Maximum Savings — stronger compression', 'affstackdev-image-manager' ), ), ), 'content' => array( __( 'Image content', 'affstackdev-image-manager' ), array( 'auto' => __( 'Automatic, with graphics protection', 'affstackdev-image-manager' ), 'photo' => __( 'Photograph', 'affstackdev-image-manager' ), 'graphics' => __( 'Screenshot, text, logo or graphic', 'affstackdev-image-manager' ), ), ), 'formats' => array( __( 'Output formats', 'affstackdev-image-manager' ), array( 'auto' => __( 'Choose from supported formats', 'affstackdev-image-manager' ), 'native' => __( 'Keep the original format', 'affstackdev-image-manager' ), 'webp' => 'WebP', 'avif' => __( 'AVIF (photographs, lossy modes)', 'affstackdev-image-manager' ), ), ), 'effort' => array( __( 'Processing effort', 'affstackdev-image-manager' ), array( 'balanced' => __( 'Balanced', 'affstackdev-image-manager' ), 'fast' => __( 'Faster processing', 'affstackdev-image-manager' ), ), ), 'metadata' => array( __( 'Metadata', 'affstackdev-image-manager' ), array( 'preserve' => __( 'Preserve supported metadata', 'affstackdev-image-manager' ), 'remove_gps' => __( 'Remove standard EXIF GPS from copies', 'affstackdev-image-manager' ), 'remove_all' => __( 'Remove photo details from copies (camera, dates, location, captions; orientation and colour profile kept)', 'affstackdev-image-manager' ), ), ), ); foreach ( $fields as $key => $field ) { echo ''; } echo '

' . esc_html__( 'High Quality and Maximum Savings allow pixel changes. Graphics and transparency remain lossless; choose Photograph only for photographic content. No mode resizes, crops, sharpens or edits the source.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Quality and metadata details', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Lossless uses exact pixel or native image-data checks. Photographic modes use format-specific settings and sampled SSIM/color-error limits, plus your visual review. A sampled check does not prove that differences are invisible. If a candidate fails validation or is not meaningfully smaller, the original remains.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Metadata preservation can limit available formats. Unverified color, orientation, animation and precision stay unchanged. Removing GPS only targets standard EXIF GPS blocks in generated copies; it does not remove other metadata, alter public originals or their fallback URLs, or recall files already downloaded. Ambiguous metadata stays unchanged for review.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Encoding effort is not image quality. Native JPEG lossless optimization requires a safe, locally installed jpegtran backend; AVIF requires an actual encode/decode test on this host. Missing capabilities are listed in Tools.', 'affstackdev-image-manager' ) . '

'; } /** * Print an image's comparison screen: its compression choices with Prepare comparison, and the comparison itself; or * that the image is not available. */ private function preview(): void { try { $r = Preview::row( $this->image ); } catch ( \Throwable $e ) { echo '

' . esc_html__( 'This image is unavailable or you do not have permission to manage it.', 'affstackdev-image-manager' ) . '

'; return; } $d = $r['manifest']['draft'] ?? array(); $recipe = isset( $d['recipe'] ) ? Policy::clean( $d['recipe'] ) : Policy::current( $r ); echo '
' . esc_html__( 'Back to images', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Compare compression', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Prepare a comparison, review the same-size images, then approve the new copies. Preparing a preview does not change this image’s published alternatives or delivery setting.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'preview_prepare' ); self::recipe_fields( $recipe ); self::check( 'compression_confirm', __( 'I understand that photographic High Quality / Maximum Savings can change pixels.', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'The comparison includes this image’s existing native sizes. It uses the saved background queue and obeys Pause. Your existing prepared copies remain active until you approve.', 'affstackdev-image-manager' ) . '

'; echo '
' . wp_kses( $this->preview_result(), Admin_Layout::allowed_html() ) . '
'; } /** * A fingerprint of what the comparison shows (its draft, the image's state, the pause, the size and the format), so * the browser knows when to refresh it. * * @return string SHA-256, or '' when no image is open. */ private function preview_key(): string { if ( ! $this->image ) { return ''; } $r = Repository::row( $this->image ); return hash( 'sha256', wp_json_encode( array( $r['manifest']['draft'] ?? array(), $r['state'] ?? '', (bool) Settings::get()['paused'], $this->size, $this->format ) ) ); } /** * The comparison: the original beside each prepared copy of the chosen size and format, the results for every size, * and the Approve these copies and Discard comparison forms; or what it is waiting for. * * @return string The HTML. */ public function preview_result(): string { try { $r = Preview::row( $this->image ); $files = Responsive::preview_files( $r, Sources::row_files( $r ) ); } catch ( \Throwable $e ) { return '

' . esc_html__( 'The image is unavailable.', 'affstackdev-image-manager' ) . '

'; } return Plugin::capture( function () use ( $r, $files ): void { $this->print_preview_result( $r, $files ); } ); } /** * Print the comparison (preview_result()). * * @param array $r The image's index row. * @param array $files Its files that have prepared copies. */ private function print_preview_result( array $r, array $files ): void { $d = $r['manifest']['draft'] ?? array(); if ( ! $d ) { echo '

' . esc_html__( 'Ready for a comparison', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Choose your settings above and prepare a comparison. Your source file and existing delivery are unchanged.', 'affstackdev-image-manager' ) . '

'; return; } echo '

' . esc_html( ! empty( $d['complete'] ) ? __( 'Comparison ready for review', 'affstackdev-image-manager' ) : ( 'failed' === $r['state'] ? __( 'Comparison needs attention', 'affstackdev-image-manager' ) : __( 'Preparing comparison', 'affstackdev-image-manager' ) ) ) . '

'; if ( empty( $d['complete'] ) ) { echo '

' . esc_html( Settings::get()['paused'] ? __( 'Processing is paused. Resume from Optimize when ready.', 'affstackdev-image-manager' ) : ( 'failed' === $r['state'] ? __( 'This comparison needs attention. Discard it and retry after reviewing Tools.', 'affstackdev-image-manager' ) : __( 'Progress is saved. Existing published copies remain unchanged.', 'affstackdev-image-manager' ) ) ) . '

' . esc_html__( 'Refresh comparison', 'affstackdev-image-manager' ) . ''; } if ( ! empty( $d['complete'] ) && $files ) { $keys = array_keys( $files ); $rel = $keys[0]; foreach ( $keys as $key ) { if ( hash( 'sha256', $key ) === $this->size ) { $rel = $key; break; } } $f = $files[ $rel ]; $size = hash( 'sha256', $rel ); $v = $d['variants'][ $rel ] ?? $d['responsive'][ $rel ] ?? array(); $choices = Variants::choices( $v ); $chosen = $choices[0] ?? null; foreach ( $choices as $c ) { if ( Variants::mime( $c ) === $this->format ) { $chosen = $c; break; } } echo ''; $original = Preview::url( $this->image, $size, 'original' ); echo '
' . esc_html__( 'Original', 'affstackdev-image-manager' ) . ' · ' . esc_html( size_format( (int) filesize( $f['path'] ), 1 ) ) . '
'
				. esc_attr__( 'Original image for comparison', 'affstackdev-image-manager' ) . '
'; if ( $chosen ) { $url = Preview::url( $this->image, $size, 'candidate', $d['id'], Variants::mime( $chosen ) ); echo '
' . esc_html( strtoupper( Variants::extension( Variants::mime( $chosen ) ) ) . ' · ' . size_format( $chosen['output_bytes'], 1 ) . ' · ' . Policy::label( $chosen['fidelity'] ) . ( ! empty( $chosen['resized'] ) ? ' · ' . __( 'resized display copy', 'affstackdev-image-manager' ) : '' ) ) . '
'
					. esc_attr__( 'Prepared image for comparison', 'affstackdev-image-manager' ) . '
'; } else { echo '
' . esc_html__( 'Original retained for this size', 'affstackdev-image-manager' ) . '

' . esc_html( Results::reason( $d['results'][ $rel ]['reason'] ?? __( 'No qualifying smaller copy.', 'affstackdev-image-manager' ) ) ) . '

'; } echo '

' . esc_html__( 'Both views use the same display scale. Open either image for full-resolution inspection. Preview URLs require your current administrator permission.', 'affstackdev-image-manager' ) . '

'; if ( $chosen ) { echo '

' . esc_html( Results::reason( $chosen['verification'] ) ) . '

' . esc_html( sprintf( /* translators: 1: encoder name; 2: milliseconds the encode took, or "not recorded"; 3: PHP peak memory, such as 24 MB, or "not recorded". */ __( 'Encoder: %1$s · %2$s ms for this size’s candidate pass. Reported PHP peak: %3$s. Native-library memory is not included in PHP accounting.', 'affstackdev-image-manager' ), $chosen['encoder'], isset( $chosen['encode_milliseconds'] ) ? (string) $chosen['encode_milliseconds'] : __( 'not recorded', 'affstackdev-image-manager' ), isset( $chosen['peak_memory_bytes'] ) ? size_format( $chosen['peak_memory_bytes'], 1 ) : __( 'not recorded', 'affstackdev-image-manager' ) ) ) . '

'; } echo '
' . esc_html__( 'Results for all existing sizes', 'affstackdev-image-manager' ) . ''; foreach ( $d['results'] ?? array() as $key => $result ) { echo '

' . esc_html( basename( $key ) ) . ' · ' . esc_html( Results::reason( $result['reason'] ) ) . '

'; }echo '
'; if ( ! empty( $d['variants'] ) || ! empty( $d['responsive'] ) ) { $this->begin( 'preview_apply' ); echo ''; self::check( 'preview_confirm', __( 'I have reviewed these sizes and approve the new prepared copies.', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'Approval retains source files and old published copies for cached URLs. It does not turn optimized delivery on. Unsupported or incomplete responsive families still use their original output.', 'affstackdev-image-manager' ) . '

'; } } $this->begin( 'preview_discard' ); echo '
'; } /** Portable media backups, local download, copies in the owner's own remote storage, and selective recovery. */ private function backups(): void { $cap = Backup::capabilities(); $status = Backup::status(); $snapshots = Backup::index(); $remote = Remote_Backup::status(); $conn = Remote_Backup::connection(); $rs = Remote_Backup::settings(); $remote_items = Remote_Backup::index(); $remote_schedule_status = Remote_Backup::schedule_status(); if ( $this->backup ) { $this->backup_detail( $this->backup ); return; } $folders = Library::terms( Library::FOLDER ); $collections = Library::terms( Library::COLLECTION ); echo '

' . esc_html__( 'MEDIA RECOVERY', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Back up media you can keep anywhere', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Every completed snapshot can be downloaded directly to your computer. You can also keep a verified copy in one connected Google Drive or S3-compatible destination. Media never routes through an AffStackDev server.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Your data', 'affstackdev-image-manager' ) . '
'; echo '
' . wp_kses( $this->backup_progress( $status ), Admin_Layout::allowed_html() ) . '
'; if ( $status['active'] ) { echo '
'; $this->button_form( 'backup_pause', __( 'Pause backup', 'affstackdev-image-manager' ) ); echo ''; $this->button_form( 'backup_resume', __( 'Resume backup', 'affstackdev-image-manager' ), true ); echo ''; $this->button_form( 'backup_tick', __( 'Process here now', 'affstackdev-image-manager' ) ); $this->button_form( 'backup_cancel', __( 'Stop backup', 'affstackdev-image-manager' ) ); echo '
'; } else { $this->begin( 'backup_start' ); echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
' . esc_html__( 'Password protection', 'affstackdev-image-manager' ) . ''; if ( ! empty( $cap['encrypted_zip'] ) ) { self::check( 'backup_encrypt', __( 'Encrypt archive entries with a password', 'affstackdev-image-manager' ), false, __( 'Keep the password somewhere independent of this website. AffStackDev cannot recover it.', 'affstackdev-image-manager' ) ); echo '
'; } else { echo '

' . esc_html__( 'AES-encrypted ZIP creation did not pass this host capability check. Unencrypted private snapshots and downloaded archives remain available.', 'affstackdev-image-manager' ) . '

'; } echo '
'; self::check( 'backup_confirm', __( 'Create this portable media snapshot. Existing media and website output will not be changed.', 'affstackdev-image-manager' ) ); echo '
'; } echo '
' . esc_html__( 'Local download is always available', 'affstackdev-image-manager' ) . '

' . sprintf( /* translators: %s: the name of the Download to this device button, shown in bold. */ esc_html__( 'Completed local snapshots have a %s button. Remote storage is optional. Essential recovery protects source media and data; Published media recovery also includes current generated files. This remains media recovery, not a full-site/database backup.', 'affstackdev-image-manager' ), '' . esc_html__( 'Download to this device', 'affstackdev-image-manager' ) . '' ) . '

'; echo '

' . esc_html__( 'Remote transfer', 'affstackdev-image-manager' ) . '

' . esc_html( $conn['label'] . ' · ' . $conn['detail'] ) . '

' . esc_html( $conn['ready'] ? __( 'Ready', 'affstackdev-image-manager' ) : __( 'Not connected', 'affstackdev-image-manager' ) ) . '
' . wp_kses( $this->remote_progress( $remote ), Admin_Layout::allowed_html() ) . '
'; if ( $remote['active'] ) { echo '
'; $this->button_form( 'remote_pause', __( 'Pause transfer', 'affstackdev-image-manager' ) ); echo ''; $this->button_form( 'remote_resume', __( 'Resume transfer', 'affstackdev-image-manager' ), true ); echo ''; $this->button_form( 'remote_tick', __( 'Process transfer here now', 'affstackdev-image-manager' ) ); $this->button_form( 'remote_cancel', __( 'Stop transfer', 'affstackdev-image-manager' ) ); echo '
'; } echo '
'; echo '
'; echo '

' . esc_html__( 'Local snapshots', 'affstackdev-image-manager' ) . '

' . esc_html__( 'These are private server-side recovery copies. Download anything important to your own computer; temporary server storage can be purged by a host.', 'affstackdev-image-manager' ) . '

' /* translators: %s: number of local backup snapshots. */ . esc_html( sprintf( _n( '%s saved', '%s saved', count( $snapshots ), 'affstackdev-image-manager' ), number_format_i18n( count( $snapshots ) ) ) ) . '
'; if ( ! $snapshots ) { echo '

' . esc_html__( 'No completed local media backup yet.', 'affstackdev-image-manager' ) . '

'; } foreach ( $snapshots as $snap ) { $id = (string) $snap['id']; $date = ! empty( $snap['completed'] ) ? wp_date( 'M j, Y g:i a', (int) $snap['completed'] ) : __( 'Unknown time', 'affstackdev-image-manager' ); $summary = self::backup_words( 'profile', (string) ( $snap['profile'] ?? 'essential' ) ) . ' · ' /* translators: %s: number of media items in a backup snapshot. */ . sprintf( _n( '%s items', '%s items', (int) ( $snap['items'] ?? 0 ), 'affstackdev-image-manager' ), number_format_i18n( (int) ( $snap['items'] ?? 0 ) ) ) . ' · ' . size_format( (int) ( $snap['bytes'] ?? 0 ), 1 ) . ( ! empty( $snap['encrypted'] ) ? ' · ' . __( 'Encrypted', 'affstackdev-image-manager' ) : '' ); echo '
' . esc_html( (string) ( $snap['scope_label'] ?? __( 'Media backup', 'affstackdev-image-manager' ) ) ) . '

' . esc_html( $summary ) . '

' . esc_html( $date . ' · ' . self::backup_words( 'integrity', (string) ( $snap['integrity'] ?? 'recorded' ) ) ) . '
'; $this->begin( 'backup_download', true ); echo ''; echo '' . esc_html__( 'Inspect / restore', 'affstackdev-image-manager' ) . ''; if ( $conn['ready'] ) { $this->begin( 'remote_upload_existing' ); /* translators: %s: the connected remote destination, such as Google Drive. */ echo ''; } $this->begin( 'backup_delete' ); echo ''; self::check( 'backup_delete_confirm', __( 'Delete this local snapshot copy', 'affstackdev-image-manager' ) ); echo '
'; } echo '
'; echo '

' . esc_html__( 'Remote backups', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Only backups created by Image Manager are indexed here. The customer-owned provider remains the authoritative remote storage.', 'affstackdev-image-manager' ) . '

' /* translators: %s: number of remote backups. */ . esc_html( sprintf( _n( '%s remote', '%s remote', count( $remote_items ), 'affstackdev-image-manager' ), number_format_i18n( count( $remote_items ) ) ) ) . '
'; if ( ! $remote_items ) { echo '

' . esc_html__( 'No verified remote media backup yet.', 'affstackdev-image-manager' ) . '

'; } foreach ( $remote_items as $entry ) { $id = (string) $entry['id']; $manageable = Remote_Backup::can_manage_entry( $entry ); $local = Backup::snapshot( (string) $entry['snapshot_id'] ); $date = wp_date( 'M j, Y g:i a', (int) $entry['uploaded'] ); echo '
' . esc_html( (string) $entry['scope_label'] ) . '

' . esc_html( Remote_Backup::provider_label( (string) $entry['provider'] ) . ' · ' . size_format( (int) $entry['bytes'], 1 ) . ( ! empty( $entry['encrypted'] ) ? ' · ' . __( 'Encrypted', 'affstackdev-image-manager' ) : '' ) ) . '

' . esc_html( $date . ' · ' . self::backup_words( 'integrity', (string) $entry['integrity'] ) ) . esc_html( $local ? ' · ' . __( 'Local copy available', 'affstackdev-image-manager' ) : ' · ' . __( 'Remote only', 'affstackdev-image-manager' ) ) . '
'; if ( $manageable ) { $this->begin( 'remote_download', true ); echo ''; $this->begin( 'remote_fetch' ); echo ''; if ( ! empty( $entry['encrypted'] ) ) { echo ''; }echo ''; $this->begin( 'remote_delete' ); echo ''; self::check( 'remote_delete_confirm', __( 'Delete this remote backup copy', 'affstackdev-image-manager' ) ); echo ''; } else { echo '' . esc_html__( 'Reconnect this destination to manage this copy here.', 'affstackdev-image-manager' ) . ''; } echo '
'; } echo '
'; } /** * Stored backup keys shown as words. Unknown keys keep the words they always showed. * * @param string $kind profile, or anything else for an integrity key. * @param string $key The stored key. * @return string The words. */ private static function backup_words( string $kind, string $key ): string { $words = 'profile' === $kind ? array( 'essential' => __( 'Essential', 'affstackdev-image-manager' ), 'published' => __( 'Published', 'affstackdev-image-manager' ), ) : array( 'recorded' => __( 'Recorded', 'affstackdev-image-manager' ), 'media_entries_readback_verified_archive_checksum_recorded' => __( 'Media Entries Readback Verified Archive Checksum Recorded', 'affstackdev-image-manager' ), 'manifest_opened_selected_files_verified_on_restore' => __( 'Manifest Opened Selected Files Verified On Restore', 'affstackdev-image-manager' ), 'remote_size_and_md5_verified' => __( 'Remote Size And Md5 Verified', 'affstackdev-image-manager' ), 'remote_size_and_downloaded_sha256_verified' => __( 'Remote Size And Downloaded Sha256 Verified', 'affstackdev-image-manager' ), ); return $words[ $key ] ?? ( 'profile' === $kind ? ucfirst( $key ) : ucwords( str_replace( '_', ' ', $key ) ) ); } /** * The remote transfer's status block: its title, detail and percent, and the words for its last error. * * @param array $status The transfer's status (Remote_Backup::status()). * @return string The HTML. */ private function remote_progress( array $status ): string { $html = '

' . esc_html( (string) $status['title'] ) . '

' . esc_html( (string) $status['detail'] ) . '

' . esc_html( (string) $status['percent'] ) . '%
'; if ( ! empty( $status['last_error'] ) ) { $html .= '

' . esc_html( Remote_Backup::human_error( (string) $status['last_error'] ) ) . '

'; } if ( ! empty( $status['assist'] ) ) { $html .= '

' . esc_html__( 'Browser assistance can advance this saved transfer while the workspace is visible. Remote data still moves directly between this site and your provider.', 'affstackdev-image-manager' ) . '

'; } return $html . '
'; } /** * The backup job's status block: its title, detail and percent, its selected, packaged and skipped counts while it * runs or once it is complete, and the words for its last error. * * @param array $status The job's status (Backup::status()). * @return string The HTML. */ private function backup_progress( array $status ): string { $html = '

' . esc_html( (string) $status['title'] ) . '

' . esc_html( (string) $status['detail'] ) . '

' . esc_html( (string) $status['percent'] ) . '%
'; if ( $status['active'] || 'complete' === $status['stage'] ) { $html .= '
' . esc_html( number_format_i18n( (int) $status['selected'] ) ) . '' . esc_html__( 'Selected', 'affstackdev-image-manager' ) . '
' . esc_html( number_format_i18n( (int) $status['processed'] ) ) . '' . esc_html__( 'Packaged', 'affstackdev-image-manager' ) . '
' . esc_html( number_format_i18n( (int) $status['skipped'] ) ) . '' . esc_html__( 'Skipped', 'affstackdev-image-manager' ) . '
'; } if ( ! empty( $status['last_error'] ) ) { $html .= '

' . esc_html__( 'Last item:', 'affstackdev-image-manager' ) . ' ' . esc_html( Backup::human_error( (string) $status['last_error'] ) ) . '

'; } if ( ! empty( $status['assist'] ) ) { $html .= '

' . esc_html__( 'Browser assistance can advance this saved backup while this workspace is visible. Completed progress is retained between requests.', 'affstackdev-image-manager' ) . '

'; } return $html . '
'; } /** * Print a backup's details: its summary, a page of its items, and the selective recovery form (restore an item as new * media or in place on this same site, with the backup's password when it is encrypted). * * @param string $id The backup's ID. */ private function backup_detail( string $id ): void { $snap = Backup::snapshot( $id ); if ( ! $snap ) { echo '

' . esc_html__( 'Backup unavailable', 'affstackdev-image-manager' ) . '

' . esc_html__( 'This snapshot is no longer available in this installation.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Back to backups', 'affstackdev-image-manager' ) . '
'; return; } $page = Backup::record_page( $id, $this->backup_page, 25 ); echo '

' . esc_html__( 'PORTABLE SNAPSHOT', 'affstackdev-image-manager' ) . '

' . esc_html( (string) ( $snap['scope_label'] ?? __( 'Media backup', 'affstackdev-image-manager' ) ) ) . '

' /* translators: %s: number of media items in the backup snapshot. */ . esc_html( self::backup_words( 'profile', (string) $snap['profile'] ) . ' · ' . sprintf( _n( '%s items', '%s items', (int) $snap['items'], 'affstackdev-image-manager' ), number_format_i18n( (int) $snap['items'] ) ) . ' · ' . size_format( (int) $snap['bytes'], 1 ) . ( ! empty( $snap['encrypted'] ) ? ' · ' . __( 'Password encrypted', 'affstackdev-image-manager' ) : '' ) ) . '

' . esc_html__( 'Back to backups', 'affstackdev-image-manager' ) . '
'; echo '
' . esc_html__( 'Selective recovery', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Restore an item as a new Media Library attachment anywhere. Same-site in-place restore is offered only when this snapshot belongs to the current site and the original attachment still exists; Image Manager first creates a separate safety snapshot.', 'affstackdev-image-manager' ) . '

'; echo '
'; foreach ( $page['items'] as $record ) { $portable = (string) ( $record['portable_id'] ?? '' ); $orig = (array) ( $record['original'] ?? array() ); $attachment = absint( $record['original_attachment_id'] ?? 0 ); $same_site = ! empty( $snap['site_uuid'] ) && hash_equals( Backup::site_uuid(), (string) $snap['site_uuid'] ); $can_in_place = $same_site && $attachment && 'attachment' === get_post_type( $attachment ) && current_user_can( 'edit_post', $attachment ); echo '
' . esc_html( (string) ( ( $record['title'] ?? '' ) ?: basename( (string) ( $record['attached_relative'] ?? 'media' ) ) ) ) . '

' . esc_html( (string) ( $record['mime'] ?? 'application/octet-stream' ) . ' · ' . size_format( (int) ( $orig['bytes'] ?? 0 ), 1 ) ) . '

' /* translators: %s: the first 12 characters of the item's portable ID. */ . esc_html( sprintf( __( 'Portable ID %s…', 'affstackdev-image-manager' ), substr( $portable, 0, 12 ) ) ) . '
'; $this->begin( 'backup_restore' ); echo ''; if ( ! empty( $snap['encrypted'] ) ) { echo ''; } self::check( 'restore_confirm', __( 'I understand this restores media data. In-place restore creates a safety snapshot first.', 'affstackdev-image-manager' ) ); echo '
'; } /* translators: 1: number of media items in the backup; 2: current page number; 3: number of pages. */ echo '
'; } /** * Print the Settings tab: the form with the compression defaults, delivery, large pictures, the sizes WordPress makes * for new uploads, folders for pages and posts, automatic processing, image sources, watermark, SVG files and * workspace choices; then the CDN card. */ private function settings(): void { $s = Settings::get(); echo '

' . esc_html__( 'Image settings', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Choose what to optimize and how images are delivered.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'settings' ); echo '
' . esc_html__( 'Compression defaults', 'affstackdev-image-manager' ) . ''; self::recipe_fields( Policy::current() ); self::check( 'compression_confirm', __( 'I understand that photographic High Quality / Maximum Savings can change pixels.', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'New defaults apply to newly queued, unprepared images. Existing prepared copies and per-image policies are retained. Use the Bulk editor or Compare compression in Images to change existing images deliberately.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Delivery', 'affstackdev-image-manager' ) . ''; self::check( 'public_policy', __( 'I have checked that selected images are public, not private or member-only media.', 'affstackdev-image-manager' ), (bool) $s['public_policy'] ); self::check( 'delivery', __( 'Offer optimized images on supported website output', 'affstackdev-image-manager' ), (bool) $s['delivery'], __( 'Turn this off to restore original markup on newly rendered pages. Existing page caches may need clearing.', 'affstackdev-image-manager' ) ); self::check( 'lazy_loading', __( 'Lazy-load pictures that WordPress does not reach', 'affstackdev-image-manager' ), (bool) $s['lazy_loading'], __( 'WordPress already lazy-loads the pictures it builds. This adds loading="lazy" to the other pictures your theme or plugins print, after the first pictures of the page. Pictures without a width and height, in the page header, marked fetchpriority="high" or with the class skip-lazy stay as they are.', 'affstackdev-image-manager' ) ); self::check( 'fit', __( 'Fit pictures to the page', 'affstackdev-image-manager' ), (bool) $s['fit'], __( 'On the pages measured under Optimize, Sizes for the page, a picture whose theme left WordPress’s standard sizes tells the browser the width it is really shown at, so the browser downloads a file close to that width. A theme’s own sizes stays. Clear page caches after changing this.', 'affstackdev-image-manager' ) ); self::check( 'dimensions', __( 'Add the missing width and height on measured pages', 'affstackdev-image-manager' ), ! empty( $s['dimensions'] ), __( 'On the pages measured under Optimize, Sizes for the page, a picture without a width and height gets its file’s own size, so the browser keeps its place while it loads and the page does not jump. Only pictures the measurement showed do not change at phone, tablet and computer widths; a picture your theme sizes in one direction only stays as it is. Measure the pages again after changing the theme. Clear page caches after changing this.', 'affstackdev-image-manager' ) ); self::check( 'auto_text', __( 'Fill empty alt text and titles from file names', 'affstackdev-image-manager' ), ! empty( $s['auto_text'] ), __( 'For new uploads: "red-summer_dress-2.jpg" gets the alt text "Red summer dress 2", and the same title when WordPress copied the bare file name. Text someone wrote is never replaced; names that say nothing, such as IMG_1234 or a screenshot, give no text. Tools fills the images already in the library.', 'affstackdev-image-manager' ) ); echo '
' . esc_html__( 'Large pictures', 'affstackdev-image-manager' ) . '

' /* translators: 1: memory available for image work, such as 180 MB; 2: Imagick or GD; 3: number of megapixels, such as 2.1. */ . esc_html( sprintf( __( 'This server gives image work about %1$s now. With %2$s that is enough for pictures up to about %3$s megapixels.', 'affstackdev-image-manager' ), size_format( $now['memory'] ), $now['backend'], number_format_i18n( $now['pixels'] / 1000000, 1 ) ) ) . '

' . esc_html__( 'Before each picture, Image Manager checks that the memory is there; a picture that does not fit stays as it is, with that reason in the results. Larger pictures take longer. To try pictures skipped earlier for their size, use Retry using each image’s saved policy in the Bulk editor.', 'affstackdev-image-manager' ) . '

'; echo ''; // 1.9.0: which registered sizes WordPress makes for new uploads. echo '
' . esc_html__( 'Sizes WordPress makes for new uploads', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Your theme and plugins register these sizes, and WordPress makes each of them for every new picture. A size you untick is no longer made for new uploads; pictures already in the library keep theirs. Keep the sizes your theme or WooCommerce shows on pages: without them WordPress shows a larger file.', 'affstackdev-image-manager' ) . '

'; foreach ( Image_Sizes::registered() as $name => $d ) { /* translators: 1: size name; 2: width; 3: height (0 means any); 4: cropped or not. */ $label = sprintf( __( '%1$s (%2$d × %3$d%4$s)', 'affstackdev-image-manager' ), $name, (int) $d['width'], (int) $d['height'], $d['crop'] ? __( ', cropped', 'affstackdev-image-manager' ) : '' ); echo ''; } echo '
' . esc_html__( 'Folders for pages and posts', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Adds a folder bar, a Folder column and Move to folder to these admin lists. Folders only sort the list: nothing changes on your website and no address changes. Editors and Administrators manage the folders; anyone who may edit an item may move it.', 'affstackdev-image-manager' ) . '

'; $types = Content_Folders::available(); $on = is_array( $s['content_folders'] ) ? $s['content_folders'] : array(); echo ''; foreach ( $types as $type => $label ) { echo ''; } echo '
' . esc_html__( 'Automatic processing', 'affstackdev-image-manager' ) . ''; self::check( 'uploads', __( 'Automatically optimize eligible future uploads', 'affstackdev-image-manager' ), (bool) $s['uploads'], __( 'Images are queued after a supported public post, product, or approved comment association is available.', 'affstackdev-image-manager' ) ); self::check( 'auto_discovery', __( 'Automatically recheck changed public pages and theme updates', 'affstackdev-image-manager' ), (bool) $s['auto_discovery'], __( 'Picks up new images inserted by supported page saves and active-theme updates, including Site Setup. Uses Home/Shop and already discovered linked pages. New eligible images use your compression defaults. Background processing is required when Optimize is closed.', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'Enabling page rechecks also checks those known pages once. Rechecks are delayed and combined to keep saving fast. Library browsing stays passive.', 'affstackdev-image-manager' ) . '

'; echo '
' . esc_html__( 'Image sources', 'affstackdev-image-manager' ) . ''; self::check( 'theme_images', __( 'Include images supplied by the active theme', 'affstackdev-image-manager' ), (bool) $s['theme_images'], __( 'Only images found on scanned public pages. Theme files are not changed.', 'affstackdev-image-manager' ) ); // 1.9.0 (D-500 gap 11): other folders inside wp-content, treated like the active theme's pictures. echo ''; self::check( 'catalog', __( 'Include public product images', 'affstackdev-image-manager' ), (bool) $s['catalog'] ); self::check( 'comments', __( 'Include local images in approved comments and reviews', 'affstackdev-image-manager' ), (bool) $s['comments'] ); echo '

' . esc_html__( 'Source choices apply to the next scan. They do not delete prepared copies.', 'affstackdev-image-manager' ) . '

'; $this->watermark_fieldset( $s ); $this->svg_fieldset( $s ); echo '
' . esc_html__( 'Workspace', 'affstackdev-image-manager' ) . ''; self::check( 'enhanced_ui', __( 'Enable enhanced interface and live progress', 'affstackdev-image-manager' ), (bool) $s['enhanced_ui'], __( 'The Suite’s global PHP mode takes precedence. Normal forms and scheduled processing remain available.', 'affstackdev-image-manager' ) ); echo '
' . esc_html__( 'Advanced delivery', 'affstackdev-image-manager' ) . ''; self::check( 'local_cdn', __( 'Allow local copies for exactly matched Jetpack image sizes', 'affstackdev-image-manager' ), (bool) $s['local_cdn'], __( 'Other Jetpack sizes remain unchanged. Compare delivery on your website before enabling this option.', 'affstackdev-image-manager' ) ); echo '
'; $this->cdn_card( $s ); } /** Settings tab: the store owner's own CDN (1.8.0). Its own form: switching on always checks the CDN first. */ /** * 1.9.0 (D-500 gap 6): Settings > Watermark. * * @param array $s The settings. */ private function watermark_fieldset( array $s ): void { echo '
' . esc_html__( 'Watermark', 'affstackdev-image-manager' ) . ''; self::check( 'watermark', __( 'Show a watermark on pictures on public pages', 'affstackdev-image-manager' ), ! empty( $s['watermark'] ), __( 'Pictures get watermarked copies the next time Optimize runs for them; public pages then show those copies, the page’s own picture included. Your original files are never changed, and pictures narrower than chosen below keep their ordinary copies.', 'affstackdev-image-manager' ) ); $pictures = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => array( 'image/png', 'image/webp' ), 'posts_per_page' => 200, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ) ); echo ''; $places = array( 'top-left' => __( 'Top left', 'affstackdev-image-manager' ), 'top' => __( 'Top centre', 'affstackdev-image-manager' ), 'top-right' => __( 'Top right', 'affstackdev-image-manager' ), 'left' => __( 'Middle left', 'affstackdev-image-manager' ), 'center' => __( 'Centre', 'affstackdev-image-manager' ), 'right' => __( 'Middle right', 'affstackdev-image-manager' ), 'bottom-left' => __( 'Bottom left', 'affstackdev-image-manager' ), 'bottom' => __( 'Bottom centre', 'affstackdev-image-manager' ), 'bottom-right' => __( 'Bottom right', 'affstackdev-image-manager' ), ); echo ''; echo ''; echo ''; echo ''; echo '
'; } /** * 1.9.0 (D-500 gap 14): Settings > SVG files. * * @param array $s The settings. */ private function svg_fieldset( array $s ): void { echo '
' . esc_html__( 'SVG files', 'affstackdev-image-manager' ) . ''; if ( ! SVG::available() ) { echo '

' . esc_html__( 'This server’s PHP lacks the DOM or XMLReader extension that cleaning SVG files needs, so SVG uploads cannot be switched on here.', 'affstackdev-image-manager' ) . '

'; } self::check( 'svg_uploads', __( 'Allow SVG uploads, cleaned before they are saved', 'affstackdev-image-manager' ), ! empty( $s['svg_uploads'] ), __( 'WordPress refuses SVG files because an SVG can carry scripts. With this on, the roles ticked below may upload SVG files in the Media Library, in the block editor and through the REST API. Each file is cleaned first: only the SVG tags and attributes on the cleaner’s safe list stay, so scripts, event attributes, script links, embedded web pages and references to other sites are removed, and a file that cannot be read safely is not uploaded. Its width and height are saved, so WordPress shows it like other pictures. Turning this off stops new SVG uploads; SVG files already in the library stay.', 'affstackdev-image-manager' ) ); $chosen = SVG::clean_roles( $s['svg_roles'] ?? array() ); echo '

' . esc_html__( 'Roles that may upload SVG files', 'affstackdev-image-manager' ) . '

'; foreach ( SVG::roles() as $slug => $name ) { echo ''; } echo '
'; } /** * Print the CDN card: the CDN address and switch, what the last check found (the daily check can switch it off), and * Check and save. * * @param array $s The settings. */ private function cdn_card( array $s ): void { $state = is_array( $s['cdn_state'] ) ? $s['cdn_state'] : array(); echo '

' . esc_html__( 'Deliver pictures through your CDN', 'affstackdev-image-manager' ) . '

' . esc_html__( 'If your CDN has an address of its own (such as a Bunny CDN pull zone, KeyCDN or Amazon CloudFront, set to fetch from this site), Image Manager can point your pictures to it: originals, WordPress sizes and Image Manager’s lighter copies in the uploads folder, on the public pages Image Manager delivers on. A CDN that already sits in front of your whole site at its own address (such as Cloudflare) needs nothing here. There is no AffStackDev service involved.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'cdn_save' ); echo ''; self::check( 'cdn', __( 'Deliver pictures through this CDN', 'affstackdev-image-manager' ), (bool) $s['cdn'], __( 'Saving with this on checks the CDN first: Image Manager writes a new small test picture, asks for it through the CDN and switches on only when the CDN returns it unchanged. The check runs again every day; if it fails, pictures come from your site again at once. Clear page caches after switching on or off. Theme files, scripts, styles, downloads and the cart, checkout and account pages keep your site’s address.', 'affstackdev-image-manager' ) ); if ( ! empty( $state['checked'] ) ) { echo '

' /* translators: 1: date and time of the last CDN check; 2: its result. */ . esc_html( sprintf( __( 'Last check %1$s: %2$s', 'affstackdev-image-manager' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $state['checked'] ), CDN::human( (string) ( $state['reason'] ?? '' ), (int) ( $state['status'] ?? 0 ) ) ) ) . ( ! empty( $state['stopped'] ) ? ' ' . esc_html__( 'The daily check switched the CDN off.', 'affstackdev-image-manager' ) : '' ) . '

'; } if ( CDN::jetpack() ) { echo '

' . esc_html( CDN::human( 'cdn_jetpack' ) ) . '

'; } echo '
'; } /** * Print the Tools tab: background processing and its check, alt text from file names, release health, compression * capabilities, and the integration contract and diagnostics downloads. */ private function tools(): void { $p = Settings::preflight(); $v = Workflow::snapshot(); echo '

' . esc_html__( 'Tools and system status', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Check background processing and review saved activity without changing your original images.', 'affstackdev-image-manager' ) . '

' . wp_kses( $this->background( $v ), Admin_Layout::allowed_html() ) . '
'; $media_timing = get_option( 'afsd_im_media_timing', array() ); if ( is_array( $media_timing ) && ! empty( $media_timing['recorded_at'] ) ) { echo '
' . esc_html__( 'Last Media Manager server timing', 'affstackdev-image-manager' ) . '

' . esc_html( sprintf( /* translators: 1: total milliseconds; 2: attachment query milliseconds; 3: folder and collection lookup milliseconds; 4: card render milliseconds; 5: number of database queries; 6: media items shown; 7: media items found. */ __( 'Total %1$d ms · attachment query %2$d ms · folder/collection lookup %3$d ms · card render %4$d ms · %5$d database queries · %6$d visible of %7$d media items', 'affstackdev-image-manager' ), (int) ( $media_timing['total_ms'] ?? 0 ), (int) ( $media_timing['query_ms'] ?? 0 ), (int) ( $media_timing['organization_ms'] ?? 0 ), (int) ( $media_timing['cards_ms'] ?? 0 ), (int) ( $media_timing['query_count'] ?? 0 ), (int) ( $media_timing['items'] ?? 0 ), (int) ( $media_timing['found'] ?? 0 ) ) ) . '

' . esc_html__( 'Measured inside the last sampled Media library PHP request. It excludes browser rendering, network latency, and unrelated WordPress admin assets.', 'affstackdev-image-manager' ) . '
'; } $this->button_form( 'check_background', __( 'Run background check', 'affstackdev-image-manager' ), true ); echo '

' . esc_html__( 'Alt text from file names', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Fill the empty alt text of images already in the library from their file names, 300 at a time. Titles and text someone wrote stay as they are.', 'affstackdev-image-manager' ) . '

'; $this->button_form( 'auto_text_fill', __( 'Fill empty alt text now', 'affstackdev-image-manager' ) ); echo '
'; $this->unknown_files_panel(); $this->sizes_panel(); $health = Release_Health::report(); echo '

' . esc_html__( 'Release health', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Read-only environment checks. Warnings narrow what is available; they do not delete or rewrite media.', 'affstackdev-image-manager' ) . '

' /* translators: %s: number of release health warnings. */ . esc_html( $health['warnings'] ? sprintf( _n( '%s warning(s)', '%s warning(s)', (int) $health['warnings'], 'affstackdev-image-manager' ), number_format_i18n( (int) $health['warnings'] ) ) : __( 'Core checks passed', 'affstackdev-image-manager' ) ) . '
'; $statuses = array( 'pass' => __( 'Pass', 'affstackdev-image-manager' ), 'warn' => __( 'Warn', 'affstackdev-image-manager' ), 'info' => __( 'Info', 'affstackdev-image-manager' ), ); foreach ( $health['checks'] as $check ) { echo '
' . esc_html( (string) $check['label'] ) . '
' . esc_html( $statuses[ (string) $check['status'] ] ?? ucfirst( (string) $check['status'] ) ) . ' · ' . esc_html( (string) $check['detail'] ) . '
'; } echo '
'; $this->begin( 'release_report', true ); echo '
'; echo '

' . esc_html__( 'Compression capabilities', 'affstackdev-image-manager' ) . '

' . esc_html__( 'A passed check verifies a small encode/decode on this host, not every possible input. Unsupported formats remain unchanged.', 'affstackdev-image-manager' ) . '

'; // Self-test results are cached in English; the known ones are shown translated, codes as they are. $selftests = array( 'encode/decode passed' => __( 'encode/decode passed', 'affstackdev-image-manager' ), 'animated encode/decode passed' => __( 'animated encode/decode passed', 'affstackdev-image-manager' ), 'pdf copy passed' => __( 'PDF copy passed', 'affstackdev-image-manager' ), 'pdf page read passed' => __( 'PDF page read passed', 'affstackdev-image-manager' ), 'output rejected' => __( 'output rejected', 'affstackdev-image-manager' ), 'not installed or safe execution unavailable' => __( 'not installed or safe execution unavailable', 'affstackdev-image-manager' ), 'coefficient transform and exact decoded check passed' => __( 'coefficient transform and exact decoded check passed', 'affstackdev-image-manager' ), 'storage unavailable' => __( 'storage unavailable', 'affstackdev-image-manager' ), ); foreach ( Compression::capabilities() as $key => $cap ) { echo '
' . esc_html( $key ) . '
' . esc_html( ! empty( $cap['available'] ) ? __( 'Available', 'affstackdev-image-manager' ) : __( 'Unavailable', 'affstackdev-image-manager' ) ) . ' · ' . esc_html( isset( $cap['selftest'] ) ? ( $selftests[ (string) $cap['selftest'] ] ?? $cap['selftest'] ) : __( 'Not checked', 'affstackdev-image-manager' ) ) . '
'; } echo '
'; $this->button_form( 'check_codecs', __( 'Recheck encoders', 'affstackdev-image-manager' ) ); echo '

' . esc_html__( 'The check schedules a separate harmless task. Clicking Process here now or refreshing this screen cannot make the check pass.', 'affstackdev-image-manager' ) . '

'; echo '
' . esc_html__( 'Data ownership and integration contract', 'affstackdev-image-manager' ) . '

' . esc_html__( 'WordPress owns your attachments, original files and native metadata. Image Manager owns its processing records and generated copies. This contract describes those boundaries; it does not export your media or create a backup.', 'affstackdev-image-manager' ) . '

'; $this->begin( 'integration_contract', true ); echo '
'; $this->begin( 'diagnostics', true ); echo '
'; echo '
' . esc_html__( 'Recent activity', 'affstackdev-image-manager' ) . '
    '; foreach ( Activity::recent() as $event ) { $label = Activity::labels()[ $event['code'] ?? '' ] ?? ''; if ( ! $label ) { continue; } /* translators: %s: the image's record number in Image Manager. */ echo '
  1. ' . esc_html( $label ) . ( ! empty( $event['source'] ) ? esc_html( ' · ' . sprintf( __( 'Image #%s', 'affstackdev-image-manager' ), (string) $event['source'] ) ) : '' ) . '
  2. '; } echo '

' . esc_html__( 'Up to 40 recent events. No filenames, site URLs or customer details are recorded in this history.', 'affstackdev-image-manager' ) . '

'; echo '
' . esc_html__( 'Server details and last batch', 'affstackdev-image-manager' ) . '
'; $p['last_batch'] = get_option( 'afsd_im_last_batch', array() ); foreach ( $p as $key => $value ) { echo '
' . esc_html( ucwords( str_replace( '_', ' ', $key ) ) ) . '
' . esc_html( is_array( $value ) ? wp_json_encode( $value ) : ( is_bool( $value ) ? ( $value ? __( 'Yes', 'affstackdev-image-manager' ) : __( 'No', 'affstackdev-image-manager' ) ) : ( null === $value ? __( 'Unknown', 'affstackdev-image-manager' ) : (string) $value ) ) ) . '
'; } echo '
' . esc_html__( 'Host scheduling and WP-CLI', 'affstackdev-image-manager' ) . '

' . esc_html__( 'A passed check proves that one scheduled task executed. It does not certify that your host will keep running tasks while the site has no visitors. An external host schedule provides dependable unattended processing.', 'affstackdev-image-manager' ) . '

' . sprintf( /* translators: 1: a WP-CLI command that runs WordPress's due events; 2: a WP-CLI command that runs one batch of Image Manager's Action Scheduler group. */ esc_html__( 'A host administrator can run WordPress due events from the site directory using %1$s. Where Action Scheduler is active, %2$s can also advance this product’s group. Keep the host’s existing schedules for other products intact.', 'affstackdev-image-manager' ), 'wp cron event run --due-now', 'wp action-scheduler run --group=affstackdev-image-manager --batches=1' ) . '

' . sprintf( /* translators: 1: the WP-CLI status command; 2: the WP-CLI background test command; 3: the WP-CLI work command. */ esc_html__( 'To inspect this plugin: %1$s. To schedule a check: %2$s. To manually advance authorized work: %3$s. Manual work honors Pause and does not certify background scheduling.', 'affstackdev-image-manager' ), 'wp affstackdev-image-manager status', 'wp affstackdev-image-manager test-background', 'wp affstackdev-image-manager work --batches=1' ) . '

' . esc_html__( 'Worker recovery waits for its exclusive lease to expire, which can take up to ten minutes after a hard interruption. A native image encode finishes as one operation; Pause applies before the next operation and prevents stale publication.', 'affstackdev-image-manager' ) . '

'; echo '
' . esc_html__( 'Scanned pages', 'affstackdev-image-manager' ) . '
'; $page_states = array( 'waiting' => __( 'Waiting', 'affstackdev-image-manager' ), 'blocked' => __( 'Blocked', 'affstackdev-image-manager' ), 'scanned' => __( 'Scanned', 'affstackdev-image-manager' ), ); foreach ( Scanner::pages( 100 ) as $page ) { echo '

' . esc_html( wp_parse_url( $page['url'], PHP_URL_PATH ) ?: '/' ) . '' . esc_html( $page_states[ $page['state'] ] ?? ucfirst( $page['state'] ) ) . ( ! empty( $page['reason'] ) && 'scanned' !== $page['state'] ? ' · ' . esc_html( Results::reason( $page['reason'] ) ) : '' ) . '

'; } echo '

' . esc_html__( 'Up to 100 page records. Scanning stays within Home, Shop and their direct links.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Supported delivery and safe rollback', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Prepared files and enabled delivery do not prove which files a visitor’s browser downloaded. Browser delivery and network savings are not measured here.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Native single-product galleries, zoom and variation output, existing picture elements, scripted lazy-load markup, CSS background images, external/offloaded media and unmatched CDN sizes remain unchanged.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Turn optimized delivery off, then clear normal page caches. Deactivation and uninstall retain managed copies so cached pages do not lose files. Delete copies only after old cached references have retired. Originals are never deleted by cleanup.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Private-media and membership providers need their own compatibility review. Unsupported protected media stays excluded.', 'affstackdev-image-manager' ) . '

'; } /** * Server-authoritative action handler shared by PHP and enhanced controls. * * @param string $op The operation. * @param array $data The form's fields, with the nonce. * @return string The notice to show. * @throws \RuntimeException With image_access_denied for a missing or wrong nonce, or a reason code (or message) when * the operation cannot be done. */ public function perform( string $op, array $data ): string { self::authorize(); if ( ! isset( $data['_wpnonce'] ) || ! is_string( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'afsd_im_action' ) ) { throw new \RuntimeException( 'image_access_denied' ); } switch ( $op ) { case 'view': case 'status': return ''; case 'start': $optimize = empty( $data['scan_only'] ); if ( $optimize && isset( $data['compression'] ) ) { Batch::start( $data, true ); } else { Workflow::start( $optimize, ! empty( $data['public_policy'] ), ! empty( $data['delivery'] ) ); } // The first sentence stays its own string: screen() recognises a run notice by it. return $optimize ? __( 'Optimization started.', 'affstackdev-image-manager' ) . ' ' . __( 'Progress is saved automatically.', 'affstackdev-image-manager' ) : __( 'Scan started.', 'affstackdev-image-manager' ) . ' ' . __( 'Image delivery settings were not changed.', 'affstackdev-image-manager' ); case 'step': case 'tick': Workflow::run_batch(); return __( 'Progress updated.', 'affstackdev-image-manager' ); case 'pause': Workflow::pause(); return __( 'Optimization paused.', 'affstackdev-image-manager' ); case 'cancel': Workflow::cancel(); return __( 'Run stopped. Originals and prepared copies are retained.', 'affstackdev-image-manager' ); case 'photo_text': $done = Media_Details::copy_photo_text( $this->media_id ); if ( ! $done ) { return __( 'Nothing was copied: the caption and title already hold text.', 'affstackdev-image-manager' ); } return in_array( 'caption', $done, true ) && in_array( 'title', $done, true ) ? __( 'The photo’s own caption and title are now the media item’s.', 'affstackdev-image-manager' ) : ( in_array( 'caption', $done, true ) ? __( 'The photo’s own caption is now the media item’s.', 'affstackdev-image-manager' ) : __( 'The photo’s own title is now the media item’s.', 'affstackdev-image-manager' ) ); case 'auto_text_fill': $r = Auto_Text::fill_missing(); if ( $r['busy'] ) { return __( 'Image Manager is finishing another change. Nothing was filled; try again in a minute.', 'affstackdev-image-manager' ); } /* translators: 1: images checked, 2: alt texts filled, 3: images whose file name gives no text, 4: images the current user may not edit. */ $done = sprintf( __( 'Checked %1$d images without alt text: %2$d filled from their file names, %3$d left empty because the file name says nothing, %4$d you may not edit.', 'affstackdev-image-manager' ), $r['checked'], $r['filled'], $r['no_usable_name'], $r['skipped'] ); return $r['more'] ? $done . ' ' . __( 'More remain: choose Fill empty alt text now again.', 'affstackdev-image-manager' ) : $done; case 'check_background': Scheduler::check( true ); return __( 'Background check scheduled. Waiting for independent execution.', 'affstackdev-image-manager' ); case 'resume': Workflow::resume(); return __( 'Optimization resumed.', 'affstackdev-image-manager' ); case 'settings': $before = Settings::get(); $values = array(); foreach ( array( 'public_policy', 'delivery', 'uploads', 'auto_discovery', 'local_cdn', 'theme_images', 'catalog', 'comments', 'enhanced_ui', 'lazy_loading', 'fit', 'dimensions', 'auto_text', 'watermark', 'svg_uploads' ) as $key ) { $values[ $key ] = ! empty( $data[ $key ] ); } // 1.9.0 (D-500 gap 14): the roles that may upload SVG files; at least one while SVG uploads are on. if ( isset( $data['svg_roles'] ) ) { $asked = is_array( $data['svg_roles'] ) ? array_values( array_filter( array_map( 'strval', array_filter( $data['svg_roles'], 'is_scalar' ) ) ) ) : array( '' ); $values['svg_roles'] = SVG::clean_roles( $asked ); if ( count( $values['svg_roles'] ) !== count( array_unique( $asked ) ) ) { throw new \RuntimeException( 'settings_invalid_choice' ); } } if ( $values['svg_uploads'] && ! SVG::available() ) { throw new \RuntimeException( 'settings_svg_unavailable' ); } if ( $values['svg_uploads'] && ! SVG::clean_roles( $values['svg_roles'] ?? $before['svg_roles'] ) ) { throw new \RuntimeException( 'settings_svg_roles' ); } // 1.9.0 (D-500 gap 6): the watermark's picture, place, size, opacity and narrowest picture. foreach ( array( 'watermark_size' => array( 5, 50 ), 'watermark_opacity' => array( 10, 100 ), 'watermark_min' => array( 100, 4000 ), ) as $key => $range ) { if ( isset( $data[ $key ] ) ) { if ( ! is_scalar( $data[ $key ] ) || ! preg_match( '/^[0-9]{1,4}$/D', (string) $data[ $key ] ) || (int) $data[ $key ] < $range[0] || (int) $data[ $key ] > $range[1] ) { throw new \RuntimeException( 'settings_invalid_choice' ); } $values[ $key ] = (int) $data[ $key ]; } } if ( isset( $data['watermark_place'] ) ) { if ( ! is_string( $data['watermark_place'] ) || ! in_array( $data['watermark_place'], Watermark::PLACES, true ) ) { throw new \RuntimeException( 'settings_invalid_choice' ); } $values['watermark_place'] = $data['watermark_place']; } if ( isset( $data['watermark_image'] ) ) { $values['watermark_image'] = is_scalar( $data['watermark_image'] ) ? absint( $data['watermark_image'] ) : -1; if ( $values['watermark_image'] < 0 || ( $values['watermark_image'] && ! Watermark::picture( $values['watermark_image'] ) ) ) { throw new \RuntimeException( 'settings_invalid_watermark' ); } } if ( $values['watermark'] && ! Watermark::picture( (int) ( $values['watermark_image'] ?? $before['watermark_image'] ) ) ) { throw new \RuntimeException( 'settings_invalid_watermark' ); } if ( isset( $data['content_folders'] ) ) { $values['content_folders'] = Content_Folders::clean_types( $data['content_folders'] ); } if ( isset( $data['picture_folders'] ) && is_string( $data['picture_folders'] ) ) { $asked = array_values( array_filter( array_map( 'trim', explode( "\n", str_replace( "\r", '', $data['picture_folders'] ) ) ) ) ); $kept = Theme::folders( $asked ); if ( count( $kept ) !== count( array_unique( array_map( static fn( $f ) => trim( $f, '/' ), $asked ) ) ) ) { throw new \RuntimeException( 'settings_invalid_folder' ); } $values['picture_folders'] = $kept; } if ( isset( $data['make_sizes'] ) && is_array( $data['make_sizes'] ) ) { $make = array_filter( array_map( 'strval', $data['make_sizes'] ) ); $values['skip_sizes'] = array_values( array_diff( array_map( 'strval', array_keys( Image_Sizes::registered() ) ), $make ) ); } if ( isset( $data['upload_limit'] ) ) { if ( ! is_string( $data['upload_limit'] ) || ! in_array( $data['upload_limit'], Upload_Limit::CHOICES, true ) ) { throw new \RuntimeException( 'settings_invalid_choice' ); } $values['upload_limit'] = $data['upload_limit']; } if ( isset( $data['large_pictures'] ) ) { if ( ! is_string( $data['large_pictures'] ) || ! isset( Settings::LARGE[ $data['large_pictures'] ] ) ) { throw new \RuntimeException( 'settings_invalid_choice' ); } $values['large_pictures'] = $data['large_pictures']; } if ( ( $values['delivery'] || $values['uploads'] || $values['auto_discovery'] || $values['fit'] || $values['dimensions'] || $values['watermark'] ) && ! $values['public_policy'] ) { throw new \RuntimeException( 'Please confirm that these are public website images.' ); } if ( isset( $data['compression'] ) ) { if ( ! is_array( $data['compression'] ) ) { throw new \RuntimeException( 'invalid_compression_policy' ); } $values['compression'] = Policy::clean( $data['compression'] ); if ( 'lossless' !== $values['compression']['mode'] && Policy::key( $values['compression'] ) !== Policy::key( Policy::current() ) && empty( $data['compression_confirm'] ) ) { throw new \RuntimeException( 'compression_confirmation_required' ); } } $values['generation'] = Settings::get()['generation'] + 1; Settings::patch( $values ); Auto_Discovery::configured( $before ); Engine::ensure(); return __( 'Settings saved. Original image files are unchanged.', 'affstackdev-image-manager' ); case 'check_codecs': Compression::capabilities( true ); return __( 'Encoder checks complete. Results show only tested capabilities on this host.', 'affstackdev-image-manager' ); case 'preview_prepare': if ( ! isset( $data['compression'] ) || ! is_array( $data['compression'] ) ) { throw new \RuntimeException( 'invalid_compression_policy' ); } Preview::start( isset( $data['im_image'] ) && is_scalar( $data['im_image'] ) ? absint( $data['im_image'] ) : 0, $data['compression'], ! empty( $data['compression_confirm'] ) ); return __( 'Comparison queued. Existing image delivery is unchanged.', 'affstackdev-image-manager' ); case 'preview_apply': Preview::apply( isset( $data['im_image'] ) && is_scalar( $data['im_image'] ) ? absint( $data['im_image'] ) : 0, is_string( $data['draft'] ?? null ) ? $data['draft'] : '', ! empty( $data['preview_confirm'] ) ); return __( 'Copies approved. Original files and delivery switches are unchanged.', 'affstackdev-image-manager' ); case 'preview_discard': Preview::discard( isset( $data['im_image'] ) && is_scalar( $data['im_image'] ) ? absint( $data['im_image'] ) : 0, is_string( $data['draft'] ?? null ) ? $data['draft'] : '' ); return __( 'Comparison discarded. Original files and existing copies retained.', 'affstackdev-image-manager' ); case 'bulk_run': $n = Batch::start( $data ); /* translators: %d: number of images the bulk action selected. */ return sprintf( _n( '%d images selected. Bulk work is saved and will continue through the worker.', '%d images selected. Bulk work is saved and will continue through the worker.', $n, 'affstackdev-image-manager' ), $n ); case 'library_upload': // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Media_Actions::upload() validates array fields/count/receipt and delegates each upload to media_handle_upload(); do not text-sanitize PHP upload structures. $upload = Media_Actions::upload( is_array( $_FILES['media_files'] ?? null ) ? $_FILES['media_files'] : array(), is_scalar( $data['upload_folder'] ?? null ) ? absint( $data['upload_folder'] ) : 0, is_string( $data['upload_request'] ?? null ) ? $data['upload_request'] : '', is_array( $data['media_paths'] ?? null ) ? $data['media_paths'] : array() ); $this->upload_result = array( 'ready' => count( $upload['ids'] ), 'errors' => array_values( $upload['errors'] ), ); if ( $upload['ids'] ) { $this->media_folder = absint( $data['upload_folder'] ?? 0 ); $this->media_collection = 0; $this->media_type = 'all'; $this->search = ''; $this->page = 1; $this->media_sort = 'newest'; $this->media_view = 'library'; } /* translators: %d: number of uploaded media items now in the library. */ return sprintf( _n( '%d media item(s) ready in the library.', '%d media item(s) ready in the library.', count( $upload['ids'] ), 'affstackdev-image-manager' ), count( $upload['ids'] ) ) . ' ' . implode( ' ', $upload['errors'] ); case 'pdf_prepare': PDF::prepare( $this->media_id, is_scalar( $data['pdf_page'] ?? null ) ? (int) $data['pdf_page'] : 0, is_scalar( $data['pdf_edge'] ?? null ) ? (int) $data['pdf_edge'] : 0, ! empty( $data['media_operation_confirm'] ) ); return __( 'PDF page preview prepared. Review it before saving.', 'affstackdev-image-manager' ); case 'pdf_save': $new = PDF::save( $this->media_id, is_string( $data['media_draft'] ?? null ) ? $data['media_draft'] : '', ! empty( $data['media_operation_confirm'] ) ); /* translators: %d: attachment ID of the new image. */ return sprintf( __( 'PDF page saved as image #%d. The PDF is unchanged.', 'affstackdev-image-manager' ), $new ); case 'pdf_copy_prepare': PDF_Copy::prepare( $this->media_id, is_string( $data['pdf_preset'] ?? null ) ? $data['pdf_preset'] : '', ! empty( $data['media_operation_confirm'] ) ); return __( 'Smaller copy prepared. Look at it before saving.', 'affstackdev-image-manager' ); case 'pdf_copy_save': $new = PDF_Copy::save( $this->media_id, is_string( $data['media_draft'] ?? null ) ? $data['media_draft'] : '', ! empty( $data['media_operation_confirm'] ) ); /* translators: %d: attachment ID of the new PDF. */ return sprintf( __( 'Smaller copy saved as media #%d. The PDF is unchanged.', 'affstackdev-image-manager' ), $new ); case 'replace_prepare': // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Media_Replacement::prepare() validates PHP upload provenance, size, MIME, dimensions and source ownership before storing a private draft. Media_Replacement::prepare( $this->media_id, is_array( $_FILES['replacement_file'] ?? null ) ? $_FILES['replacement_file'] : array(), ! empty( $data['media_operation_confirm'] ) ); return __( 'Replacement preview prepared. The current attachment is unchanged.', 'affstackdev-image-manager' ); case 'replace_apply': Media_Replacement::apply( $this->media_id, is_string( $data['media_draft'] ?? null ) ? $data['media_draft'] : '', ! empty( $data['media_operation_confirm'] ) ); return __( 'Replacement applied to the same attachment ID. Review its uses and download the recovery ZIP.', 'affstackdev-image-manager' ) . ( CDN::active() ? ' ' . __( 'The picture kept its address, so your CDN can show the old one until its cache runs out: purge this picture there.', 'affstackdev-image-manager' ) : '' ); case 'replace_undo': case 'replace_recover': Media_Replacement::undo( $this->media_id, is_string( $data['media_draft'] ?? null ) ? $data['media_draft'] : '', ! empty( $data['media_operation_confirm'] ), 'replace_recover' === $op ); return __( 'The attachment is back on its previous files and metadata.', 'affstackdev-image-manager' ); case 'media_draft_discard': Media_Draft::discard( $this->media_id, $this->media_view, is_string( $data['media_draft'] ?? null ) ? $data['media_draft'] : '' ); return __( 'Private preview discarded.', 'affstackdev-image-manager' ); case 'library_create': return $this->library_create( $data ); case 'library_term': return $this->library_term( $data ); case 'library_bulk': return $this->library_bulk( $data ); case 'library_gallery': return $this->library_gallery( $data ); case 'folder_import': $state = Folder_Import::step( is_string( $data['import_source'] ?? null ) ? sanitize_key( $data['import_source'] ) : '', ! empty( $data['import_start'] ), ! empty( $data['import_confirm'] ) ); return Folder_Import::message( $state ); case 'image_sizes': $state = Image_Sizes::step( ! empty( $data['sizes_start'] ) ); /* translators: 1: sizes made; 2: sizes made again. */ return 'done' === $state['status'] ? sprintf( __( 'Done: %1$d sizes made, %2$d made again. Nothing was overwritten or deleted.', 'affstackdev-image-manager' ), (int) $state['made'], (int) $state['remade'] ) : __( 'Progress saved. Choose Continue.', 'affstackdev-image-manager' ); case 'unknown_files': $state = Unknown_Files::step( ! empty( $data['unknown_start'] ) ); return 'done' === $state['status'] /* translators: %d: files the Media Library does not know. */ ? sprintf( _n( 'Check finished: %d file the Media Library does not know. Nothing was deleted or moved.', 'Check finished: %d files the Media Library does not know. Nothing was deleted or moved.', count( $state['listed'] ) + (int) $state['more'], 'affstackdev-image-manager' ), count( $state['listed'] ) + (int) $state['more'] ) : __( 'Progress saved. Choose Continue checking.', 'affstackdev-image-manager' ); case 'unused_check': $state = Unused_Media::step( ! empty( $data['unused_start'] ) ); return 'done' === $state['status'] /* translators: %d: files with no use found. */ ? sprintf( _n( 'Check finished: %d file with no use found.', 'Check finished: %d files with no use found.', count( $state['listed'] ) + (int) $state['more_listed'], 'affstackdev-image-manager' ), count( $state['listed'] ) + (int) $state['more_listed'] ) : ( 'limit' === $state['status'] ? __( 'Check stopped: this site refers to its files in more places than one check can keep track of.', 'affstackdev-image-manager' ) : __( 'Progress saved. Choose Continue checking.', 'affstackdev-image-manager' ) ); case 'unused_collect': $state = Unused_Media::collect(); /* translators: 1: files put into the collection so far; 2: files listed. */ return sprintf( __( '%1$d of %2$d files are in the collection. Nothing was deleted or moved.', 'affstackdev-image-manager' ), min( (int) $state['collected'], count( $state['listed'] ) ), count( $state['listed'] ) ); case 'editor_prepare': if ( ! $this->media_id || ! isset( $data['editor'] ) || ! is_array( $data['editor'] ) ) { throw new \RuntimeException( 'editor_invalid_request' ); } Editor::prepare( $this->media_id, $data['editor'], ! empty( $data['editor_confirm'] ) ); return __( 'Edit prepared for review. The original attachment and website output are unchanged.', 'affstackdev-image-manager' ); case 'editor_save': $new = Editor::save_copy( $this->media_id, is_string( $data['editor_draft'] ?? null ) ? $data['editor_draft'] : '', ! empty( $data['editor_save_confirm'] ) ); /* translators: %d: attachment ID of the new edited copy. */ return sprintf( __( 'Edited copy saved as Media Library attachment #%d. The original attachment and existing uses are unchanged.', 'affstackdev-image-manager' ), $new ); case 'editor_discard': Editor::discard( $this->media_id, is_string( $data['editor_draft'] ?? null ) ? $data['editor_draft'] : '' ); return __( 'Edit draft discarded. The original attachment was never changed.', 'affstackdev-image-manager' ); case 'backup_start': if ( empty( $data['backup_confirm'] ) ) { throw new \RuntimeException( 'backup_confirmation_required' ); } $scope = isset( $data['backup_scope'] ) ? sanitize_key( (string) $data['backup_scope'] ) : 'all'; $scope_id = 'folder' === $scope ? absint( $data['backup_folder_id'] ?? 0 ) : ( 'collection' === $scope ? absint( $data['backup_collection_id'] ?? 0 ) : 0 ); $payload = $data; $payload['backup_scope'] = $scope; $payload['backup_scope_id'] = $scope_id; if ( isset( $data['backup_destination'] ) && 'remote' === sanitize_key( (string) $data['backup_destination'] ) ) { Remote_Backup::start_backup( $payload ); return __( 'Media backup started. After the local portable snapshot is verified it will be sent to your connected remote destination; the local copy stays available unless you changed that setting.', 'affstackdev-image-manager' ); } Backup::start( $payload ); return __( 'Local media backup started. When it completes you can download it directly to this device.', 'affstackdev-image-manager' ); case 'backup_tick': Backup::work( 'browser' ); return __( 'Backup progress updated.', 'affstackdev-image-manager' ); case 'backup_status': return ''; case 'backup_pause': Backup::pause(); return __( 'Media backup paused. Saved progress is retained.', 'affstackdev-image-manager' ); case 'backup_resume': Backup::resume(); return __( 'Media backup resumed.', 'affstackdev-image-manager' ); case 'backup_cancel': Backup::cancel(); return __( 'Media backup stopped. Completed snapshots and existing media are retained.', 'affstackdev-image-manager' ); case 'backup_delete': if ( empty( $data['backup_delete_confirm'] ) ) { throw new \RuntimeException( 'backup_delete_confirmation_required' ); } Backup::delete_snapshot( is_string( $data['snapshot_id'] ?? null ) ? $data['snapshot_id'] : '' ); return __( 'Local snapshot copy deleted. Media Library files were not deleted.', 'affstackdev-image-manager' ); case 'backup_restore': $restored = Backup::restore( is_string( $data['snapshot_id'] ?? null ) ? $data['snapshot_id'] : '', is_string( $data['portable_id'] ?? null ) ? $data['portable_id'] : '', is_string( $data['restore_mode'] ?? null ) ? sanitize_key( $data['restore_mode'] ) : 'new', is_string( $data['backup_password'] ?? null ) ? $data['backup_password'] : '', ! empty( $data['restore_confirm'] ) ); /* translators: %d: attachment ID of the restored media item. */ return sprintf( __( 'Media restored as attachment #%d. Review its uses before changing live content.', 'affstackdev-image-manager' ), $restored ); case 'remote_settings': $choice = is_string( $data['remote_scope_choice'] ?? null ) ? sanitize_text_field( $data['remote_scope_choice'] ) : 'all:0'; if ( ! preg_match( '/^(all|folder|collection):(\d+)$/D', $choice, $m ) ) { throw new \RuntimeException( 'backup_invalid_scope' ); } $payload = $data; $payload['remote_scope'] = $m[1]; $payload['remote_scope_id'] = (int) $m[2]; $c = Remote_Backup::save_settings( $payload ); /* translators: %s: the connected remote destination, such as Google Drive. */ return __( 'Backup destination settings saved.', 'affstackdev-image-manager' ) . ' ' . ( $c['ready'] ? sprintf( __( '%s is ready.', 'affstackdev-image-manager' ), $c['label'] ) : __( 'Local downloads remain available while the remote destination is not connected.', 'affstackdev-image-manager' ) ); case 'remote_google_disconnect': Remote_Backup::disconnect_google(); return __( 'Google Drive disconnected from Image Manager. Existing files in your Google Drive are not deleted.', 'affstackdev-image-manager' ); case 'remote_s3_test': Remote_Backup::test_s3(); return __( 'S3-compatible connection verified with a temporary test object that was then removed.', 'affstackdev-image-manager' ); case 'remote_upload_existing': Remote_Backup::queue_existing( is_string( $data['snapshot_id'] ?? null ) ? $data['snapshot_id'] : '' ); return __( 'Remote upload queued. The local snapshot remains available while it transfers.', 'affstackdev-image-manager' ); case 'remote_tick': Remote_Backup::work( 'browser' ); return __( 'Remote transfer progress updated.', 'affstackdev-image-manager' ); case 'remote_status': return ''; case 'remote_pause': Remote_Backup::pause(); return __( 'Remote transfer paused. Saved progress is retained.', 'affstackdev-image-manager' ); case 'remote_resume': Remote_Backup::resume(); return __( 'Remote transfer resumed.', 'affstackdev-image-manager' ); case 'remote_cancel': Remote_Backup::cancel(); return __( 'Remote transfer stopped. Local snapshots and completed remote backups are retained.', 'affstackdev-image-manager' ); case 'remote_fetch': $snap = Remote_Backup::fetch_local( is_string( $data['remote_id'] ?? null ) ? $data['remote_id'] : '', is_string( $data['backup_password'] ?? null ) ? $data['backup_password'] : '' ); $this->backup = (string) $snap['id']; return __( 'Remote backup downloaded, verified, and added to local recovery. Inspect it before restoring anything.', 'affstackdev-image-manager' ); case 'remote_delete': Remote_Backup::delete_remote( is_string( $data['remote_id'] ?? null ) ? $data['remote_id'] : '', ! empty( $data['remote_delete_confirm'] ) ); return __( 'Remote backup copy deleted. Local media and local snapshots were not deleted.', 'affstackdev-image-manager' ); case 'delivery_check': $r = Preview::row( is_scalar( $data['source_id'] ?? null ) ? absint( $data['source_id'] ) : 0 ); Verification::check_row( $r ); return Verification::label( Repository::row( (int) $r['id'] ) ); case 'browser_result': $observations = is_string( $data['observations'] ?? null ) && strlen( $data['observations'] ) <= 131072 ? json_decode( $data['observations'], true ) : null; if ( ! is_array( $observations ) || ! is_string( $data['signature'] ?? null ) || ! is_numeric( $data['width'] ?? null ) || ! is_numeric( $data['dpr'] ?? null ) ) { throw new \RuntimeException( 'delivery_invalid_result' ); } return Verification::browser_record( is_scalar( $data['im_image'] ?? null ) ? absint( $data['im_image'] ) : 0, $data['signature'], $observations, (int) $data['width'], (float) $data['dpr'] ); case 'fit_result': $viewports = is_string( $data['fit_viewports'] ?? null ) && strlen( $data['fit_viewports'] ) <= 8192 ? json_decode( $data['fit_viewports'], true ) : null; $images = is_string( $data['fit_images'] ?? null ) && strlen( $data['fit_images'] ) <= 2097152 ? json_decode( $data['fit_images'], true ) : null; if ( ! is_array( $viewports ) || ! is_array( $images ) || ! is_string( $data['fit_page'] ?? null ) || count( $viewports ) > 400 ) { throw new \RuntimeException( 'fit_invalid_result' ); } $viewports = array_values( $viewports ); $list = array(); foreach ( array_slice( $images, 0, 120 ) as $img ) { if ( ! is_array( $img ) || ! is_array( $img['widths'] ?? null ) || count( $img['widths'] ) !== count( $viewports ) ) { continue; } $samples = array(); foreach ( $viewports as $i => $viewport ) { $samples[] = array( $viewport, $img['widths'][ $i ] ); } $list[] = array( 'src' => is_string( $img['src'] ?? null ) ? $img['src'] : '', 'sizes' => is_string( $img['sizes'] ?? null ) ? $img['sizes'] : '', 'srcset' => ! empty( $img['srcset'] ), 'art' => ! empty( $img['art'] ), 'gallery' => ! empty( $img['gallery'] ), 'samples' => $samples, ); } $dims = is_string( $data['fit_dims'] ?? null ) && strlen( $data['fit_dims'] ) <= 262144 ? json_decode( $data['fit_dims'], true ) : array(); $c = Fit::save_page( $data['fit_page'], $list, is_array( $dims ) ? $dims : array() ); /* translators: 1: page address; 2: pictures fitted; 3: pictures the theme sizes itself; 4: pictures left as they are; 5: pictures without a width and height that can safely get their file's size. */ return sprintf( __( 'Measured %1$s: %2$d fitted, %3$d sized by the theme, %4$d left as they are; %5$d without a width and height can safely get their file’s size.', 'affstackdev-image-manager' ), wp_parse_url( $data['fit_page'], PHP_URL_PATH ) ?: '/', $c['fitted'], $c['theme'], $c['skipped'], $c['sized'] ); case 'fit_sizes': if ( empty( $data['resize_confirm'] ) ) { throw new \RuntimeException( 'batch_resize_confirmation' ); } $ids = Fit::measured_sources(); if ( ! $ids ) { throw new \RuntimeException( 'fit_nothing_measured' ); } $n = Batch::start( array( 'bulk' => 'optimize', 'scope' => 'selected', 'ids' => $ids, 'sizing' => 'fit', 'resize_confirm' => true, 'bulk_confirm' => true, 'compression' => Policy::current(), 'compression_confirm' => ! empty( $data['compression_confirm'] ), 'bulk_intent' => 'apply', ) ); /* translators: %d: number of measured pictures selected. */ return sprintf( _n( '%d measured picture selected. Its sizes are made in the background; see the progress above.', '%d measured pictures selected. Their sizes are made in the background; see the progress above.', $n, 'affstackdev-image-manager' ), $n ); case 'fit_forget': if ( empty( $data['fit_forget_confirm'] ) ) { throw new \RuntimeException( 'confirm_media' ); } $n = Fit::forget(); /* translators: %d: number of pages whose measurements were forgotten. */ return sprintf( _n( 'Measurements of %d page forgotten. Files already made stay.', 'Measurements of %d pages forgotten. Files already made stay.', $n, 'affstackdev-image-manager' ), $n ); case 'cdn_save': $on = ! empty( $data['cdn'] ); $state = CDN::save( is_string( $data['cdn_url'] ?? null ) ? $data['cdn_url'] : '', $on ); if ( ! $on ) { return __( 'CDN switched off. Pictures come from your site; clear page caches so cached pages follow.', 'affstackdev-image-manager' ); } return CDN::human( (string) $state['reason'], (int) ( $state['status'] ?? 0 ) ) . ( ! empty( $state['ok'] ) ? ' ' . __( 'Pictures now come through the CDN; clear page caches so cached pages follow.', 'affstackdev-image-manager' ) : '' ); case 'selected': return $this->selected( $data ); default: throw new \RuntimeException( 'Unknown operation.' ); } } /** * Apply an image action to up to 20 chosen images the user may change: optimize, retry, exclude, include, use the * original or the optimized copies, or delete the managed copies (only with optimized delivery off and the cleanup * confirmed). * * @param array $data The form's fields. * @return string The notice. * @throws \RuntimeException With the reason's words when the images or the action are not valid. */ private function selected( array $data ): string { $raw = isset( $data['ids'] ) && is_array( $data['ids'] ) ? $data['ids'] : array(); $ids = array_slice( array_unique( array_map( 'absint', array_filter( $raw, 'is_scalar' ) ) ), 0, 20 ); $op = isset( $data['bulk'] ) && is_string( $data['bulk'] ) ? sanitize_key( $data['bulk'] ) : ''; if ( ! $ids ) { throw new \RuntimeException( 'Choose at least one image.' ); } if ( ! in_array( $op, array( 'optimize', 'retry', 'exclude', 'include', 'original', 'optimized', 'cleanup' ), true ) ) { throw new \RuntimeException( 'Invalid image operation.' ); } if ( in_array( $op, array( 'retry', 'optimize' ), true ) && ! Settings::get()['public_policy'] ) { throw new \RuntimeException( 'Please confirm that these are public website images.' ); } if ( 'cleanup' === $op && ( Settings::get()['delivery'] || empty( $data['cleanup_confirm'] ) ) ) { throw new \RuntimeException( 'Turn optimized delivery off and confirm cache cleanup before deleting copies.' ); } $rows = array(); foreach ( $ids as $id ) { $r = Repository::row( $id ); if ( ! $r || ( ! $r['attachment_id'] && ! Theme::is_source( $r ) ) || ( $r['attachment_id'] && ! current_user_can( 'edit_post', (int) $r['attachment_id'] ) ) ) { throw new \RuntimeException( 'You do not have permission to change that image.' ); } $rows[] = $r; } Reviewed_Owner::source_change( $op, $ids ); /* translators: %d: number of images changed. */ return sprintf( _n( '%d image(s) updated. Original files were not changed.', '%d image(s) updated. Original files were not changed.', count( $rows ), 'affstackdev-image-manager' ), count( $rows ) ); } /** * The words for an error: by its code's prefix, from the part of Image Manager it came from, or from the known * messages; otherwise a general message. * * @param \Throwable $e The error. * @return string The translated words. */ private static function error_message( \Throwable $e ): string { if ( str_starts_with( $e->getMessage(), 'media_action_' ) ) { return Media_Actions::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'media_pdf_' ) || str_starts_with( $e->getMessage(), 'media_draft_' ) || str_starts_with( $e->getMessage(), 'media_replace_' ) ) { return Media_Workspace::human_error( $e->getMessage() ); } // These exception messages are fixed English identifiers; the notice shows their translation. $safe = array( 'Please confirm that these are public website images.' => __( 'Please confirm that these are public website images.', 'affstackdev-image-manager' ), 'Unknown operation.' => __( 'Unknown operation.', 'affstackdev-image-manager' ), 'Choose at least one image.' => __( 'Choose at least one image.', 'affstackdev-image-manager' ), 'Invalid image operation.' => __( 'Invalid image operation.', 'affstackdev-image-manager' ), 'Turn optimized delivery off and confirm cache cleanup before deleting copies.' => __( 'Turn optimized delivery off and confirm cache cleanup before deleting copies.', 'affstackdev-image-manager' ), 'You do not have permission to change that image.' => __( 'You do not have permission to change that image.', 'affstackdev-image-manager' ), 'An existing run is saved. Resume it or stop it before starting another.' => __( 'An existing run is saved. Resume it or stop it before starting another.', 'affstackdev-image-manager' ), ); if ( str_starts_with( $e->getMessage(), 'backup_' ) ) { return Backup::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'remote_' ) ) { return Remote_Backup::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'batch_' ) || str_starts_with( $e->getMessage(), 'delivery_' ) || str_starts_with( $e->getMessage(), 'preview_' ) || str_starts_with( $e->getMessage(), 'editor_' ) || in_array( $e->getMessage(), array( 'compression_confirmation_required', 'invalid_compression_policy', 'lossless_avif_not_supported', 'image_access_denied' ), true ) ) { return Results::reason( $e->getMessage() ); } if ( 'worker_busy' === $e->getMessage() ) { return __( 'Another processing request is finishing. Please try again in a moment.', 'affstackdev-image-manager' ); } if ( str_starts_with( $e->getMessage(), 'folder_import_' ) ) { return Folder_Import::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'unused_' ) ) { return Unused_Media::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'sizes_' ) ) { return Image_Sizes::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'unknown_files_' ) ) { return Unknown_Files::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'content_folder_' ) ) { return Content_Folders::human_error( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'cdn_' ) ) { return CDN::human( $e->getMessage() ); } if ( str_starts_with( $e->getMessage(), 'fit_' ) ) { return Results::reason( $e->getMessage() ); } // Library forms (F-IM-11): say what to do instead of the general message. $library = array( 'choose_media' => __( 'Check at least one media item first.', 'affstackdev-image-manager' ), 'confirm_media' => __( 'Tick the confirmation box, then apply again. Nothing was changed.', 'affstackdev-image-manager' ), 'choose_media_term' => __( 'Choose a folder or collection first. Nothing was changed.', 'affstackdev-image-manager' ), 'media_term_failed' => __( 'That folder or collection could not be changed. Reload the library and try again.', 'affstackdev-image-manager' ), 'settings_invalid_choice' => __( 'That choice is not available. Reload the Settings tab and save again.', 'affstackdev-image-manager' ), 'settings_invalid_folder' => __( 'A picture folder must be a folder inside wp-content: not the uploads folder or inside it, not Image Manager’s own folder and not a link. Check each line and save again.', 'affstackdev-image-manager' ), 'settings_invalid_watermark' => __( 'Choose the watermark picture: a PNG or WebP from the Media Library, at most 2,048 pixels on its longer side and 4 MB.', 'affstackdev-image-manager' ), 'settings_svg_roles' => __( 'Tick at least one role that may upload SVG files, or switch SVG uploads off.', 'affstackdev-image-manager' ), 'settings_svg_unavailable' => __( 'SVG uploads cannot be switched on: this server’s PHP lacks the DOM or XMLReader extension that cleaning SVG files needs.', 'affstackdev-image-manager' ), ); if ( isset( $library[ $e->getMessage() ] ) ) { return $library[ $e->getMessage() ]; } return $safe[ $e->getMessage() ] ?? __( 'This action could not finish. Originals remain unchanged. Review Tools for system status.', 'affstackdev-image-manager' ); } /** * Handle a form posted to admin-post.php (a POST with the nonce): stream a backup or remote backup download, start the * Google Drive sign-in, take an uploaded backup, send the integration contract, release report or diagnostics as JSON, * or run the operation; then send the user back to the same view with its notice. */ public function action(): void { self::authorize(); if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { wp_die( esc_html__( 'POST required.', 'affstackdev-image-manager' ), '', array( 'response' => 405 ) ); } check_admin_referer( 'afsd_im_action' ); $data = Plugin::fields( self::FORM_FIELDS ); $this->read_view( $data ); $op = isset( $data['operation'] ) && is_string( $data['operation'] ) ? sanitize_key( $data['operation'] ) : ''; if ( 'backup_download' === $op ) { try { Backup::stream_download( is_string( $data['snapshot_id'] ?? null ) ? $data['snapshot_id'] : '' ); } catch ( \Throwable $e ) { wp_die( esc_html( self::error_message( $e ) ), '', array( 'response' => 400 ) ); } } if ( 'remote_download' === $op ) { try { Remote_Backup::stream_remote_download( is_string( $data['remote_id'] ?? null ) ? $data['remote_id'] : '' ); } catch ( \Throwable $e ) { wp_die( esc_html( self::error_message( $e ) ), '', array( 'response' => 400 ) ); } } if ( 'remote_google_connect' === $op ) { try { $url = Remote_Backup::google_authorize_url(); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- destination is fixed Google OAuth host constructed by Remote_Backup. wp_redirect( $url ); exit; } // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- destination is fixed Google OAuth host constructed by Remote_Backup. catch ( \Throwable $e ) { Reviewed_Store::set_transient( 'afsd_im_notice_' . get_current_user_id(), self::error_message( $e ), 120 ); wp_safe_redirect( self::url( 'backups' ) ); exit; } } if ( 'backup_import' === $op ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Backup::import_uploaded() validates PHP upload provenance and size, uses private staging, then validates the archive before restore. try { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Backup::import_uploaded() validates PHP upload provenance and size, uses private staging, then validates the archive before restore. $snap = Backup::import_uploaded( isset( $_FILES['backup_file'] ) && is_array( $_FILES['backup_file'] ) ? $_FILES['backup_file'] : array(), is_string( $data['backup_password'] ?? null ) ? $data['backup_password'] : '' ); $message = __( 'Portable media backup imported. Inspect it before restoring anything.', 'affstackdev-image-manager' ); $this->backup = (string) $snap['id']; } catch ( \Throwable $e ) { $message = self::error_message( $e ); } Reviewed_Store::set_transient( 'afsd_im_notice_' . get_current_user_id(), $message, 120 ); wp_safe_redirect( self::url( 'backups', $this->backup ? array( 'im_backup' => $this->backup ) : array() ) ); exit; } if ( 'integration_contract' === $op ) { nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="affstackdev-image-manager-contract-v1-3.json"' ); echo wp_json_encode( Foundation::contract(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON, no site/private data. exit; } if ( 'release_report' === $op ) { nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="affstackdev-image-manager-release-health-' . VERSION . '.json"' ); echo wp_json_encode( Release_Health::report(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON, no secrets/media payload. exit; } if ( 'diagnostics' === $op ) { $out = array( 'plugin' => VERSION, 'foundation' => Foundation::contract(), 'generated_utc' => gmdate( 'c' ), 'preflight' => Settings::preflight(), 'settings' => Settings::get(), 'job' => Workflow::job(), 'progress' => Workflow::snapshot(), 'suite_available' => Suite::available(), 'enhanced_effective' => Suite::enhanced(), 'network_savings_measured' => false, 'background' => Scheduler::status(), 'backup' => Backup::status(), 'remote_backup' => array( 'connection' => Remote_Backup::connection(), 'transfer' => Remote_Backup::status(), 'schedule' => Remote_Backup::schedule_status(), 'indexed' => count( Remote_Backup::index() ), ), 'activity' => Activity::recent(), 'release_health' => Release_Health::report(), 'last_batch' => get_option( 'afsd_im_last_batch', array() ), ); nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="affstackdev-image-manager-diagnostics.json"' ); echo wp_json_encode( $out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON download. exit; } try { $message = $this->perform( $op, $data ); } catch ( \Throwable $e ) { $message = self::error_message( $e ); } Reviewed_Store::set_transient( 'afsd_im_notice_' . get_current_user_id(), $message, 120 ); wp_safe_redirect( self::url( $this->tab, array( 'im_page' => $this->page, 'im_filter' => $this->filter, 'im_search' => $this->search, 'im_image' => $this->image, 'im_size' => $this->size, 'im_format' => $this->format, 'im_media_view' => $this->media_view, 'im_media_layout' => $this->media_layout, 'im_media_folder' => $this->media_folder, 'im_media_collection' => $this->media_collection, 'im_media_type' => $this->media_type, 'im_media_sort' => $this->media_sort, 'im_media_id' => $this->media_id, 'im_backup' => $this->backup, 'im_backup_page' => $this->backup_page, ) ) ); exit; } /** * Handle the enhanced interface's requests (a POST with the nonce): run the operation and send back its notice with * the run's, the backup's and the transfer's state and panels, the image cards, the comparison and, when the screen * should change, the whole screen; on an error send its words (409 when Image Manager is busy). */ public function ajax(): void { self::authorize(); if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { wp_send_json_error( array( 'message' => __( 'POST required.', 'affstackdev-image-manager' ) ), 405 ); } if ( ! check_ajax_referer( 'afsd_im_action', '_wpnonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Your security token expired. Reload this screen to continue.', 'affstackdev-image-manager' ) ), 403 ); } $data = Plugin::fields( self::FORM_FIELDS ); $this->read_view( $data ); $op = isset( $data['operation'] ) && is_string( $data['operation'] ) ? sanitize_key( $data['operation'] ) : ''; try { $message = $this->perform( $op, $data ); $passive_media = $this->passive_media_view(); $v = $passive_media ? $this->media_inventory_snapshot() : Workflow::snapshot(); $b = $passive_media ? self::idle_backup_status() : Backup::status(); $rb = $passive_media ? self::idle_backup_status() : Remote_Backup::status(); $out = array( 'message' => $message, 'upload' => $this->upload_result, 'snapshot' => $v, 'backup_snapshot' => $b, 'backup_html' => $passive_media ? '' : $this->backup_progress( $b ), 'remote_snapshot' => $rb, 'remote_html' => $passive_media ? '' : $this->remote_progress( $rb ), 'progress_html' => $passive_media ? '' : $this->progress( $v ), 'breakdown_html' => $passive_media ? '' : $this->breakdown( $v ), 'background_html' => $passive_media ? '' : $this->background( $v ), 'saved_label' => size_format( $v['saved'], 1 ), 'enhanced' => Suite::enhanced(), 'url' => self::url( $this->tab, array( 'im_page' => $this->page, 'im_filter' => $this->filter, 'im_search' => $this->search, 'im_image' => $this->image, 'im_size' => $this->size, 'im_format' => $this->format, 'im_media_view' => $this->media_view, 'im_media_layout' => $this->media_layout, 'im_media_folder' => $this->media_folder, 'im_media_collection' => $this->media_collection, 'im_media_type' => $this->media_type, 'im_media_sort' => $this->media_sort, 'im_media_id' => $this->media_id, 'im_backup' => $this->backup, 'im_backup_page' => $this->backup_page, ) ), ); if ( ! $this->image && 'images' === $this->tab && 'results' === $this->media_view ) { global $wpdb; $visible = is_string( $data['visible_ids'] ?? null ) ? array_values( array_unique( array_filter( array_map( 'absint', explode( ',', $data['visible_ids'] ) ) ) ) ) : array(); if ( count( $visible ) > 20 ) { $visible = array_slice( $visible, 0, 20 ); } if ( $visible ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current plugin source/progress rows during polling; writes happen in independent workers and stale cached rows misreport completion. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id IN (' . implode( ',', array_fill( 0, count( $visible ), '%d' ) ) . ')', array_merge( array( Repository::table( 'sources' ) ), $visible ) ), ARRAY_A ); } else { $rows = Batch::page_matching( $this->filter, $this->search, ( $this->page - 1 ) * 20 ); } $out['row_status'] = array(); foreach ( $rows as $raw ) { $r = Repository::decode( $raw ); $out['row_status'][ (string) $r['id'] ] = array( 'status' => $this->card_status( $r ), 'reason' => Results::row( $r )['reason'], 'details' => $this->size_results( $r ), ); } } if ( $this->image && 'images' === $this->tab ) { $out['preview_html'] = $this->preview_result(); $out['preview_key'] = $this->preview_key(); } $quiet = in_array( $op, array( 'tick', 'status', 'browser_result', 'fit_result', 'backup_tick', 'backup_status', 'remote_tick', 'remote_status' ), true ); $backup_transition = in_array( $op, array( 'backup_tick', 'backup_status' ), true ) && 'backups' === $this->tab && ! $b['active']; $remote_transition = in_array( $op, array( 'remote_tick', 'remote_status' ), true ) && 'backups' === $this->tab && ! $rb['active']; if ( ! $quiet || $backup_transition || $remote_transition ) { $out['html'] = Plugin::capture( function () use ( $message ): void { $this->screen( $message ); } ); } wp_send_json_success( $out ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => self::error_message( $e ) ), 'worker_busy' === $e->getMessage() ? 409 : 400 ); } } }