From 28dff2a6d46703f7d8e946760cd00c07c4e09d16 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 11:53:44 +0800 Subject: [PATCH 01/13] feat: show bulk library progress stats in free settings Move attachment listing and shared progress counting into free so the Bulk Optimization upsell can display real "X of Y optimized" numbers. Co-authored-by: Cursor --- cimo.php | 1 + src/admin/class-bulk-library.php | 126 +++++++++++ src/admin/css/admin-page.css | 17 +- .../bulk-optimization-upsell.js | 70 ++++++ .../js/bulk-optimizer/bulk-optimizer-stats.js | 76 +++++++ src/admin/js/page/admin-settings.js | 16 +- src/shared/bulk-stats.js | 201 ++++++++++++++++++ 7 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 src/admin/class-bulk-library.php create mode 100644 src/admin/js/bulk-optimizer/bulk-optimization-upsell.js create mode 100644 src/admin/js/bulk-optimizer/bulk-optimizer-stats.js create mode 100644 src/shared/bulk-stats.js diff --git a/cimo.php b/cimo.php index 9e6f403..bfa76eb 100644 --- a/cimo.php +++ b/cimo.php @@ -25,6 +25,7 @@ require_once __DIR__ . '/src/admin/class-meta-box.php'; require_once __DIR__ . '/src/admin/class-metadata.php'; require_once __DIR__ . '/src/admin/class-admin-notices.php'; +require_once __DIR__ . '/src/admin/class-bulk-library.php'; require_once __DIR__ . '/src/admin/class-stats.php'; require_once __DIR__ . '/src/admin/class-admin.php'; diff --git a/src/admin/class-bulk-library.php b/src/admin/class-bulk-library.php new file mode 100644 index 0000000..a332da8 --- /dev/null +++ b/src/admin/class-bulk-library.php @@ -0,0 +1,126 @@ + 'GET', + 'callback' => [ $this, 'rest_get_all_attachments' ], + 'permission_callback' => function() { + return current_user_can( 'upload_files' ) && current_user_can( 'edit_posts' ) && current_user_can( 'edit_others_posts' ); + }, + ] ); + } + + /** + * REST: all bulk-optimizable attachments. + * + * @return WP_REST_Response + */ + public function rest_get_all_attachments() { + return rest_ensure_response( self::get_all_attachments() ); + } + + /** + * Get all image, video, and audio attachments with metadata. + * Same dataset Premium Bulk Optimization uses for progress stats. + * + * @return array + */ + public static function get_all_attachments() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $attachments = $wpdb->get_results( + $wpdb->prepare( + " + SELECT p.ID, p.post_date, p.post_mime_type, pm.meta_value AS attached_file + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm + ON p.ID = pm.post_id + AND pm.meta_key = '_wp_attached_file' + WHERE p.post_type = %s + AND p.post_status = %s + AND ( + p.post_mime_type LIKE %s + OR p.post_mime_type LIKE %s + OR p.post_mime_type LIKE %s + ) + ", + 'attachment', + 'inherit', + 'image/%', + 'video/%', + 'audio/%' + ), + ARRAY_A + ); + + if ( empty( $attachments ) ) { + return []; + } + + $candidates = []; + foreach ( $attachments as $row ) { + $id = (int) $row['ID']; + $mime_type = isset( $row['post_mime_type'] ) ? (string) $row['post_mime_type'] : ''; + $attached_file = isset( $row['attached_file'] ) ? maybe_unserialize( $row['attached_file'] ) : null; + if ( ! is_string( $attached_file ) || $attached_file === '' ) { + continue; + } + if ( strpos( $mime_type, 'image/' ) === 0 && stripos( $attached_file, '.gif' ) === strlen( $attached_file ) - 4 ) { + continue; + } + $candidates[] = [ + 'id' => $id, + 'date' => isset( $row['post_date'] ) ? $row['post_date'] : null, + 'mime_type' => $mime_type, + 'attached_file' => $attached_file, + ]; + } + + $ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $candidates, 'id' ) ) ) ); + if ( $ids ) { + update_meta_cache( 'post', $ids ); + } + + $filtered_attachments = []; + foreach ( $candidates as $candidate ) { + $meta_raw = wp_get_attachment_metadata( $candidate['id'] ); + if ( ! is_array( $meta_raw ) ) { + $meta_raw = []; + } + $filtered_attachments[] = [ + 'id' => $candidate['id'], + 'date' => $candidate['date'], + 'file' => $candidate['attached_file'], + 'filesize' => isset( $meta_raw['filesize'] ) ? $meta_raw['filesize'] : null, + 'sizes' => isset( $meta_raw['sizes'] ) ? $meta_raw['sizes'] : null, + 'cimo' => isset( $meta_raw['cimo'] ) ? $meta_raw['cimo'] : null, + 'mimeType' => $candidate['mime_type'], + ]; + } + + return $filtered_attachments; + } + } + + new Cimo_Bulk_Library(); +} diff --git a/src/admin/css/admin-page.css b/src/admin/css/admin-page.css index 5373578..0a12b91 100644 --- a/src/admin/css/admin-page.css +++ b/src/admin/css/admin-page.css @@ -643,10 +643,25 @@ } } -.cimo-is-premium .cimo-settings-bulk-optimization { +/* Bulk Optimization first (after info) in free and premium */ +.cimo-settings-bulk-optimization { order: 1; } +.cimo-bulk-optimizer-upsell .cimo-bulk-optimize-button { + background: #3c83f6; + background-image: linear-gradient(to right, #9833ff, #4d2fe5); + color: #fff; + border: none; + justify-content: center; + + &:hover { + background: #226be0 !important; + background-image: linear-gradient(to right, #8114ef, #3516d2) !important; + color: #fff; + } +} + .cimo-smart-optimization-toggle { order: 2; diff --git a/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js b/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js new file mode 100644 index 0000000..662e607 --- /dev/null +++ b/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js @@ -0,0 +1,70 @@ +/** + * Free Bulk Optimization upsell — shows real progress stats, non-working controls. + */ +import { useEffect, useState } from '@wordpress/element' +import { __ } from '@wordpress/i18n' +import { Button } from '@wordpress/components' +import apiFetch from '@wordpress/api-fetch' +import { countBulkProgressStats } from '~cimo/shared/bulk-stats' +import { buildPricingUrl } from '~cimo/shared/pricing-url' +import { BulkOptimizerStats } from './bulk-optimizer-stats' + +const BulkOptimizationUpsell = () => { + const [ isLoading, setIsLoading ] = useState( true ) + const [ stats, setStats ] = useState( { + optimized: 0, unoptimized: 0, skipped: 0, + } ) + const pricingHref = buildPricingUrl( 'bulk' ) + + useEffect( () => { + apiFetch( { path: '/cimo/v1/attachments' } ) + .then( data => { + setStats( countBulkProgressStats( data ) ) + } ) + .catch( () => { + setStats( { + optimized: 0, unoptimized: 0, skipped: 0, + } ) + } ) + .finally( () => setIsLoading( false ) ) + }, [] ) + + return ( +
+ +
+ + +
+
+ ) +} + +export { BulkOptimizationUpsell } diff --git a/src/admin/js/bulk-optimizer/bulk-optimizer-stats.js b/src/admin/js/bulk-optimizer/bulk-optimizer-stats.js new file mode 100644 index 0000000..14e1204 --- /dev/null +++ b/src/admin/js/bulk-optimizer/bulk-optimizer-stats.js @@ -0,0 +1,76 @@ +/** + * Presentational bulk progress stats ("X of Y optimized" + progress bar). + * Shared by Premium Bulk Optimizer and free Bulk upsell. + */ +import { __, sprintf } from '@wordpress/i18n' +import { ProgressBar } from '@wordpress/components' + +/** + * @param {Object} props + * @param {boolean} props.isLoading + * @param {number} props.optimized + * @param {number} props.unoptimized + * @param {number} [props.skipped=0] + * @param {Function} [props.onSkippedClick] + */ +const BulkOptimizerStats = ( { + isLoading, + optimized = 0, + unoptimized = 0, + skipped = 0, + onSkippedClick, +} ) => { + const total = optimized + unoptimized + // Don't display to 100% unless all images are optimized. + const percent = total > 0 ? Math.floor( optimized / total * 1000 ) / 10 : 0 + + return ( + <> +
+ + { sprintf( + __( '%s of %s optimized', 'cimo-image-optimizer' ), + isLoading ? '-' : optimized.toLocaleString(), + isLoading ? '-' : total.toLocaleString() + ) } + + + { sprintf( + __( '%s%%', 'cimo-image-optimizer' ), + isLoading ? '-' : ( + percent % 1 === 0 + ? percent.toLocaleString() + : Number( percent.toFixed( 1 ) ).toLocaleString() + ) + ) } + +
+ + { !! skipped && ( +
{ + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault() + onSkippedClick?.() + } + } } + title={ __( 'View skipped files', 'cimo-image-optimizer' ) } + > + + + + { sprintf( __( '%d skipped', 'cimo-image-optimizer' ), skipped ) } +
+ ) } + + ) +} + +export { BulkOptimizerStats } diff --git a/src/admin/js/page/admin-settings.js b/src/admin/js/page/admin-settings.js index 1558808..cb03ad9 100644 --- a/src/admin/js/page/admin-settings.js +++ b/src/admin/js/page/admin-settings.js @@ -10,6 +10,7 @@ import { applyFilters } from '@wordpress/hooks' import apiFetch from '@wordpress/api-fetch' import { __, sprintf } from '@wordpress/i18n' import { buildPricingUrl } from '~cimo/shared/pricing-url' +import { BulkOptimizationUpsell } from '~cimo/admin/js/bulk-optimizer/bulk-optimization-upsell' import cimoLogo from './assets/logo-long.webp' const buildType = applyFilters( 'cimo.admin.settings.buildType', 'free' ) @@ -750,11 +751,16 @@ const AdminSettings = () => { { buildType === 'free' && ( - + <> +

+ { __( 'Bulk optimize existing media in your Media Library.', 'cimo-image-optimizer' ) } + { ' ' } + + { __( 'Learn more', 'cimo-image-optimizer' ) } + +

+ + ) } { buildType === 'premium' && <>

diff --git a/src/shared/bulk-stats.js b/src/shared/bulk-stats.js new file mode 100644 index 0000000..84883a3 --- /dev/null +++ b/src/shared/bulk-stats.js @@ -0,0 +1,201 @@ +/** + * Bulk progress counting helpers (shared free + premium). + * Used to compute "X of Y optimized" for the Bulk Optimization UI. + * + * Mime inclusion mirrors Premium BulkCollection.supportsAttachmentMimeType. + */ + +import mime from 'mime/lite' +import { ImageConverter } from './converters/image-converter' + +/** Bulk-optimizable video MIME types (Premium VideoConverter SUPPORTED_OUTPUT_FORMATS). */ +const BULK_VIDEO_MIME_TYPES = new Set( [ + 'video/mp4', + 'video/x-m4v', + 'video/webm', + 'video/ogg', + 'video/quicktime', +] ) + +/** Bulk-optimizable audio MIME types (Premium AudioConverter SUPPORTED_OUTPUT_FORMATS). */ +const BULK_AUDIO_MIME_TYPES = new Set( [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/x-wav', + 'audio/wave', + 'audio/ogg', + 'audio/opus', + 'audio/vorbis', + 'audio/aac', + 'audio/adts', + 'audio/flac', + 'audio/x-flac', + 'audio/mp4', + 'audio/x-m4a', +] ) + +/** + * @param {'audio'|'video'} tagName + * @param {string} mimeType + * @return {boolean} + */ +function canBrowserPlayMediaType( tagName, mimeType ) { + if ( ! mimeType || typeof document === 'undefined' ) { + return false + } + const element = document.createElement( tagName ) + return typeof element.canPlayType === 'function' && element.canPlayType( mimeType ) !== '' +} + +/** + * @param {string} file Path or URL. + * @return {string|null} + */ +export function getFileExtension( file ) { + if ( ! file || typeof file !== 'string' || ! file.includes( '.' ) ) { + return null + } + const ext = file.split( '.' ).pop() || null + return ext ? ext.toLowerCase() : null +} + +/** + * Resolve attachment MIME type the same way Premium BulkCollection does. + * + * @param {Object} attachment + * @return {string} + */ +export function resolveAttachmentMimeType( attachment ) { + const ext = attachment?.file ? getFileExtension( attachment.file ) : null + return attachment?.mimeType || ( ext ? mime.getType( ext ) : '' ) || '' +} + +/** + * Whether an attachment MIME type is included in bulk progress stats. + * Matches Premium BulkCollection.supportsAttachmentMimeType (incl. HEIC + browser play checks). + * + * @param {string} mimeType + * @return {boolean} + */ +export function supportsBulkStatsMimeType( mimeType ) { + if ( ! mimeType || typeof mimeType !== 'string' ) { + return false + } + + if ( mimeType.startsWith( 'image/' ) ) { + // Premium adds image/heic via filter; free stats include it explicitly for parity. + return ImageConverter.supportsMimeType( mimeType ) || mimeType === 'image/heic' + } + + if ( mimeType.startsWith( 'video/' ) ) { + return BULK_VIDEO_MIME_TYPES.has( mimeType ) && canBrowserPlayMediaType( 'video', mimeType ) + } + + if ( mimeType.startsWith( 'audio/' ) ) { + return BULK_AUDIO_MIME_TYPES.has( mimeType ) && canBrowserPlayMediaType( 'audio', mimeType ) + } + + return false +} + +/** + * Status for one attachment size — same rules as Premium BulkCollection. + * + * @param {string} size Size key ('full', 'thumbnail', …). + * @param {Object} attachment Attachment from /cimo/v1/attachments. + * @return {string|false} + */ +export function getAttachmentSizeStatus( size, attachment ) { + if ( ! attachment?.cimo ) { + return false + } + + if ( attachment.cimo.optimized_during_upload ) { + return 'optimized-on-upload' + } + if ( ! attachment.cimo.bulk_optimization ) { + return 'optimized-on-upload' + } + + const bulk = attachment.cimo.bulk_optimization + if ( bulk[ size ] ) { + const entry = bulk[ size ] + const status = typeof entry === 'object' ? entry.status : entry + if ( status === 'skip' ) { + return 'skipped' + } + if ( status === 'bulk' ) { + return 'bulk-optimized' + } + } + + return false +} + +/** + * Tally one attachment (full + image size variants with a file). + * + * @param {Object} attachment + * @param {Function} [supportsMimeType] (mimeType) => boolean — defaults to supportsBulkStatsMimeType + * @return {{ optimized: number, unoptimized: number, skipped: number }} + */ +export function tallyAttachment( attachment, supportsMimeType = supportsBulkStatsMimeType ) { + const stats = { + optimized: 0, unoptimized: 0, skipped: 0, + } + + const mimeType = resolveAttachmentMimeType( attachment ) + if ( typeof supportsMimeType === 'function' && ! supportsMimeType( mimeType ) ) { + return stats + } + + const isImage = typeof mimeType === 'string' && mimeType.startsWith( 'image/' ) + + const bump = status => { + if ( status === 'skipped' ) { + stats.skipped++ + } else if ( status ) { + stats.optimized++ + } else { + stats.unoptimized++ + } + } + + if ( attachment?.file ) { + bump( getAttachmentSizeStatus( 'full', attachment ) ) + } + + if ( isImage && attachment?.sizes && typeof attachment.sizes === 'object' ) { + for ( const sizeKey of Object.keys( attachment.sizes ) ) { + if ( attachment.sizes[ sizeKey ]?.file ) { + bump( getAttachmentSizeStatus( sizeKey, attachment ) ) + } + } + } + + return stats +} + +/** + * Count bulk progress. total = optimized + unoptimized (skipped excluded). + * + * @param {Object[]} attachments + * @param {Function} [supportsMimeType] + * @return {{ optimized: number, unoptimized: number, skipped: number, total: number }} + */ +export function countBulkProgressStats( attachments, supportsMimeType = supportsBulkStatsMimeType ) { + const stats = { + optimized: 0, unoptimized: 0, skipped: 0, total: 0, + } + + for ( const attachment of attachments || [] ) { + const piece = tallyAttachment( attachment, supportsMimeType ) + stats.optimized += piece.optimized + stats.unoptimized += piece.unoptimized + stats.skipped += piece.skipped + } + + stats.total = stats.optimized + stats.unoptimized + return stats +} From 3506ac927d9083fe98f660c8385031ae9a27601e Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 12:14:48 +0800 Subject: [PATCH 02/13] added computation of upsell savings in settings --- src/admin/class-stats.php | 4 +- src/admin/css/admin-page.css | 16 + .../bulk-optimization-upsell.js | 22 +- .../bulk-optimizer/use-bulk-progress-stats.js | 90 + src/admin/js/page/admin-settings.js | 1563 +++++++++-------- src/admin/js/page/premium-savings-estimate.js | 48 + src/shared/estimate-additional-savings.js | 60 + 7 files changed, 1003 insertions(+), 800 deletions(-) create mode 100644 src/admin/js/bulk-optimizer/use-bulk-progress-stats.js create mode 100644 src/admin/js/page/premium-savings-estimate.js create mode 100644 src/shared/estimate-additional-savings.js diff --git a/src/admin/class-stats.php b/src/admin/class-stats.php index ab398b3..c382417 100644 --- a/src/admin/class-stats.php +++ b/src/admin/class-stats.php @@ -145,6 +145,8 @@ public static function get_formatted_stats() { return [ 'media_optimized' => number_format( (int) ( $stats['media_optimized_num'] ?? 0 ) ), + 'media_optimized_num' => (int) ( $stats['media_optimized_num'] ?? 0 ), + 'total_original_size_kb' => $kb_before, 'before' => self::format_bytes( $bytes_before ), 'after' => self::format_bytes( $bytes_after ), 'saved' => self::format_bytes( $bytes_saved ), @@ -158,7 +160,7 @@ public static function get_formatted_stats() { /** * Format bytes into human readable format */ - private static function format_bytes( $bytes, $decimals = 2 ) { + public static function format_bytes( $bytes, $decimals = 2 ) { if ( ! is_numeric( $bytes ) || $bytes == 0 ) { return '0 Bytes'; } diff --git a/src/admin/css/admin-page.css b/src/admin/css/admin-page.css index 0a12b91..9b3adba 100644 --- a/src/admin/css/admin-page.css +++ b/src/admin/css/admin-page.css @@ -87,6 +87,22 @@ line-height: 1.1; } +.cimo-premium-savings-estimate { + display: inline-block; + margin-top: 10px; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + color: #3c83f6; + text-decoration: none; + + &:hover, + &:focus { + color: #1d4ed8; + text-decoration: underline; + } +} + .cimo-stat-icon { background: #3c83f61a; color: #3c83f6; diff --git a/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js b/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js index 662e607..496a1ec 100644 --- a/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js +++ b/src/admin/js/bulk-optimizer/bulk-optimization-upsell.js @@ -1,34 +1,16 @@ /** * Free Bulk Optimization upsell — shows real progress stats, non-working controls. */ -import { useEffect, useState } from '@wordpress/element' import { __ } from '@wordpress/i18n' import { Button } from '@wordpress/components' -import apiFetch from '@wordpress/api-fetch' -import { countBulkProgressStats } from '~cimo/shared/bulk-stats' import { buildPricingUrl } from '~cimo/shared/pricing-url' import { BulkOptimizerStats } from './bulk-optimizer-stats' +import { useSharedBulkProgressStats } from './use-bulk-progress-stats' const BulkOptimizationUpsell = () => { - const [ isLoading, setIsLoading ] = useState( true ) - const [ stats, setStats ] = useState( { - optimized: 0, unoptimized: 0, skipped: 0, - } ) + const { isLoading, stats } = useSharedBulkProgressStats() const pricingHref = buildPricingUrl( 'bulk' ) - useEffect( () => { - apiFetch( { path: '/cimo/v1/attachments' } ) - .then( data => { - setStats( countBulkProgressStats( data ) ) - } ) - .catch( () => { - setStats( { - optimized: 0, unoptimized: 0, skipped: 0, - } ) - } ) - .finally( () => setIsLoading( false ) ) - }, [] ) - return (

{ + if ( ! enabled ) { + setIsLoading( false ) + setStats( emptyStats ) + return undefined + } + + let cancelled = false + setIsLoading( true ) + + apiFetch( { path: '/cimo/v1/attachments' } ) + .then( data => { + if ( ! cancelled ) { + setStats( countBulkProgressStats( data ) ) + } + } ) + .catch( () => { + if ( ! cancelled ) { + setStats( emptyStats ) + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ) + } + } ) + + return () => { + cancelled = true + } + }, [ enabled ] ) + + return useMemo( () => ( { + isLoading, stats, + } ), [ isLoading, stats ] ) +} + +/** + * Provides one shared attachments fetch for free admin upsells. + * + * @param {Object} props + * @param {boolean} [props.enabled=true] + * @param {*} props.children + */ +export function BulkProgressStatsProvider( { + enabled = true, children, +} ) { + const value = useBulkProgressStats( enabled ) + return ( + + { children } + + ) +} + +/** + * @return {{ isLoading: boolean, stats: typeof emptyStats }} + */ +export function useSharedBulkProgressStats() { + const ctx = useContext( BulkProgressStatsContext ) + if ( ctx ) { + return ctx + } + return { + isLoading: false, stats: emptyStats, + } +} diff --git a/src/admin/js/page/admin-settings.js b/src/admin/js/page/admin-settings.js index cb03ad9..1be4c8d 100644 --- a/src/admin/js/page/admin-settings.js +++ b/src/admin/js/page/admin-settings.js @@ -11,6 +11,8 @@ import apiFetch from '@wordpress/api-fetch' import { __, sprintf } from '@wordpress/i18n' import { buildPricingUrl } from '~cimo/shared/pricing-url' import { BulkOptimizationUpsell } from '~cimo/admin/js/bulk-optimizer/bulk-optimization-upsell' +import { BulkProgressStatsProvider } from '~cimo/admin/js/bulk-optimizer/use-bulk-progress-stats' +import { PremiumSavingsEstimate } from './premium-savings-estimate' import cimoLogo from './assets/logo-long.webp' const buildType = applyFilters( 'cimo.admin.settings.buildType', 'free' ) @@ -366,182 +368,139 @@ const AdminSettings = () => { } return ( -
-
- { - - { /* Statistics Section */ } -
-
-

{ __( 'Total Storage Saved', 'cimo-image-optimizer' ) }

-
- { window.cimoAdmin.stats.total_storage_saved } - ↓ { window.cimoAdmin.stats.percentage_saved }% { __( 'reduction', 'cimo-image-optimizer' ) } + +
+
+ { + + { /* Statistics Section */ } +
+
+

{ __( 'Total Storage Saved', 'cimo-image-optimizer' ) }

+
+ { window.cimoAdmin.stats.total_storage_saved } + ↓ { window.cimoAdmin.stats.percentage_saved }% { __( 'reduction', 'cimo-image-optimizer' ) } +
+
{ __( 'Across all optimized media files', 'cimo-image-optimizer' ) }
+ { buildType === 'free' && }
-
{ __( 'Across all optimized media files', 'cimo-image-optimizer' ) }
-
-
-
- +
+
+ +
+ { window.cimoAdmin.stats.media_optimized } + { __( 'Media Files Optimized', 'cimo-image-optimizer' ) }
- { window.cimoAdmin.stats.media_optimized } - { __( 'Media Files Optimized', 'cimo-image-optimizer' ) } -
-
-
- +
+
+ +
+ { window.cimoAdmin.stats.before } + { __( 'Original Size', 'cimo-image-optimizer' ) }
- { window.cimoAdmin.stats.before } - { __( 'Original Size', 'cimo-image-optimizer' ) } -
-
-
- +
+
+ +
+ { window.cimoAdmin.stats.after } + { __( 'Optimized Size', 'cimo-image-optimizer' ) }
- { window.cimoAdmin.stats.after } - { __( 'Optimized Size', 'cimo-image-optimizer' ) }
-
- { ( () => { - const savedStr = window.cimoAdmin?.stats?.total_storage_saved - let showRating = false - if ( typeof savedStr === 'string' ) { - const match = savedStr.match( /^([\d.]+)\s*([a-zA-Z]+)/ ) - if ( match ) { - const num = parseFloat( match[ 1 ] ) - const unit = match[ 2 ].toUpperCase() - if ( unit === 'MB' && num > 5 ) { - showRating = true + { ( () => { + const savedStr = window.cimoAdmin?.stats?.total_storage_saved + let showRating = false + if ( typeof savedStr === 'string' ) { + const match = savedStr.match( /^([\d.]+)\s*([a-zA-Z]+)/ ) + if ( match ) { + const num = parseFloat( match[ 1 ] ) + const unit = match[ 2 ].toUpperCase() + if ( unit === 'MB' && num > 5 ) { + showRating = true + } } } - } - - if ( showRating && ! isRatingDismissed ) { - return ( -
-
-

- { __( 'Loving the instant storage & server resource savings?', 'cimo-image-optimizer' ) } -

-

- { sprintf( + + if ( showRating && ! isRatingDismissed ) { + return ( +

+
+

+ { __( 'Loving the instant storage & server resource savings?', 'cimo-image-optimizer' ) } +

+

+ { sprintf( // translators: %s is replaced with the total storage saved (e.g. "1.5 GB") - __( "You've saved over %s! If Cimo is helping your site, please consider leaving us a 5-star rating and help others discover Cimo!", 'cimo-image-optimizer' ), - window.cimoAdmin.stats.total_storage_saved - ) } -

-
- - + __( "You've saved over %s! If Cimo is helping your site, please consider leaving us a 5-star rating and help others discover Cimo!", 'cimo-image-optimizer' ), + window.cimoAdmin.stats.total_storage_saved + ) } +

+
+ + +
-
- ) - } - return null - } )() } - -
-
-
- - { buildType === 'free' && ( - { __( 'Your images are instantly optimized within your browser as you upload — only the optimized versions ever touch your site!', 'cimo-image-optimizer' ) } - ) } - { buildType === 'premium' && ( - { __( 'Your images, videos and audio files are instantly optimized within your browser as you upload — only the optimized versions ever touch your site!', 'cimo-image-optimizer' ) } - ) } -
- - { /* General Settings */ } + ) + } + return null + } )() } -
-
-

- - { __( 'General Settings', 'cimo-image-optimizer' ) } -

- + +
+
+ + { buildType === 'free' && ( + { __( 'Your images are instantly optimized within your browser as you upload — only the optimized versions ever touch your site!', 'cimo-image-optimizer' ) } + ) } + { buildType === 'premium' && ( + { __( 'Your images, videos and audio files are instantly optimized within your browser as you upload — only the optimized versions ever touch your site!', 'cimo-image-optimizer' ) } + ) }
- { /* Optimize All Media Uploads */ } -
- - { __( 'Optimize All Media Uploads', 'cimo-image-optimizer' ) } - { buildType === 'free' && ( - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } - - } - checked={ buildType === 'premium' ? settings.optimizeAllMedia === 1 : 0 } - disabled={ buildType === 'free' } - onChange={ checked => handleInputChange( 'optimizeAllMedia', checked ? 1 : 0 ) } - help={ __( 'Enable to optimize all files uploaded via any input type="file" on your website, including those in the admin pages, plugin forms and custom HTML upload forms in the frontend of your stie. When disabled, only uploads handled by Cimo\'s official integrations will be optimized.', 'cimo-image-optimizer' ) } - /> -
+ { /* General Settings */ } - { buildType === 'premium' &&
} - - { /* Show Optimization Toggle */ } -
- - { __( 'Show Optimization Toggle', 'cimo-image-optimizer' ) } - { buildType === 'free' && ( - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } +
+
+

+ - } - checked={ buildType === 'premium' ? settings.showOptimizationToggle === 1 : 0 } - disabled={ buildType === 'free' } - onChange={ checked => handleInputChange( 'showOptimizationToggle', checked ? 1 : 0 ) } - help={ __( 'Enable to show a small floating toggle near the lower right-hand corner of your screen in the WordPress backend. This can be used to temporarily disable media optimization for the current tab.', 'cimo-image-optimizer' ) } - /> -

+ { __( 'General Settings', 'cimo-image-optimizer' ) } + + +
- { /* Show in Frontend */ } - { settings.showOptimizationToggle === 1 && buildType === 'premium' && + { /* Optimize All Media Uploads */ }
- { __( 'Show in Frontend', 'cimo-image-optimizer' ) } + { __( 'Optimize All Media Uploads', 'cimo-image-optimizer' ) } { buildType === 'free' && ( { __( 'Premium', 'cimo-image-optimizer' ) } @@ -549,22 +508,22 @@ const AdminSettings = () => { ) } } - checked={ buildType === 'premium' ? settings.showOptimizationToggleFrontend === 1 : 0 } + checked={ buildType === 'premium' ? settings.optimizeAllMedia === 1 : 0 } disabled={ buildType === 'free' } - onChange={ checked => handleInputChange( 'showOptimizationToggleFrontend', checked ? 1 : 0 ) } - help={ __( 'Enable to show the optimization toggle also in frontend. Website visitors will also be able to see the floating toggle for visitor-facing forms and whenever there is a file upload input.', 'cimo-image-optimizer' ) } + onChange={ checked => handleInputChange( 'optimizeAllMedia', checked ? 1 : 0 ) } + help={ __( 'Enable to optimize all files uploaded via any input type="file" on your website, including those in the admin pages, plugin forms and custom HTML upload forms in the frontend of your stie. When disabled, only uploads handled by Cimo\'s official integrations will be optimized.', 'cimo-image-optimizer' ) } />
- } - { /* Persist Optimization Toggle */ } - { settings.showOptimizationToggle === 1 && buildType === 'premium' && + { buildType === 'premium' &&
} + + { /* Show Optimization Toggle */ }
- { __( 'Remember Toggle On/Off After Page Reload', 'cimo-image-optimizer' ) } + { __( 'Show Optimization Toggle', 'cimo-image-optimizer' ) } { buildType === 'free' && ( { __( 'Premium', 'cimo-image-optimizer' ) } @@ -572,721 +531,767 @@ const AdminSettings = () => { ) } } - checked={ buildType === 'premium' ? settings.persistOptimizationToggle === 1 : 0 } + checked={ buildType === 'premium' ? settings.showOptimizationToggle === 1 : 0 } disabled={ buildType === 'free' } - onChange={ checked => handleInputChange( 'persistOptimizationToggle', checked ? 1 : 0 ) } - help={ __( 'If enabled, your optimization toggle will remain the same even after you refresh the page or visit other pages. If disabled, the toggle will always reset to ON after reloading the page.', 'cimo-image-optimizer' ) } + onChange={ checked => handleInputChange( 'showOptimizationToggle', checked ? 1 : 0 ) } + help={ __( 'Enable to show a small floating toggle near the lower right-hand corner of your screen in the WordPress backend. This can be used to temporarily disable media optimization for the current tab.', 'cimo-image-optimizer' ) } />
- } - { buildType === 'premium' &&
} - - { /* WordPress Auto-Scaling */ } -
- handleInputChange( 'disableWpScaling', checked ? 1 : 0 ) } - help={ __( 'WordPress automatically scales images larger than 2560px. Disable this option to allow uploads of any size.', 'cimo-image-optimizer' ) } - /> -
+ { /* Show in Frontend */ } + { settings.showOptimizationToggle === 1 && buildType === 'premium' && +
+ + { __( 'Show in Frontend', 'cimo-image-optimizer' ) } + { buildType === 'free' && ( + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } + + } + checked={ buildType === 'premium' ? settings.showOptimizationToggleFrontend === 1 : 0 } + disabled={ buildType === 'free' } + onChange={ checked => handleInputChange( 'showOptimizationToggleFrontend', checked ? 1 : 0 ) } + help={ __( 'Enable to show the optimization toggle also in frontend. Website visitors will also be able to see the floating toggle for visitor-facing forms and whenever there is a file upload input.', 'cimo-image-optimizer' ) } + /> +
+ } - { /* Thumbnail Generation */ } -
- handleInputChange( 'disableThumbnailGeneration', checked ? 0 : 1 ) } - help={ __( 'By default, WordPress generates multiple image sizes (thumbnail, medium, large, etc.) when you upload images. Disable this option to save disk space.', 'cimo-image-optimizer' ) } - /> - - { settings.disableThumbnailGeneration === 0 && ( -
-

- { __( 'Individual Image Sizes', 'cimo-image-optimizer' ) } -

-

- { __( 'You can turn off generation for individual image sizes:', 'cimo-image-optimizer' ) } -

- { imageSizes.map( size => { - // thumbnailSizes stores DISABLED sizes, so invert the logic - const isEnabled = ! settings.thumbnailSizes.includes( size.name ) - return ( - handleThumbnailSizeChange( size.name, checked ) } - /> - ) - } ) } - { imageSizes.length === 0 && ( -
-

- { __( 'No image sizes detected. If you just re-enabled thumbnail generation, please save settings and refresh this page.', 'cimo-image-optimizer' ) } -

-
- ) } + { /* Persist Optimization Toggle */ } + { settings.showOptimizationToggle === 1 && buildType === 'premium' && +
+ + { __( 'Remember Toggle On/Off After Page Reload', 'cimo-image-optimizer' ) } + { buildType === 'free' && ( + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } + + } + checked={ buildType === 'premium' ? settings.persistOptimizationToggle === 1 : 0 } + disabled={ buildType === 'free' } + onChange={ checked => handleInputChange( 'persistOptimizationToggle', checked ? 1 : 0 ) } + help={ __( 'If enabled, your optimization toggle will remain the same even after you refresh the page or visit other pages. If disabled, the toggle will always reset to ON after reloading the page.', 'cimo-image-optimizer' ) } + />
- ) } -
+ } - -
+ { buildType === 'premium' &&
} + + { /* WordPress Auto-Scaling */ } +
+ handleInputChange( 'disableWpScaling', checked ? 1 : 0 ) } + help={ __( 'WordPress automatically scales images larger than 2560px. Disable this option to allow uploads of any size.', 'cimo-image-optimizer' ) } + /> +
- { /* Image Optimization */ } + { /* Thumbnail Generation */ } +
+ handleInputChange( 'disableThumbnailGeneration', checked ? 0 : 1 ) } + help={ __( 'By default, WordPress generates multiple image sizes (thumbnail, medium, large, etc.) when you upload images. Disable this option to save disk space.', 'cimo-image-optimizer' ) } + /> + + { settings.disableThumbnailGeneration === 0 && ( +
+

+ { __( 'Individual Image Sizes', 'cimo-image-optimizer' ) } +

+

+ { __( 'You can turn off generation for individual image sizes:', 'cimo-image-optimizer' ) } +

+ { imageSizes.map( size => { + // thumbnailSizes stores DISABLED sizes, so invert the logic + const isEnabled = ! settings.thumbnailSizes.includes( size.name ) + return ( + handleThumbnailSizeChange( size.name, checked ) } + /> + ) + } ) } + { imageSizes.length === 0 && ( +
+

+ { __( 'No image sizes detected. If you just re-enabled thumbnail generation, please save settings and refresh this page.', 'cimo-image-optimizer' ) } +

+
+ ) } +
+ ) } +
-
-
-

- - { __( 'Image Optimization Settings', 'cimo-image-optimizer' ) } -

- { /* Smart Optimization */ } -
- - { __( 'Smart Optimization', 'cimo-image-optimizer' ) } - { buildType === 'free' && ( - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } + { /* Image Optimization */ } + +
+
+

+ - } - checked={ buildType === 'free' ? false : ( settings.smartOptimization === 1 ) } - disabled={ buildType === 'free' } - onChange={ checked => handleInputChange( 'smartOptimization', checked ? 1 : 0 ) } - help={ __( 'Smart Optimization uses our advanced algorithms to choose the best compression and quality settings per image. This adds a small overhead to the upload process, but the results are even smaller file sizes and faster loading times.', 'cimo-image-optimizer' ) } - /> -

+ { __( 'Image Optimization Settings', 'cimo-image-optimizer' ) } + + +
+ + { /* Smart Optimization */ } +
+ + { __( 'Smart Optimization', 'cimo-image-optimizer' ) } + { buildType === 'free' && ( + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } + + } + checked={ buildType === 'free' ? false : ( settings.smartOptimization === 1 ) } + disabled={ buildType === 'free' } + onChange={ checked => handleInputChange( 'smartOptimization', checked ? 1 : 0 ) } + help={ __( 'Smart Optimization uses our advanced algorithms to choose the best compression and quality settings per image. This adds a small overhead to the upload process, but the results are even smaller file sizes and faster loading times.', 'cimo-image-optimizer' ) } + /> +
+ + { /* WebP Image Quality */ } + { ( buildType === 'free' || settings.smartOptimization === 0 ) && ( +
+ handleInputChange( 'webpQuality', value || '' ) } + min="1" + max="100" + step="1" + __next40pxDefaultSize + allowReset + initialPosition={ 80 } + help={ __( 'Set the quality / compression level for generated .webp images. Default is 80%. Higher values mean better quality and larger file size; lower values reduce file size with more compression but lower quality.', 'cimo-image-optimizer' ) } + /> +
+ ) } + + { /* Maximum Image Dimension */ } +
+ handleInputChange( 'maxImageDimension', value ) } + help={ sprintf( + __( 'Maximum width or height in pixels for uploaded images. Images exceeding this dimension will be automatically resized while preserving aspect ratio. Leave empty to %s. We recommend a value of 1920px.', 'cimo-image-optimizer' ), + window.cimoSettings?.wpScalingThreshold + ? sprintf( __( "use WordPress's default auto-scaling at %spx", 'cimo-image-optimizer' ), window.cimoSettings.wpScalingThreshold ) + : __( 'disable auto-scaling', 'cimo-image-optimizer' ) + ) } - { /* WebP Image Quality */ } - { ( buildType === 'free' || settings.smartOptimization === 0 ) && ( -
- handleInputChange( 'webpQuality', value || '' ) } - min="1" - max="100" - step="1" __next40pxDefaultSize - allowReset - initialPosition={ 80 } - help={ __( 'Set the quality / compression level for generated .webp images. Default is 80%. Higher values mean better quality and larger file size; lower values reduce file size with more compression but lower quality.', 'cimo-image-optimizer' ) } />
- ) } - - { /* Maximum Image Dimension */ } -
- handleInputChange( 'maxImageDimension', value ) } - help={ sprintf( - __( 'Maximum width or height in pixels for uploaded images. Images exceeding this dimension will be automatically resized while preserving aspect ratio. Leave empty to %s. We recommend a value of 1920px.', 'cimo-image-optimizer' ), - window.cimoSettings?.wpScalingThreshold - ? sprintf( __( "use WordPress's default auto-scaling at %spx", 'cimo-image-optimizer' ), window.cimoSettings.wpScalingThreshold ) - : __( 'disable auto-scaling', 'cimo-image-optimizer' ) - ) } - __next40pxDefaultSize - /> +
- -
+ { /* Bulk Optimization */ } - { /* Bulk Optimization */ } +
+
+

+ + { __( 'Bulk Optimization', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } +
-
-
-

- - { __( 'Bulk Optimization', 'cimo-image-optimizer' ) } -

{ buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } - + <> +

+ { __( 'Bulk optimize existing media in your Media Library.', 'cimo-image-optimizer' ) } + { ' ' } + + { __( 'Learn more', 'cimo-image-optimizer' ) } + +

+ + ) } -
- - { buildType === 'free' && ( - <> + { buildType === 'premium' && <>

- { __( 'Bulk optimize existing media in your Media Library.', 'cimo-image-optimizer' ) } - { ' ' } + { __( 'Bulk optimize existing media in your Media Library in one go.', 'cimo-image-optimizer' ) } +   { __( 'Learn more', 'cimo-image-optimizer' ) }

- - - ) } - { buildType === 'premium' && <> -

- { __( 'Bulk optimize existing media in your Media Library in one go.', 'cimo-image-optimizer' ) } -   - - { __( 'Learn more', 'cimo-image-optimizer' ) } - -

- - } -
+ + } +
- { /* Low Quality Image Placeholder */ } + { /* Low Quality Image Placeholder */ } -
-
-

- - { __( 'Low Quality Image Placeholder Settings', 'cimo-image-optimizer' ) } -

- { buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } -
+
+
+

+ + { __( 'Low Quality Image Placeholder Settings', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } +
- { buildType === 'free' && ( - - ) } - { buildType === 'premium' && <> -
- handleInputChange( 'lqipEnabled', checked ? 1 : 0 ) } - help={ __( 'Turn this option on to enable LQIP for all images. LQIP is only supported by Native Image Blocks.', 'cimo-image-optimizer' ) } + { buildType === 'free' && ( + -
- { settings.lqipEnabled === 1 && <> + ) } + { buildType === 'premium' && <>
- handleInputChange( 'lqipPulseSpeed', value || '' ) } - min="0.1" - max="5" - step="0.1" - __next40pxDefaultSize - allowReset - initialPosition={ 2.5 } - help={ __( 'Set the speed of the pulse animation when the image is loading. Default is 2.5s.', 'cimo-image-optimizer' ) } + handleInputChange( 'lqipEnabled', checked ? 1 : 0 ) } + help={ __( 'Turn this option on to enable LQIP for all images. LQIP is only supported by Native Image Blocks.', 'cimo-image-optimizer' ) } />
-
- handleInputChange( 'lqipBrightness', value || '' ) } - min="0.5" - max="1.5" - step="0.05" + { settings.lqipEnabled === 1 && <> +
+ handleInputChange( 'lqipPulseSpeed', value || '' ) } + min="0.1" + max="5" + step="0.1" + __next40pxDefaultSize + allowReset + initialPosition={ 2.5 } + help={ __( 'Set the speed of the pulse animation when the image is loading. Default is 2.5s.', 'cimo-image-optimizer' ) } + /> +
+
+ handleInputChange( 'lqipBrightness', value || '' ) } + min="0.5" + max="1.5" + step="0.05" + __next40pxDefaultSize + allowReset + initialPosition={ 1.3 } + help={ __( 'Set the brightness of the pulse animation when the image is loading. Default is 1.3x brightness.', 'cimo-image-optimizer' ) } + /> +
+
+ handleInputChange( 'lqipFadeDuration', value || '' ) } + min="0.1" + max="3" + step="0.1" + __next40pxDefaultSize + allowReset + initialPosition={ 0.5 } + help={ __( 'Set the duration of the fade in animation when the image is loaded. Default is 0.5s.', 'cimo-image-optimizer' ) } + /> +
+ + + } + } +
+ + { /* Video Optimization Settings */ } + +
+
+

+ + { __( 'Video Optimization Settings', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } + { buildType === 'premium' && ( +
+ > + { __( 'Recommended', 'cimo-image-optimizer' ) } + + ) } +
+ + { buildType === 'free' && ( + + ) } + { buildType === 'premium' && <>
- handleInputChange( 'lqipFadeDuration', value || '' ) } - min="0.1" - max="3" - step="0.1" - __next40pxDefaultSize - allowReset - initialPosition={ 0.5 } - help={ __( 'Set the duration of the fade in animation when the image is loaded. Default is 0.5s.', 'cimo-image-optimizer' ) } + handleInputChange( 'videoOptimizationEnabled', checked ? 1 : 0 ) } + help={ __( 'Turn this option off to upload videos without optimizing them.', 'cimo-image-optimizer' ) } />
+ { settings.videoOptimizationEnabled === 1 && <> +
+ handleInputChange( 'videoQuality', value ) } + isBlock + help={ __( 'Set the quality / compression level for optimized .MP4 video uploads. Default is Medium (Balanced). Lower quality means a smaller file size and lower quality, higher quality means a higher quality but larger file size.', 'cimo-image-optimizer' ) } + > + + + + + + +
+ +
+ handleInputChange( 'videoMaxResolution', value ) } + isBlock + help={ __( 'Set the maximum resolution for optimized video uploads. If the video uploaded is bigger than this, the video will not be resized down to this maximum resolution. Default is the video will not be resized.', 'cimo-image-optimizer' ) } + > + + + + + + + +
+ } + } - } -
- - { /* Video Optimization Settings */ } - -
-
-

- - { __( 'Video Optimization Settings', 'cimo-image-optimizer' ) } -

- { buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } - { buildType === 'premium' && ( - - ) }
- { buildType === 'free' && ( - - ) } - { buildType === 'premium' && <> -
- handleInputChange( 'videoOptimizationEnabled', checked ? 1 : 0 ) } - help={ __( 'Turn this option off to upload videos without optimizing them.', 'cimo-image-optimizer' ) } - /> -
+ { /* Audio Optimization Settings */ } - { settings.videoOptimizationEnabled === 1 && <> -
- handleInputChange( 'videoQuality', value ) } - isBlock - help={ __( 'Set the quality / compression level for optimized .MP4 video uploads. Default is Medium (Balanced). Lower quality means a smaller file size and lower quality, higher quality means a higher quality but larger file size.', 'cimo-image-optimizer' ) } +
+
+

+ + { __( 'Audio Optimization Settings', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + - - - - - - -
+ + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) } +
+ { buildType === 'free' && ( + + ) } + { buildType === 'premium' && <>
- handleInputChange( 'videoMaxResolution', value ) } - isBlock - help={ __( 'Set the maximum resolution for optimized video uploads. If the video uploaded is bigger than this, the video will not be resized down to this maximum resolution. Default is the video will not be resized.', 'cimo-image-optimizer' ) } - > - - - - - - - + label={ __( 'Enable Audio Optimization', 'cimo-image-optimizer' ) } + checked={ settings.audioOptimizationEnabled === 1 } + onChange={ checked => handleInputChange( 'audioOptimizationEnabled', checked ? 1 : 0 ) } + help={ __( 'Turn this option off to upload audio files without optimizing them.', 'cimo-image-optimizer' ) } + />
- } - - - } -
- { /* Audio Optimization Settings */ } + { settings.audioOptimizationEnabled === 1 && <> +
+ handleInputChange( 'audioQuality', value ) } + min="32" + max="320" + step="32" + help={ __( 'Set the quality / compression level for optimized .MP3 audio uploads. Default is 128kbps. Lower quality means a smaller file size and lower quality, higher quality means a higher quality but larger file size.', 'cimo-image-optimizer' ) } + /> +
+ } -
-
-

- - { __( 'Audio Optimization Settings', 'cimo-image-optimizer' ) } -

- { buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } + { __( 'Reset to Default', 'cimo-image-optimizer' ) } + + }
- { buildType === 'free' && ( - - ) } - { buildType === 'premium' && <> -
- handleInputChange( 'audioOptimizationEnabled', checked ? 1 : 0 ) } - help={ __( 'Turn this option off to upload audio files without optimizing them.', 'cimo-image-optimizer' ) } - /> + { /* SVG Optimization Settings */ } +
+
+

+ + { __( 'SVG Optimization Settings', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) }
- { settings.audioOptimizationEnabled === 1 && <> + { buildType === 'free' && ( + + ) } + { buildType === 'premium' && <>
- handleInputChange( 'audioQuality', value ) } - min="32" - max="320" - step="32" - help={ __( 'Set the quality / compression level for optimized .MP3 audio uploads. Default is 128kbps. Lower quality means a smaller file size and lower quality, higher quality means a higher quality but larger file size.', 'cimo-image-optimizer' ) } + label={ __( 'Enable SVG uploads', 'cimo-image-optimizer' ) } + checked={ settings.svgUpload === 1 } + onChange={ checked => handleInputChange( 'svgUpload', checked ? 1 : 0 ) } + help={ __( 'Allow SVG files to be uploaded in the media library. WordPress has this option disabled by default', 'cimo-image-optimizer' ) } />
- } - - } -
+ { settings.svgUpload === 1 && <> +
+ handleInputChange( 'svgOptimizationEnabled', checked ? 1 : 0 ) } + help={ __( 'Turn this option off to upload SVG files without optimizing them.', 'cimo-image-optimizer' ) } + /> +
+ } - { /* SVG Optimization Settings */ } -
-
-

- - { __( 'SVG Optimization Settings', 'cimo-image-optimizer' ) } -

- { buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } - - ) } + { __( 'Reset to Default', 'cimo-image-optimizer' ) } + + }
- { buildType === 'free' && ( - - ) } - { buildType === 'premium' && <> -
- handleInputChange( 'svgUpload', checked ? 1 : 0 ) } - help={ __( 'Allow SVG files to be uploaded in the media library. WordPress has this option disabled by default', 'cimo-image-optimizer' ) } - /> + { /* Stealth Mode Settings */ } + +
+
+

+ + { __( 'Stealth Mode', 'cimo-image-optimizer' ) } +

+ { buildType === 'free' && ( + + + { __( 'Premium', 'cimo-image-optimizer' ) } + + ) }
- { settings.svgUpload === 1 && <> + { buildType === 'free' && ( + + ) } + { buildType === 'premium' && <>
handleInputChange( 'svgOptimizationEnabled', checked ? 1 : 0 ) } - help={ __( 'Turn this option off to upload SVG files without optimizing them.', 'cimo-image-optimizer' ) } + label={ __( 'Stealth Mode', 'cimo-image-optimizer' ) } + checked={ settings.stealthModeEnabled === 1 } + onChange={ checked => handleInputChange( 'stealthModeEnabled', checked ? 1 : 0 ) } + help={ + <> + { __( 'When Stealth Mode is enabled, all Cimo branding and optimization stats will not be shown in the UI and dashboard. This settings page will not appear in the admin sidebar, you can access it by clicking the “Settings” link under Cimo in the plugins page. Stealth Mode will not affect how your media is optimized; everything continues to work as usual, just without any visual indicators of Cimo.', 'cimo-image-optimizer' ) } +   + + { __( 'Learn more', 'cimo-image-optimizer' ) } + + + } />
+ } +
+
+ { /* Submit Button */ } +
+
- } -
- - { /* Stealth Mode Settings */ } - -
-
-

- - { __( 'Stealth Mode', 'cimo-image-optimizer' ) } -

- { buildType === 'free' && ( - - - { __( 'Premium', 'cimo-image-optimizer' ) } + { hasUnsavedChanges && ( + + { __( 'You have unsaved changes', 'cimo-image-optimizer' ) } ) } + { saveMessage && ( +

{ saveMessage }

+ ) }
+
- { buildType === 'free' && ( - - ) } - { buildType === 'premium' && <> -
- handleInputChange( 'stealthModeEnabled', checked ? 1 : 0 ) } - help={ - <> - { __( 'When Stealth Mode is enabled, all Cimo branding and optimization stats will not be shown in the UI and dashboard. This settings page will not appear in the admin sidebar, you can access it by clicking the “Settings” link under Cimo in the plugins page. Stealth Mode will not affect how your media is optimized; everything continues to work as usual, just without any visual indicators of Cimo.', 'cimo-image-optimizer' ) } -   - - { __( 'Learn more', 'cimo-image-optimizer' ) } - - - } - /> + + + { buildType === 'free' && ( +
+
+
+
+

{ __( 'Unlock Full Optimization', 'cimo-image-optimizer' ) }

+
+

+ { __( 'Get even smaller file sizes, optimize existing media files, control user uploads, while keeping your server free from image processing.', 'cimo-image-optimizer' ) } +

+

+ { __( 'Main Features', 'cimo-image-optimizer' ) } +

+
    +
  • + + { /* Sparkles Icon */ } + + +

    + { __( 'Smart Optimization', 'cimo-image-optimizer' ) } +

    + + { __( 'Automatically apply the best format and compression for every image and get smaller file sizes', 'cimo-image-optimizer' ) } + +
  • +
  • + + { /* Images Icon */ } + + +

    + { __( 'Bulk Optimization', 'cimo-image-optimizer' ) } +

    + + { __( 'Optimize your existing files in your entire media library', 'cimo-image-optimizer' ) } + +
  • +
  • + + { /* Users Icon */ } + + +

    + { __( 'User Uploads', 'cimo-image-optimizer' ) } +

    + + { __( 'Prevent oversized uploads from users with Form Plugin integrations', 'cimo-image-optimizer' ) } + +
  • +
+

{ __( 'Also includes:', 'cimo-image-optimizer' ) }

+
    +
  • { __( 'Optimize video & audio files', 'cimo-image-optimizer' ) }
  • +
  • { __( 'SVG & HEIC support', 'cimo-image-optimizer' ) }
  • +
  • { __( 'Low Quality Image Placeholder', 'cimo-image-optimizer' ) }
  • +
  • { __( 'Stealth mode', 'cimo-image-optimizer' ) }
  • +
+ +
- } -
-
- - { /* Submit Button */ } -
-
- - { hasUnsavedChanges && ( - - { __( 'You have unsaved changes', 'cimo-image-optimizer' ) } - - ) } - { saveMessage && ( -

{ saveMessage }

- ) } -
-
- - - - { buildType === 'free' && ( -
-
-
- -
-

{ __( 'Unlock Full Optimization', 'cimo-image-optimizer' ) }

-
-

- { __( 'Get even smaller file sizes, optimize existing media files, control user uploads, while keeping your server free from image processing.', 'cimo-image-optimizer' ) } -

-

- { __( 'Main Features', 'cimo-image-optimizer' ) } -

-
    -
  • - - { /* Sparkles Icon */ } - - -

    - { __( 'Smart Optimization', 'cimo-image-optimizer' ) } -

    - - { __( 'Automatically apply the best format and compression for every image and get smaller file sizes', 'cimo-image-optimizer' ) } - -
  • -
  • - - { /* Images Icon */ } - - -

    - { __( 'Bulk Optimization', 'cimo-image-optimizer' ) } -

    - - { __( 'Optimize your existing files in your entire media library', 'cimo-image-optimizer' ) } - -
  • -
  • - - { /* Users Icon */ } - - -

    - { __( 'User Uploads', 'cimo-image-optimizer' ) } -

    - - { __( 'Prevent oversized uploads from users with Form Plugin integrations', 'cimo-image-optimizer' ) } - -
  • -
-

{ __( 'Also includes:', 'cimo-image-optimizer' ) }

-
    -
  • { __( 'Optimize video & audio files', 'cimo-image-optimizer' ) }
  • -
  • { __( 'SVG & HEIC support', 'cimo-image-optimizer' ) }
  • -
  • { __( 'Low Quality Image Placeholder', 'cimo-image-optimizer' ) }
  • -
  • { __( 'Stealth mode', 'cimo-image-optimizer' ) }
  • -
- -
- -
- - { __( '30-day money-back guarantee. No risk.', 'cimo-image-optimizer' ) } +
+ + { __( '30-day money-back guarantee. No risk.', 'cimo-image-optimizer' ) } +
-
- ) } -
+ ) } +
+ ) } diff --git a/src/admin/js/page/premium-savings-estimate.js b/src/admin/js/page/premium-savings-estimate.js new file mode 100644 index 0000000..4a329a5 --- /dev/null +++ b/src/admin/js/page/premium-savings-estimate.js @@ -0,0 +1,48 @@ +/** + * Free-only upsell under Total Storage Saved: estimated extra MB Premium could save. + */ +import { __, sprintf } from '@wordpress/i18n' +import { buildPricingUrl } from '~cimo/shared/pricing-url' +import { + estimateAdditionalSavingsBytes, + formatSavingsBytes, +} from '~cimo/shared/estimate-additional-savings' +import { useSharedBulkProgressStats } from '~cimo/admin/js/bulk-optimizer/use-bulk-progress-stats' + +const PremiumSavingsEstimate = () => { + const { isLoading, stats } = useSharedBulkProgressStats() + const siteStats = window.cimoAdmin?.stats || {} + + const savingsBytes = estimateAdditionalSavingsBytes( { + percentageSaved: siteStats.percentage_saved, + totalOriginalSizeKb: siteStats.total_original_size_kb, + mediaOptimizedNum: siteStats.media_optimized_num, + unoptimizedCount: stats.unoptimized, + } ) + + if ( isLoading || savingsBytes <= 0 ) { + return null + } + + const savingsLabel = formatSavingsBytes( savingsBytes ) + const href = buildPricingUrl( 'stats-savings' ) + + return ( + + { + sprintf( + /* translators: %s is a human-readable size, e.g. "12.4 MB" */ + __( 'Cimo Premium can save %s more →', 'cimo-image-optimizer' ), + savingsLabel + ) + } + + ) +} + +export { PremiumSavingsEstimate } diff --git a/src/shared/estimate-additional-savings.js b/src/shared/estimate-additional-savings.js new file mode 100644 index 0000000..0fa7d3c --- /dev/null +++ b/src/shared/estimate-additional-savings.js @@ -0,0 +1,60 @@ +/** + * Estimate how much more storage Premium bulk optimization could save. + * + * Formula: + * avgOriginal = totalOriginalSize / mediaOptimizedCount + * unoptimizedOriginal = avgOriginal * unoptimizedCount + * savings = unoptimizedOriginal * (percentageSaved / 100) + */ + +/** + * @param {Object} params + * @param {number} params.percentageSaved e.g. 42.5 for 42.5% reduction + * @param {number} params.totalOriginalSizeKb Original size of optimized media (KB) + * @param {number} params.mediaOptimizedNum Count of optimized media units + * @param {number} params.unoptimizedCount Unoptimized units from bulk progress + * @return {number} Estimated additional savings in bytes + */ +export function estimateAdditionalSavingsBytes( { + percentageSaved, + totalOriginalSizeKb, + mediaOptimizedNum, + unoptimizedCount, +} ) { + const optimized = Number( mediaOptimizedNum ) || 0 + const unoptimized = Number( unoptimizedCount ) || 0 + const originalKb = Number( totalOriginalSizeKb ) || 0 + const reduction = Number( percentageSaved ) || 0 + + if ( optimized <= 0 || unoptimized <= 0 || originalKb <= 0 || reduction <= 0 ) { + return 0 + } + + const avgOriginalKb = originalKb / optimized + const unoptimizedOriginalKb = avgOriginalKb * unoptimized + const savingsKb = unoptimizedOriginalKb * ( reduction / 100 ) + + return Math.max( 0, Math.round( savingsKb * 1024 ) ) +} + +/** + * Format bytes for the savings upsell (matches PHP Cimo_Stats::format_bytes). + * + * @param {number} bytes + * @param {number} [decimals=1] + * @return {string} Formatted bytes string + */ +export function formatSavingsBytes( bytes, decimals = 1 ) { + const n = Number( bytes ) + if ( ! Number.isFinite( n ) || n <= 0 ) { + return '0 Bytes' + } + + const k = 1024 + const sizes = [ 'Bytes', 'KB', 'MB', 'GB', 'TB' ] + const i = Math.min( sizes.length - 1, Math.floor( Math.log( n ) / Math.log( k ) ) ) + const value = n / Math.pow( k, i ) + const rounded = Number( value.toFixed( decimals ) ) + + return `${ rounded } ${ sizes[ i ] }` +} From 792116b5c7ddfe8b5ba2fd8368c2e34bb36feb73 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 12:27:49 +0800 Subject: [PATCH 03/13] change order of upsell --- src/admin/js/page/admin-settings.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/admin/js/page/admin-settings.js b/src/admin/js/page/admin-settings.js index 1be4c8d..6012de8 100644 --- a/src/admin/js/page/admin-settings.js +++ b/src/admin/js/page/admin-settings.js @@ -1231,26 +1231,26 @@ const AdminSettings = () => {
  • - { /* Sparkles Icon */ } - + { /* Images Icon */ } +

    - { __( 'Smart Optimization', 'cimo-image-optimizer' ) } + { __( 'Bulk Optimization', 'cimo-image-optimizer' ) }

    - { __( 'Automatically apply the best format and compression for every image and get smaller file sizes', 'cimo-image-optimizer' ) } + { __( 'Optimize your existing files in your entire media library', 'cimo-image-optimizer' ) }
  • - { /* Images Icon */ } - + { /* Sparkles Icon */ } +

    - { __( 'Bulk Optimization', 'cimo-image-optimizer' ) } + { __( 'Smart Optimization', 'cimo-image-optimizer' ) }

    - { __( 'Optimize your existing files in your entire media library', 'cimo-image-optimizer' ) } + { __( 'Automatically apply the best format and compression for every image and get smaller file sizes', 'cimo-image-optimizer' ) }
  • From 213a43059d0751e8242b363a178d90fe62974be5 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 12:29:02 +0800 Subject: [PATCH 04/13] added upsell on getting X MB savings --- src/admin/class-admin-notices.php | 17 ++- src/admin/class-bulk-library.php | 174 ++++++++++++++++++++++++++++++ src/admin/class-stats.php | 49 +++++++++ 3 files changed, 237 insertions(+), 3 deletions(-) diff --git a/src/admin/class-admin-notices.php b/src/admin/class-admin-notices.php index d6dacdd..dacf840 100644 --- a/src/admin/class-admin-notices.php +++ b/src/admin/class-admin-notices.php @@ -149,15 +149,26 @@ public function show_library_premium_notice() { $nonce = wp_create_nonce( 'cimo_dismiss_library_premium_notice_ajax' ); $pricing = Cimo_Admin::pricing_url( 'library-admin-notice', 'admin' ); + $savings_label = Cimo_Stats::get_additional_savings_estimate_label(); ?>

    - +

    - + ' . esc_html( $savings_label ) . '' + ); + } else { + esc_html_e( 'You\'re optimizing images on upload. Upgrade to Cimo premium to bulk optimize your entire media library.', 'cimo-image-optimizer' ); + } + ?>

    -

    +

    diff --git a/src/admin/class-bulk-library.php b/src/admin/class-bulk-library.php index a332da8..63a101c 100644 --- a/src/admin/class-bulk-library.php +++ b/src/admin/class-bulk-library.php @@ -120,6 +120,180 @@ public static function get_all_attachments() { return $filtered_attachments; } + + /** + * Count bulk progress the same way shared JS bulk-stats does (server-side). + * Video/audio browser canPlay checks are skipped; format allowlists still apply. + * + * @return array{optimized: int, unoptimized: int, skipped: int, total: int} + */ + public static function count_progress_stats() { + $stats = [ + 'optimized' => 0, + 'unoptimized' => 0, + 'skipped' => 0, + 'total' => 0, + ]; + + foreach ( self::get_all_attachments() as $attachment ) { + $piece = self::tally_attachment( $attachment ); + $stats['optimized'] += $piece['optimized']; + $stats['unoptimized'] += $piece['unoptimized']; + $stats['skipped'] += $piece['skipped']; + } + + $stats['total'] = $stats['optimized'] + $stats['unoptimized']; + return $stats; + } + + /** + * @param array $attachment Attachment from get_all_attachments(). + * @return array{optimized: int, unoptimized: int, skipped: int} + */ + private static function tally_attachment( $attachment ) { + $stats = [ + 'optimized' => 0, + 'unoptimized' => 0, + 'skipped' => 0, + ]; + + $mime_type = self::resolve_mime_type( $attachment ); + if ( ! self::supports_bulk_stats_mime_type( $mime_type ) ) { + return $stats; + } + + $is_image = is_string( $mime_type ) && strpos( $mime_type, 'image/' ) === 0; + + $bump = static function( $status ) use ( &$stats ) { + if ( $status === 'skipped' ) { + $stats['skipped']++; + } elseif ( $status ) { + $stats['optimized']++; + } else { + $stats['unoptimized']++; + } + }; + + if ( ! empty( $attachment['file'] ) ) { + $bump( self::get_attachment_size_status( 'full', $attachment ) ); + } + + if ( $is_image && ! empty( $attachment['sizes'] ) && is_array( $attachment['sizes'] ) ) { + foreach ( $attachment['sizes'] as $size_key => $size ) { + if ( ! empty( $size['file'] ) ) { + $bump( self::get_attachment_size_status( $size_key, $attachment ) ); + } + } + } + + return $stats; + } + + /** + * @param array $attachment + * @return string + */ + private static function resolve_mime_type( $attachment ) { + if ( ! empty( $attachment['mimeType'] ) && is_string( $attachment['mimeType'] ) ) { + return $attachment['mimeType']; + } + $file = isset( $attachment['file'] ) ? (string) $attachment['file'] : ''; + if ( $file === '' ) { + return ''; + } + $filetype = wp_check_filetype( $file ); + return ! empty( $filetype['type'] ) ? (string) $filetype['type'] : ''; + } + + /** + * Mirrors JS supportsBulkStatsMimeType (without browser canPlay). + * + * @param string $mime_type + * @return bool + */ + private static function supports_bulk_stats_mime_type( $mime_type ) { + if ( ! is_string( $mime_type ) || $mime_type === '' ) { + return false; + } + + if ( strpos( $mime_type, 'image/' ) === 0 ) { + return in_array( $mime_type, [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/jpg', + 'image/heic', + ], true ); + } + + if ( strpos( $mime_type, 'video/' ) === 0 ) { + return in_array( $mime_type, [ + 'video/mp4', + 'video/x-m4v', + 'video/webm', + 'video/ogg', + 'video/quicktime', + ], true ); + } + + if ( strpos( $mime_type, 'audio/' ) === 0 ) { + return in_array( $mime_type, [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/x-wav', + 'audio/wave', + 'audio/ogg', + 'audio/opus', + 'audio/vorbis', + 'audio/aac', + 'audio/adts', + 'audio/flac', + 'audio/x-flac', + 'audio/mp4', + 'audio/x-m4a', + ], true ); + } + + return false; + } + + /** + * Mirrors JS getAttachmentSizeStatus. + * + * @param string $size + * @param array $attachment + * @return string|false + */ + private static function get_attachment_size_status( $size, $attachment ) { + if ( empty( $attachment['cimo'] ) || ! is_array( $attachment['cimo'] ) ) { + return false; + } + + $cimo = $attachment['cimo']; + if ( ! empty( $cimo['optimized_during_upload'] ) ) { + return 'optimized-on-upload'; + } + if ( empty( $cimo['bulk_optimization'] ) || ! is_array( $cimo['bulk_optimization'] ) ) { + return 'optimized-on-upload'; + } + + $bulk = $cimo['bulk_optimization']; + if ( empty( $bulk[ $size ] ) ) { + return false; + } + + $entry = $bulk[ $size ]; + $status = is_array( $entry ) ? ( $entry['status'] ?? null ) : $entry; + if ( $status === 'skip' ) { + return 'skipped'; + } + if ( $status === 'bulk' ) { + return 'bulk-optimized'; + } + + return false; + } } new Cimo_Bulk_Library(); diff --git a/src/admin/class-stats.php b/src/admin/class-stats.php index c382417..849f1c8 100644 --- a/src/admin/class-stats.php +++ b/src/admin/class-stats.php @@ -175,6 +175,55 @@ public static function format_bytes( $bytes, $decimals = 2 ) { return $value . ' ' . $sizes[ $i ]; } + /** + * Estimate additional bytes Premium bulk optimization could save. + * Same formula as the free settings upsell JS helper. + * + * @param int $unoptimized_count Unoptimized media units from bulk progress. + * @return int Estimated savings in bytes. + */ + public static function estimate_additional_savings_bytes( $unoptimized_count ) { + $stats = self::get_stats(); + $optimized = (int) ( $stats['media_optimized_num'] ?? 0 ); + $original_kb = (float) ( $stats['total_original_size'] ?? 0 ); + $optimized_kb = (float) ( $stats['total_optimized_size'] ?? 0 ); + $unoptimized = (int) $unoptimized_count; + + if ( $optimized <= 0 || $unoptimized <= 0 || $original_kb <= 0 ) { + return 0; + } + + $kb_saved = max( 0, $original_kb - $optimized_kb ); + $reduction = ( $kb_saved / $original_kb ) * 100; + if ( $reduction <= 0 ) { + return 0; + } + + $avg_original_kb = $original_kb / $optimized; + $unoptimized_original_kb = $avg_original_kb * $unoptimized; + $savings_kb = $unoptimized_original_kb * ( $reduction / 100 ); + + return (int) max( 0, round( $savings_kb * 1024 ) ); + } + + /** + * Human-readable additional savings estimate for free upsells, or empty string. + * + * @return string e.g. "12.4 MB" or "". + */ + public static function get_additional_savings_estimate_label() { + if ( ! class_exists( 'Cimo_Bulk_Library' ) ) { + return ''; + } + $progress = Cimo_Bulk_Library::count_progress_stats(); + $bytes = self::estimate_additional_savings_bytes( $progress['unoptimized'] ?? 0 ); + if ( $bytes <= 0 ) { + return ''; + } + // Match JS formatSavingsBytes default of 1 decimal. + return self::format_bytes( $bytes, 1 ); + } + /** * Update stats for when an attachment has been bulk optimized. * From d3b0c16890751dc693a8b826c9c6d1167df92c61 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 12:57:10 +0800 Subject: [PATCH 05/13] better rating notice --- src/admin/class-admin-notices.php | 150 ++++++++++++++++++++++++++++ src/admin/class-admin.php | 1 - src/admin/class-metadata.php | 9 ++ src/admin/class-stats.php | 50 ++++++++++ src/admin/css/admin-page.css | 64 +----------- src/admin/js/page/admin-settings.js | 79 --------------- 6 files changed, 211 insertions(+), 142 deletions(-) diff --git a/src/admin/class-admin-notices.php b/src/admin/class-admin-notices.php index dacf840..406d11d 100644 --- a/src/admin/class-admin-notices.php +++ b/src/admin/class-admin-notices.php @@ -10,7 +10,14 @@ if ( ! class_exists( 'Cimo_Admin_Notices' ) ) { class Cimo_Admin_Notices { + const RATING_SNOOZE_TRANSIENT = 'cimo_rating_snooze'; + const RATING_MIN_BYTES = 5242880; // 5 MB + public function __construct() { + add_action( 'admin_notices', [ $this, 'show_rating_notice' ] ); + add_action( 'wp_ajax_cimo_rating_snooze', [ $this, 'ajax_rating_snooze' ] ); + add_action( 'wp_ajax_cimo_rating_dismiss', [ $this, 'ajax_rating_dismiss' ] ); + if ( CIMO_BUILD === 'free' ) { add_action( 'admin_notices', [ $this, 'show_activation_notice' ] ); add_action( 'admin_notices', [ $this, 'show_library_premium_notice' ], 15 ); @@ -19,6 +26,149 @@ public function __construct() { } } + /** + * Site-wide ask for a WP.org review once the site has saved ≥ 5 MB. + */ + public function show_rating_notice() { + if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( '1' === get_option( 'cimo_rating_dismissed', '0' ) ) { + return; + } + + if ( get_transient( self::RATING_SNOOZE_TRANSIENT ) ) { + return; + } + + $bytes_saved = Cimo_Stats::get_bytes_saved(); + if ( $bytes_saved < self::RATING_MIN_BYTES ) { + return; + } + + $savings_label = Cimo_Stats::format_bytes( $bytes_saved, 1 ); + $review_url = 'https://wordpress.org/support/plugin/cimo-image-optimizer/reviews/#new-post'; + $nonce = wp_create_nonce( 'cimo_rating_notice' ); + ?> +
    +

    + ' . esc_html( $savings_label ) . '' + ); + ?> +

    +

    + + + +

    +
    + + + verify_rating_notice_request(); + set_transient( self::RATING_SNOOZE_TRANSIENT, 1, 30 * DAY_IN_SECONDS ); + wp_send_json_success(); + } + + /** + * Permanently dismiss the rating notice. + */ + public function ajax_rating_dismiss() { + $this->verify_rating_notice_request(); + update_option( 'cimo_rating_dismissed', '1', false ); + delete_transient( self::RATING_SNOOZE_TRANSIENT ); + wp_send_json_success(); + } + + /** + * Shared auth for rating notice AJAX actions. + */ + private function verify_rating_notice_request() { + $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'cimo_rating_notice' ) ) { + wp_die( esc_html__( 'Security check failed.', 'cimo-image-optimizer' ) ); + } + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'cimo-image-optimizer' ) ); + } + } + /** * Show the activation notice if it should be displayed. */ diff --git a/src/admin/class-admin.php b/src/admin/class-admin.php index 8eb6125..135893f 100644 --- a/src/admin/class-admin.php +++ b/src/admin/class-admin.php @@ -285,7 +285,6 @@ public function enqueue_admin_scripts( $hook ) { wp_localize_script( 'cimo-admin-page', 'cimoAdmin', [ 'stats' => $stats, 'imageSizes' => $formatted_sizes, - 'ratingDismissed' => '1' === get_option( 'cimo_rating_dismissed', '0' ) ? '1' : '0', 'isPremium' => CIMO_BUILD === 'premium', 'uploadsUrl' => wp_upload_dir()['baseurl'], ] ); diff --git a/src/admin/class-metadata.php b/src/admin/class-metadata.php index bb96be0..2de8a61 100644 --- a/src/admin/class-metadata.php +++ b/src/admin/class-metadata.php @@ -266,6 +266,15 @@ public function add_attachment_metadata( $attachment_id ) { // Update the attachment metadata. update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata ); + + // Keep running totals fresh for admin stats / rating notice (O(1), no scan). + if ( class_exists( 'Cimo_Stats' ) ) { + Cimo_Stats::update_stats_upload_optimized( + $attachment_id, + isset( $cimo_metadata['originalFilesize'] ) ? (int) $cimo_metadata['originalFilesize'] : 0, + isset( $cimo_metadata['convertedFilesize'] ) ? (int) $cimo_metadata['convertedFilesize'] : 0 + ); + } } /** diff --git a/src/admin/class-stats.php b/src/admin/class-stats.php index 849f1c8..45f2149 100644 --- a/src/admin/class-stats.php +++ b/src/admin/class-stats.php @@ -153,10 +153,28 @@ public static function get_formatted_stats() { 'percentage_saved' => $percentage_saved, 'compression_ratio' => $compression_ratio, 'total_storage_saved' => self::format_bytes( $bytes_saved ), + 'bytes_saved' => $bytes_saved, 'last_processed_post_id' => $stats['last_processed_post_id'] ?? 0, ]; } + /** + * Bytes saved across all optimized media (from stored stats option). + * Avoids a full recompute — suitable for admin-wide notice checks. + * + * @return int + */ + public static function get_bytes_saved() { + $stats = get_option( self::OPTION_KEY ); + if ( ! is_array( $stats ) ) { + return 0; + } + $kb_before = (float) ( $stats['total_original_size'] ?? 0 ); + $kb_after = (float) ( $stats['total_optimized_size'] ?? 0 ); + $kb_saved = max( 0, $kb_before - $kb_after ); + return (int) round( $kb_saved * 1024 ); + } + /** * Format bytes into human readable format */ @@ -224,6 +242,38 @@ public static function get_additional_savings_estimate_label() { return self::format_bytes( $bytes, 1 ); } + /** + * Update stats when an attachment is optimized on upload. + * Reads/writes the option only — does not run the metadata scan. + * + * @param int $attachment_id The attachment ID. + * @param int $original_size Original file size in bytes. + * @param int $optimized_size Optimized file size in bytes. + */ + public static function update_stats_upload_optimized( $attachment_id, $original_size, $optimized_size ) { + $stats = get_option( self::OPTION_KEY ); + if ( ! is_array( $stats ) ) { + $stats = [ + 'last_processed_post_id' => 0, + 'media_optimized_num' => 0, + 'total_original_size' => 0, + 'total_optimized_size' => 0, + ]; + } + + $stats['media_optimized_num'] = (int) ( $stats['media_optimized_num'] ?? 0 ) + 1; + $stats['total_original_size'] = (float) ( $stats['total_original_size'] ?? 0 ) + ( (int) $original_size / 1024 ); + $stats['total_optimized_size'] = (float) ( $stats['total_optimized_size'] ?? 0 ) + ( (int) $optimized_size / 1024 ); + + // Advance the cursor so a later incremental scan does not double-count this file. + $attachment_id = (int) $attachment_id; + if ( $attachment_id > (int) ( $stats['last_processed_post_id'] ?? 0 ) ) { + $stats['last_processed_post_id'] = $attachment_id; + } + + update_option( self::OPTION_KEY, $stats, false ); + } + /** * Update stats for when an attachment has been bulk optimized. * diff --git a/src/admin/css/admin-page.css b/src/admin/css/admin-page.css index 9b3adba..b4428d2 100644 --- a/src/admin/css/admin-page.css +++ b/src/admin/css/admin-page.css @@ -366,7 +366,7 @@ margin-right: 4px; } -.cimo-button, .cimo-save-button, .cimo-rating-buttons, .cimo-recommended-button { +.cimo-button, .cimo-save-button, .cimo-recommended-button { gap: 8px !important; border-radius: 6px; @@ -377,7 +377,7 @@ } } -.cimo-button:not(.is-secondary), .cimo-save-button, .cimo-rating-buttons { +.cimo-button:not(.is-secondary), .cimo-save-button { --wp-components-color-accent: #31BF43; --wp-components-color-accent-darker-10: #249433; --wp-components-color-accent-darker-20: #196a24; @@ -397,66 +397,6 @@ --wp-components-color-accent-darker-20: #982a2a; } -.cimo-rating-buttons { - display: flex; - gap: 16px; -} - -.cimo-rating-no-thanks { - --wp-components-color-accent: #666; - --wp-components-color-accent-darker-10: #555; - --wp-components-color-accent-darker-20: #333; -} - -.cimo-rating-notice { - background: #3c83f61a; - color: #23425e; - border-radius: 16px; - position: relative; - box-shadow: 0 4px 18px 0 #3c83f610; - border: 1px solid #4e80ac; /* dark blue border */ -} - -.cimo-rating-title { - margin-top: 0 !important; -} - -.cimo-rating-description { - margin-bottom: 2em; -} - -.cimo-rating-notice .cimo-rating-title, -.cimo-rating-notice .cimo-rating-description { - color: #23425e !important; -} - -.cimo-rating-notice .cimo-rating-buttons .cimo-rating-rate-now { - --wp-components-color-accent: #3c83f6; - --wp-components-color-accent-darker-10: #2163b0; - --wp-components-color-accent-darker-20: #153a64; - background: #3c83f6; - color: #fff; - border: none; - font-weight: 600; - border-radius: 6px; - gap: 8px; -} - -.cimo-rating-rate-now svg { - fill: none; - width: 18px; - height: 18px; -} - -.cimo-rating-notice .cimo-rating-buttons .cimo-rating-no-thanks { - background: transparent; - color: #3c83f6; - border: 1.5px solid #3c83f6; - font-weight: 600; - box-shadow: none !important; - border-radius: 6px; -} - .cimo-reset-button { --wp-components-color-accent: #555; align-self: flex-end; diff --git a/src/admin/js/page/admin-settings.js b/src/admin/js/page/admin-settings.js index 6012de8..b8a94b7 100644 --- a/src/admin/js/page/admin-settings.js +++ b/src/admin/js/page/admin-settings.js @@ -61,12 +61,6 @@ const AdminSettings = () => { const [ saveMessage, setSaveMessage ] = useState( '' ) const [ isLoading, setIsLoading ] = useState( true ) const [ hasUnsavedChanges, setHasUnsavedChanges ] = useState( false ) - const [ isRatingDismissed, setIsRatingDismissed ] = useState( () => { - if ( typeof window === 'undefined' ) { - return false - } - return window.cimoAdmin?.ratingDismissed === '1' - } ) // Load settings and image sizes on component mount useEffect( () => { @@ -277,23 +271,6 @@ const AdminSettings = () => { } ) } - const handleDismissRating = useCallback( async () => { - setIsRatingDismissed( true ) - - try { - await apiFetch( { - path: '/wp/v2/settings', - method: 'POST', - data: { - // eslint-disable-next-line camelcase - cimo_rating_dismissed: '1', - }, - } ) - } catch ( error ) { - setIsRatingDismissed( false ) - } - }, [] ) - const handleSubmit = async e => { e.preventDefault() setIsSaving( true ) @@ -408,62 +385,6 @@ const AdminSettings = () => {
- { ( () => { - const savedStr = window.cimoAdmin?.stats?.total_storage_saved - let showRating = false - if ( typeof savedStr === 'string' ) { - const match = savedStr.match( /^([\d.]+)\s*([a-zA-Z]+)/ ) - if ( match ) { - const num = parseFloat( match[ 1 ] ) - const unit = match[ 2 ].toUpperCase() - if ( unit === 'MB' && num > 5 ) { - showRating = true - } - } - } - - if ( showRating && ! isRatingDismissed ) { - return ( -
-
-

- { __( 'Loving the instant storage & server resource savings?', 'cimo-image-optimizer' ) } -

-

- { sprintf( - // translators: %s is replaced with the total storage saved (e.g. "1.5 GB") - __( "You've saved over %s! If Cimo is helping your site, please consider leaving us a 5-star rating and help others discover Cimo!", 'cimo-image-optimizer' ), - window.cimoAdmin.stats.total_storage_saved - ) } -

-
- - -
-
-
- ) - } - return null - } )() } -
From acb4c711cb584bd8bbac9a84451f4b92a5c484ca Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 13:06:59 +0800 Subject: [PATCH 06/13] updated upsell message --- src/admin/js/page/admin-settings.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/admin/js/page/admin-settings.js b/src/admin/js/page/admin-settings.js index b8a94b7..eb18518 100644 --- a/src/admin/js/page/admin-settings.js +++ b/src/admin/js/page/admin-settings.js @@ -1139,10 +1139,10 @@ const AdminSettings = () => {
-

{ __( 'Unlock Full Optimization', 'cimo-image-optimizer' ) }

+

{ __( 'Optimize beyond uploads', 'cimo-image-optimizer' ) }

- { __( 'Get even smaller file sizes, optimize existing media files, control user uploads, while keeping your server free from image processing.', 'cimo-image-optimizer' ) } + { __( 'New uploads are already covered. Premium bulk-optimizes your whole library, applies smarter compression, and more! Still in the browser, not on your server.', 'cimo-image-optimizer' ) }

{ target="_blank" rel="noopener noreferrer" > - { __( 'Unlock full optimization', 'cimo-image-optimizer' ) } + { __( 'Get Premium', 'cimo-image-optimizer' ) }
From ef6cf7d30d96d0dce21b57f31783910b79facdab Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 13:07:11 +0800 Subject: [PATCH 07/13] chore: added cursor rules --- .cursor/rules/cimo-project-repos.mdc | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .cursor/rules/cimo-project-repos.mdc diff --git a/.cursor/rules/cimo-project-repos.mdc b/.cursor/rules/cimo-project-repos.mdc new file mode 100644 index 0000000..e56ca41 --- /dev/null +++ b/.cursor/rules/cimo-project-repos.mdc @@ -0,0 +1,26 @@ +--- +description: Cimo project GitHub repos, roadmap, and free/premium structure +alwaysApply: true +--- + +# Cimo Project & Repositories + +## Release Roadmap + +The issues list, organized by targeted release version number, is the **Cimo Release Roadmap**: +https://github.com/orgs/gambitph/projects/12/views/1 + +## Repositories + +This project has two GitHub repositories, used depending on whether we're building the **free** or **premium** version of Cimo. + +### Free version + +- Main plugin repo: https://github.com/gambitph/Cimo + +### Premium version + +- Uses the free version as the main plugin repo, and additionally uses: +- Premium-only repo: https://github.com/bfintal/cimo-premium +- The premium repo contains **only** the premium plugin code. +- It is placed in the `pro__premium_only` directory inside the free plugin's root folder. From c2ad3d90d4961d07fc44b276555d75a2172c72e5 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 17 Jul 2026 22:34:32 +0800 Subject: [PATCH 08/13] added missing jsdocs --- .../js/bulk-optimizer/use-bulk-progress-stats.js | 4 ++-- src/shared/bulk-stats.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js b/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js index 4055be2..b576242 100644 --- a/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js +++ b/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js @@ -15,7 +15,7 @@ const BulkProgressStatsContext = createContext( null ) /** * @param {boolean} enabled When false, skips the network request. - * @return {{ isLoading: boolean, stats: typeof emptyStats }} + * @return {{ isLoading: boolean, stats: typeof emptyStats }} Loading flag and counted bulk progress stats. */ export function useBulkProgressStats( enabled = true ) { const [ isLoading, setIsLoading ] = useState( !! enabled ) @@ -77,7 +77,7 @@ export function BulkProgressStatsProvider( { } /** - * @return {{ isLoading: boolean, stats: typeof emptyStats }} + * @return {{ isLoading: boolean, stats: typeof emptyStats }} Shared context stats, or empty defaults outside the provider. */ export function useSharedBulkProgressStats() { const ctx = useContext( BulkProgressStatsContext ) diff --git a/src/shared/bulk-stats.js b/src/shared/bulk-stats.js index 84883a3..a5dd972 100644 --- a/src/shared/bulk-stats.js +++ b/src/shared/bulk-stats.js @@ -38,7 +38,7 @@ const BULK_AUDIO_MIME_TYPES = new Set( [ /** * @param {'audio'|'video'} tagName * @param {string} mimeType - * @return {boolean} + * @return {boolean} Whether the browser reports it can play this MIME type. */ function canBrowserPlayMediaType( tagName, mimeType ) { if ( ! mimeType || typeof document === 'undefined' ) { @@ -50,7 +50,7 @@ function canBrowserPlayMediaType( tagName, mimeType ) { /** * @param {string} file Path or URL. - * @return {string|null} + * @return {string|null} Lowercase extension, or null if none. */ export function getFileExtension( file ) { if ( ! file || typeof file !== 'string' || ! file.includes( '.' ) ) { @@ -64,7 +64,7 @@ export function getFileExtension( file ) { * Resolve attachment MIME type the same way Premium BulkCollection does. * * @param {Object} attachment - * @return {string} + * @return {string} Resolved MIME type, or empty string when unknown. */ export function resolveAttachmentMimeType( attachment ) { const ext = attachment?.file ? getFileExtension( attachment.file ) : null @@ -76,7 +76,7 @@ export function resolveAttachmentMimeType( attachment ) { * Matches Premium BulkCollection.supportsAttachmentMimeType (incl. HEIC + browser play checks). * * @param {string} mimeType - * @return {boolean} + * @return {boolean} True when the MIME type counts toward bulk progress stats. */ export function supportsBulkStatsMimeType( mimeType ) { if ( ! mimeType || typeof mimeType !== 'string' ) { @@ -104,7 +104,7 @@ export function supportsBulkStatsMimeType( mimeType ) { * * @param {string} size Size key ('full', 'thumbnail', …). * @param {Object} attachment Attachment from /cimo/v1/attachments. - * @return {string|false} + * @return {string|false} Status label, or false when still unoptimized. */ export function getAttachmentSizeStatus( size, attachment ) { if ( ! attachment?.cimo ) { @@ -138,7 +138,7 @@ export function getAttachmentSizeStatus( size, attachment ) { * * @param {Object} attachment * @param {Function} [supportsMimeType] (mimeType) => boolean — defaults to supportsBulkStatsMimeType - * @return {{ optimized: number, unoptimized: number, skipped: number }} + * @return {{ optimized: number, unoptimized: number, skipped: number }} Counts for this attachment. */ export function tallyAttachment( attachment, supportsMimeType = supportsBulkStatsMimeType ) { const stats = { @@ -182,7 +182,7 @@ export function tallyAttachment( attachment, supportsMimeType = supportsBulkStat * * @param {Object[]} attachments * @param {Function} [supportsMimeType] - * @return {{ optimized: number, unoptimized: number, skipped: number, total: number }} + * @return {{ optimized: number, unoptimized: number, skipped: number, total: number }} Aggregated bulk progress stats. */ export function countBulkProgressStats( attachments, supportsMimeType = supportsBulkStatsMimeType ) { const stats = { From 8baefa5a6ee9cbeebe0155a470b59f60b7f6a8a5 Mon Sep 17 00:00:00 2001 From: bfintal Date: Sat, 18 Jul 2026 12:35:09 +0800 Subject: [PATCH 09/13] fix: exit non-zero when plugin packaging fails So CI fails at the package step instead of continuing and failing later when dist/ is missing. Co-authored-by: Cursor --- scripts/package.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package.js b/scripts/package.js index 55ad591..ef9c666 100644 --- a/scripts/package.js +++ b/scripts/package.js @@ -389,4 +389,5 @@ async function packagePlugin() { packagePlugin().catch( err => { // eslint-disable-next-line no-console console.error( err ) + process.exit( 1 ) } ) From ec78bfb2cffb6e6913a367aacfb73e9f32ebe620 Mon Sep 17 00:00:00 2001 From: bfintal Date: Sat, 18 Jul 2026 13:15:12 +0800 Subject: [PATCH 10/13] update wording --- src/admin/class-admin-notices.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/admin/class-admin-notices.php b/src/admin/class-admin-notices.php index 406d11d..d982d18 100644 --- a/src/admin/class-admin-notices.php +++ b/src/admin/class-admin-notices.php @@ -303,7 +303,15 @@ public function show_library_premium_notice() { ?>

- + + +

Date: Sat, 18 Jul 2026 13:15:27 +0800 Subject: [PATCH 11/13] faster endpoint for bulk metrics --- src/admin/class-bulk-library.php | 32 ++++++++++++++++--- .../bulk-optimizer/use-bulk-progress-stats.js | 29 +++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/admin/class-bulk-library.php b/src/admin/class-bulk-library.php index 63a101c..ef74f7c 100644 --- a/src/admin/class-bulk-library.php +++ b/src/admin/class-bulk-library.php @@ -2,7 +2,7 @@ /** * Shared bulk Media Library helpers (free + premium). * - * Provides the attachment list used for bulk progress stats ("X of Y optimized"). + * Provides the attachment list and aggregated bulk progress stats ("X of Y optimized"). */ // Exit if accessed directly. @@ -20,15 +20,30 @@ public function __construct() { * Register REST routes shared by free and premium. */ public function register_rest_routes() { + $permission_callback = [ $this, 'rest_permission_callback' ]; + register_rest_route( 'cimo/v1', '/attachments', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_get_all_attachments' ], - 'permission_callback' => function() { - return current_user_can( 'upload_files' ) && current_user_can( 'edit_posts' ) && current_user_can( 'edit_others_posts' ); - }, + 'permission_callback' => $permission_callback, + ] ); + + register_rest_route( 'cimo/v1', '/bulk-progress', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_get_bulk_progress' ], + 'permission_callback' => $permission_callback, ] ); } + /** + * Whether the current user may access bulk library REST routes. + * + * @return bool + */ + public function rest_permission_callback() { + return current_user_can( 'upload_files' ) && current_user_can( 'edit_posts' ) && current_user_can( 'edit_others_posts' ); + } + /** * REST: all bulk-optimizable attachments. * @@ -38,6 +53,15 @@ public function rest_get_all_attachments() { return rest_ensure_response( self::get_all_attachments() ); } + /** + * REST: aggregated bulk progress counts for free UI upsells. + * + * @return WP_REST_Response + */ + public function rest_get_bulk_progress() { + return rest_ensure_response( self::count_progress_stats() ); + } + /** * Get all image, video, and audio attachments with metadata. * Same dataset Premium Bulk Optimization uses for progress stats. diff --git a/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js b/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js index b576242..2b68d43 100644 --- a/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js +++ b/src/admin/js/bulk-optimizer/use-bulk-progress-stats.js @@ -1,11 +1,10 @@ /** - * Fetch /cimo/v1/attachments and count bulk progress stats (free + shared). + * Fetch /cimo/v1/bulk-progress for free admin upsell stats. */ import { createContext, useContext, useEffect, useMemo, useState, } from '@wordpress/element' import apiFetch from '@wordpress/api-fetch' -import { countBulkProgressStats } from '~cimo/shared/bulk-stats' const emptyStats = { optimized: 0, unoptimized: 0, skipped: 0, total: 0, @@ -13,9 +12,27 @@ const emptyStats = { const BulkProgressStatsContext = createContext( null ) +/** + * Normalize a bulk-progress REST payload into stats, or emptyStats on bad data. + * + * @param {*} data + * @return {typeof emptyStats} Stats object. + */ +function normalizeBulkProgressStats( data ) { + if ( ! data || typeof data !== 'object' ) { + return emptyStats + } + return { + optimized: Number( data.optimized ) || 0, + unoptimized: Number( data.unoptimized ) || 0, + skipped: Number( data.skipped ) || 0, + total: Number( data.total ) || 0, + } +} + /** * @param {boolean} enabled When false, skips the network request. - * @return {{ isLoading: boolean, stats: typeof emptyStats }} Loading flag and counted bulk progress stats. + * @return {{ isLoading: boolean, stats: typeof emptyStats }} Loading flag and bulk progress stats. */ export function useBulkProgressStats( enabled = true ) { const [ isLoading, setIsLoading ] = useState( !! enabled ) @@ -31,10 +48,10 @@ export function useBulkProgressStats( enabled = true ) { let cancelled = false setIsLoading( true ) - apiFetch( { path: '/cimo/v1/attachments' } ) + apiFetch( { path: '/cimo/v1/bulk-progress' } ) .then( data => { if ( ! cancelled ) { - setStats( countBulkProgressStats( data ) ) + setStats( normalizeBulkProgressStats( data ) ) } } ) .catch( () => { @@ -59,7 +76,7 @@ export function useBulkProgressStats( enabled = true ) { } /** - * Provides one shared attachments fetch for free admin upsells. + * Provides one shared bulk-progress fetch for free admin upsells. * * @param {Object} props * @param {boolean} [props.enabled=true] From 76a844c17a0b91e6eb46e028a7c8be5799c3424b Mon Sep 17 00:00:00 2001 From: bfintal Date: Sat, 18 Jul 2026 13:26:24 +0800 Subject: [PATCH 12/13] added better estimation of savings in premium --- src/admin/class-stats.php | 9 ++++++++- src/shared/estimate-additional-savings.js | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/admin/class-stats.php b/src/admin/class-stats.php index 45f2149..f0c57a1 100644 --- a/src/admin/class-stats.php +++ b/src/admin/class-stats.php @@ -197,6 +197,10 @@ public static function format_bytes( $bytes, $decimals = 2 ) { * Estimate additional bytes Premium bulk optimization could save. * Same formula as the free settings upsell JS helper. * + * Includes a 1.3x uplift: bulk also optimizes intermediate sizes + * (thumbnail, medium, large, etc.), which typically add ~30% disk + * on top of the full-size original. + * * @param int $unoptimized_count Unoptimized media units from bulk progress. * @return int Estimated savings in bytes. */ @@ -217,9 +221,12 @@ public static function estimate_additional_savings_bytes( $unoptimized_count ) { return 0; } + // Intermediate sizes typically add ~30% disk vs full size alone. + $size_variants_factor = 1.3; + $avg_original_kb = $original_kb / $optimized; $unoptimized_original_kb = $avg_original_kb * $unoptimized; - $savings_kb = $unoptimized_original_kb * ( $reduction / 100 ); + $savings_kb = $unoptimized_original_kb * ( $reduction / 100 ) * $size_variants_factor; return (int) max( 0, round( $savings_kb * 1024 ) ); } diff --git a/src/shared/estimate-additional-savings.js b/src/shared/estimate-additional-savings.js index 0fa7d3c..77decf6 100644 --- a/src/shared/estimate-additional-savings.js +++ b/src/shared/estimate-additional-savings.js @@ -4,9 +4,17 @@ * Formula: * avgOriginal = totalOriginalSize / mediaOptimizedCount * unoptimizedOriginal = avgOriginal * unoptimizedCount - * savings = unoptimizedOriginal * (percentageSaved / 100) + * savings = unoptimizedOriginal * (percentageSaved / 100) * SIZE_VARIANTS_FACTOR + * + * SIZE_VARIANTS_FACTOR (1.3): bulk also optimizes intermediate sizes + * (thumbnail, medium, large, etc.), which typically add ~30% disk on top + * of the full-size original. Applied as a simple uplift rather than + * weighting each size by real filesize. */ +/** Intermediate sizes typically add ~30% disk vs full size alone. */ +const SIZE_VARIANTS_FACTOR = 1.3 + /** * @param {Object} params * @param {number} params.percentageSaved e.g. 42.5 for 42.5% reduction @@ -32,7 +40,7 @@ export function estimateAdditionalSavingsBytes( { const avgOriginalKb = originalKb / optimized const unoptimizedOriginalKb = avgOriginalKb * unoptimized - const savingsKb = unoptimizedOriginalKb * ( reduction / 100 ) + const savingsKb = unoptimizedOriginalKb * ( reduction / 100 ) * SIZE_VARIANTS_FACTOR return Math.max( 0, Math.round( savingsKb * 1024 ) ) } From 8d465bd1594a6221e9dd4efac900df5cb9f7f3d4 Mon Sep 17 00:00:00 2001 From: Benjamin Intal Date: Tue, 4 Aug 2026 12:27:57 +0800 Subject: [PATCH 13/13] Update src/admin/class-stats.php Co-authored-by: Alquen Antonio Sarmiento --- src/admin/class-stats.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/admin/class-stats.php b/src/admin/class-stats.php index f0c57a1..9b4222e 100644 --- a/src/admin/class-stats.php +++ b/src/admin/class-stats.php @@ -259,14 +259,10 @@ public static function get_additional_savings_estimate_label() { */ public static function update_stats_upload_optimized( $attachment_id, $original_size, $optimized_size ) { $stats = get_option( self::OPTION_KEY ); - if ( ! is_array( $stats ) ) { - $stats = [ - 'last_processed_post_id' => 0, - 'media_optimized_num' => 0, - 'total_original_size' => 0, - 'total_optimized_size' => 0, - ]; - } + if ( ! is_array( $stats ) || empty( $stats['last_processed_post_id'] ) ) { + self::get_stats(); + return; + } $stats['media_optimized_num'] = (int) ( $stats['media_optimized_num'] ?? 0 ) + 1; $stats['total_original_size'] = (float) ( $stats['total_original_size'] ?? 0 ) + ( (int) $original_size / 1024 );