';
}
/**
* 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__( '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' ) . '
';
$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 '
';
$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'] ) ) . '
'
. 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( 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' ) ) . '
' . 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' ) . '
' . esc_html__( 'Image selection is pending until discovery finishes. The inventory totals above are not this run’s progress.', 'affstackdev-image-manager' ) . '
' . 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 ) . '
' : '' );
}
/**
* 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 '
';
// 1.9.0: a whole folder from the computer (assets/folder-upload.js shows this where the browser can choose folders).
echo '
'
. esc_html__( 'Its sub-folders become folders with the same names inside the destination folder (eight levels at most). The files are sent eight at a time; keep this screen open until it says it is done.', 'affstackdev-image-manager' )
. '
';
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' ) . '
'
. 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__( '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__( '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__( '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 '
';
foreach ( $shown as $t ) {
/* translators: 1: folder name; 2: shortcode for that folder. */
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' )
. '
' . 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 '
';
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__( '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 '
';
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 ) ) ) ) ) . '
' . esc_html__( 'Sizes no theme or plugin registers now (kept, not removed):', 'affstackdev-image-manager' ) . '
';
foreach ( (array) $s['unregistered'] as $name => $row ) {
/* translators: 1: size name; 2: number of pictures; 3: their total size, such as 4 MB. */
echo '
';
}
}
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 '
';
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'] ) ) ) . '
';
}
}
}
$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 '
' . 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 '
';
foreach ( array_slice( $s['listed'], 0, 60 ) as $id ) {
$post = get_post( (int) $id );
if ( ! $post || 'attachment' !== $post->post_type ) {
continue;
}
/* translators: %d: attachment ID, shown for media without a title. */
$name = $post->post_title ?: sprintf( __( 'Media #%d', 'affstackdev-image-manager' ), (int) $id );
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'] ) ) . '
'
. 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__( 'Choose an action and its scope. Selecting all matching results includes every result page, not just the images shown here.', 'affstackdev-image-manager' ) . '
' . esc_html__( 'Compression, size and review choices do not affect this action. Delivery switches never delete files. Allow optimized delivery does not turn on the global delivery setting.', 'affstackdev-image-manager' ) . '
';
self::check( 'bulk_confirm', __( 'Apply the selected action to this scope. Original files will not be deleted.', 'affstackdev-image-manager' ) );
echo '
'
. 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' )
. '
'
. 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__( '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' ) . '
'
. 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' ) . '
' . 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' ) . '
';
}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__( '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' )
. '
';
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 '
'
. 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' ) . ''
)
. '
'
. 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' )
. '
'
. esc_html__( 'Only backups created by Image Manager are indexed here. The customer-owned provider remains the authoritative remote storage.', 'affstackdev-image-manager' )
. '
' . 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__( '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__( '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' )
. '
'
/* 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__( 'Choose what to optimize and how images are delivered.', 'affstackdev-image-manager' ) . '
';
$this->begin( 'settings' );
echo '';
$this->watermark_fieldset( $s );
$this->svg_fieldset( $s );
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 '';
}
/**
* 1.9.0 (D-500 gap 14): Settings > SVG files.
*
* @param array $s The settings.
*/
private function svg_fieldset( array $s ): void {
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' ) . '
' . 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' ) . '
'
. esc_html__( 'Read-only environment checks. Warnings narrow what is available; they do not delete or rewrite media.', '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' ) . '
' . 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' ) . '
'
. 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 '
'
. 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' )
. '
'
. 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__( '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 );
}
}
}