diff --git a/src/admin/css/index.css b/src/admin/css/index.css index 80fc777..de3ade2 100644 --- a/src/admin/css/index.css +++ b/src/admin/css/index.css @@ -56,6 +56,20 @@ } } +.cimo-media-upload-notice { + float: left; + clear: both; + box-sizing: border-box; + width: 100%; + margin: 1.5em 0 0; + padding: 10px 12px; + border-left: 4px solid #dba617; + background: #fff8e5; + color: #3c434a; + font-size: 13px; + line-height: 1.45; +} + .cimo-compression-savings { font-size: 21px; font-weight: 700; @@ -202,4 +216,4 @@ .cimo-optimization-toggle-container:hover { background: rgba(0, 0, 0, 0.7); -} \ No newline at end of file +} diff --git a/src/admin/js/media-manager/drop-zone.js b/src/admin/js/media-manager/drop-zone.js index 5ab858b..deee381 100644 --- a/src/admin/js/media-manager/drop-zone.js +++ b/src/admin/js/media-manager/drop-zone.js @@ -9,6 +9,7 @@ import { domReady } from '~cimo/shared/dom-ready' import { getFileConverter, requiresFileConversion } from '~cimo/shared/converters' import { watchForEditorIframe } from '~cimo/shared/util' import { saveMetadata } from '~cimo/shared/metadata-saver' +import { cacheConverterNotice } from '~cimo/shared/upload-notice-cache' import { ProgressModal } from './progress-modal' import { applyFilters } from '@wordpress/hooks' @@ -114,6 +115,7 @@ function addDropZoneListenerToMediaManager( targetDocument ) { fileConverters.map( async converter => { try { const result = await converter.optimize() + cacheConverterNotice( result ) if ( result.error ) { // eslint-disable-next-line no-console console.warn( result.error ) diff --git a/src/admin/js/media-manager/select-files.js b/src/admin/js/media-manager/select-files.js index 86ec2c0..cf5db4e 100644 --- a/src/admin/js/media-manager/select-files.js +++ b/src/admin/js/media-manager/select-files.js @@ -9,6 +9,7 @@ import { domReady } from '~cimo/shared/dom-ready' import { getFileConverter, requiresFileConversion } from '~cimo/shared/converters' import { watchForEditorIframe } from '~cimo/shared/util' import { saveMetadata } from '~cimo/shared/metadata-saver' +import { cacheConverterNotice } from '~cimo/shared/upload-notice-cache' import { ProgressModal } from './progress-modal' import { applyFilters } from '@wordpress/hooks' @@ -92,6 +93,7 @@ function addSelectFilesListenerToFileUploads( targetDocument ) { fileConverters.map( async converter => { try { const result = await converter.optimize() + cacheConverterNotice( result ) if ( result.error ) { // eslint-disable-next-line no-console console.warn( result.error ) diff --git a/src/admin/js/media-manager/sidebar-info.js b/src/admin/js/media-manager/sidebar-info.js index b7a3ba7..b414fd0 100644 --- a/src/admin/js/media-manager/sidebar-info.js +++ b/src/admin/js/media-manager/sidebar-info.js @@ -1,6 +1,7 @@ import { domReady } from '~cimo/shared/dom-ready' import { getCachedMetadata } from '~cimo/shared/metadata-saver' import { buildPricingUrl } from '~cimo/shared/pricing-url' +import { getCachedUploadNotice } from '~cimo/shared/upload-notice-cache' import { escape } from '~cimo/shared/util' import { __, sprintf } from '@wordpress/i18n' import { applyFilters } from '@wordpress/hooks' @@ -64,6 +65,39 @@ function getMediaTypeLabel( mimetype ) { } } +/** + * Inject a temporary upload notice into the Media Library sidebar. + * + * @param {Object} options - Injection options. + * @param {Object} options.model - WordPress media attachment model. + * @param {Element} options.container - Sidebar container where the notice should appear. + */ +function injectCimoUploadNotice( { + model, + container, +} ) { + if ( ! model || ! container ) { + return + } + + if ( container.querySelector( '.cimo-media-upload-notice' ) ) { + return + } + + // Ask the temporary cache for a notice that belongs to this attachment filename. + const notice = getCachedUploadNotice( model.get( 'filename' ) ) + + if ( ! notice?.message ) { + return + } + + // Append plain text only; the notice message comes from a converter result error. + const noticeElement = document.createElement( 'div' ) + noticeElement.className = 'cimo-media-upload-notice' + noticeElement.textContent = notice.message + container.appendChild( noticeElement ) +} + function injectCimoMetadata( { model, container, @@ -285,6 +319,11 @@ domReady( () => { const container = dom.querySelector( '.attachment-info' ) + injectCimoUploadNotice( { + model: view.model, + container, + } ) + injectCimoMetadata( { model: view.model, container, @@ -307,6 +346,11 @@ domReady( () => { '.attachment-info > .details' ) + injectCimoUploadNotice( { + model: this.model, + container, + } ) + injectCimoMetadata( { model: this.model, container, diff --git a/src/shared/converters/image-converter.js b/src/shared/converters/image-converter.js index f6fab07..45d5a3f 100644 --- a/src/shared/converters/image-converter.js +++ b/src/shared/converters/image-converter.js @@ -332,6 +332,7 @@ class ImageConverter extends Converter { metadata: null, reason: 'resulting-media-bigger-than-input', error: `Resulting image is bigger than the input (input: ${ file.size } bytes, output: ${ convertedBlob.size } bytes), skipping conversion.`, + notice: __( 'Media is not resized because the uploaded media is already optimized.', 'cimo-image-optimizer' ), } } diff --git a/src/shared/upload-notice-cache.js b/src/shared/upload-notice-cache.js new file mode 100644 index 0000000..9e76641 --- /dev/null +++ b/src/shared/upload-notice-cache.js @@ -0,0 +1,130 @@ + +/** + * This script is in charge of keeping track of temporary upload notices for + * media conversion results, then matching those notices back to the Media + * Library attachment after WordPress finishes uploading and renaming the file. + * The cache is kept in memory only, so refreshing the page clears all notices. + */ + +/** + * Create the cache key used to match an upload notice to a Media Library item. + * + * @param {string} filename - baseFile or WordPress-generated filename. + * @return {string|undefined} Sanitized cache key. + */ +const getCacheKey = filename => filename + // Match WordPress filename sanitization to ensure consistent cache keys. + ?.replace( /[^a-zA-Z0-9._-]+/g, '-' ) + // Collapse repeated dashes created by the previous replacement. + .replace( /-+/g, '-' ) + // Remove leading/trailing dashes around the filename. + .replace( /^-|-$/g, '' ) + .toLowerCase() + +/** + * Remove the numeric suffix WordPress adds when a filename already exists. + * + * @param {string} filename - WordPress-generated filename. + * @return {string|undefined} Filename without the final WordPress suffix. + */ +const stripFilenameSuffix = filename => { + // Convert `video-3.mov` back to `video.mov` for one-time self-correction. + return filename?.replace( /-\d+(\.[^.]+)$/, '$1' ) +} + +/** + * Find a temporary upload notice for a Media Library attachment. + * + * @param {string} filename - Filename from the media model. + * @return {Object|null} Matching upload notice, if any. + */ +export const getCachedUploadNotice = filename => { + if ( ! filename ) { + return null + } + + const cache = window.cimoUploadNoticeCache || {} + + // Get the filename which may have a suffix, and try to find a notice for it first. + const cacheKey = getCacheKey( filename ) + if ( cache[ cacheKey ] ) { + return cache[ cacheKey ] + } + + // Extract the base filename without the suffix and check if there's a notice for it. + // If no cache with the base filename exists, then there's no notice for this upload at all. + const baseFileKey = getCacheKey( stripFilenameSuffix( filename ) ) + const baseFileNotice = cache[ baseFileKey ] + if ( ! baseFileNotice ) { + return null + } + + // Copy the notice to WordPress's final filename key. + // Also remove remainingMatches from the cache since it's only needed for the base filename to track when to remove itself. + cache[ cacheKey ] = { + ...baseFileNotice, + } + delete cache[ cacheKey ].remainingMatches + + // Keep the baseFile key only while more suffixed filenames still need it. + if ( baseFileNotice.remainingMatches > 1 ) { + baseFileNotice.remainingMatches-- + } else { + // Remove the base fallback so older similar media cannot keep matching it. + delete cache[ baseFileKey ] + } + + return cache[ cacheKey ] +} + +/** + * Cache a temporary upload notice message under a sanitized filename key. + * + * @param {string} filename - Browser File.name from the failed upload. + * @param {string} message - Notice message to display in the media manager. + */ +export const setCachedUploadNotice = ( filename, message ) => { + if ( ! filename ) { + return + } + + // Keep notices in memory only; refreshing the page clears them. + if ( ! window.cimoUploadNoticeCache ) { + window.cimoUploadNoticeCache = {} + } + + // Generate the cache key based on the filename, matching WordPress's sanitization. + // This will always generate the basename, and is not guaranteed to match the final WordPress filename, + // which may have a suffix added (`-1` or `-2`.). + const baseFileKey = getCacheKey( filename ) + const cachedNotice = window.cimoUploadNoticeCache[ baseFileKey ] + + window.cimoUploadNoticeCache[ baseFileKey ] = { + message, + // Count duplicate uploads with the same browser filename so + // each suffixed item can self-correct once. + remainingMatches: ( cachedNotice?.remainingMatches || 0 ) + 1, + } +} + +/** + * Create and cache an upload notice from a converter result. + * + * @param {Object} result - Converter result. + * @return {string|null} Cached notice message when the result includes one. + */ +export const cacheConverterNotice = result => { + const file = result?.file + if ( ! file?.name ) { + return null + } + + // The converter result decides whether a sidebar notice should be shown. + if ( ! result?.notice ) { + return null + } + + // Store only the display message; matching metadata is added by the cache layer. + setCachedUploadNotice( file.name, result.notice ) + return result.notice +}