diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..7d08e22 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,13 @@ +{ + "extends": [ "plugin:@wordpress/eslint-plugin/recommended" ], + "env": { + "browser": true + }, + "rules": { + "import/no-extraneous-dependencies": [ "off" ], + "jsdoc/no-undefined-types": [ "off" ], + "jsdoc/check-line-alignment": [ "warn" ], + "jsdoc/check-tag-names": [ "warn" ], + "prettier/prettier": [ "off" ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c4e984..46882a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,6 @@ jobs: uses: ./.github/workflows/node.yml secrets: inherit - php: - uses: ./.github/workflows/php.yml - secrets: inherit + # php: + # uses: ./.github/workflows/php.yml + # secrets: inherit diff --git a/altis-media-weight.php b/altis-media-weight.php index 4994e11..69d9321 100644 --- a/altis-media-weight.php +++ b/altis-media-weight.php @@ -16,74 +16,8 @@ exit; // Exit if accessed directly. } -/** - * Connect namespace functions to actions and hooks. - */ -function bootstrap() : void { - add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\\register_block_plugin_editor_scripts' ); - add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\\maybe_warn_on_script_debug_mode' ); -} -// Initialize plugin. -bootstrap(); - -/** - * Registers the block plugin script bundle. - */ -function register_block_plugin_editor_scripts() { - $asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php'); - - if ( $asset_file === false ) { - return; - } +require_once __DIR__ . '/inc/namespace.php'; +require_once __DIR__ . '/inc/assets.php'; - wp_enqueue_script( - 'hm-media-weight', - plugins_url( 'build/index.js', __FILE__ ), - $asset_file['dependencies'], - $asset_file['version'] - ); - - wp_localize_script( - 'hm-media-weight', - 'mediaWeightData', - [ - 'mediaThreshold' => apply_filters( 'altis_media_weight_threshold', 2.50 ), - ] - ); -} - -/** - * Show a warning if SCRIPT_DEBUG is off while we're running the dev server. - */ -function maybe_warn_on_script_debug_mode() { - // Only render this notice in the post editor. - if ( ( get_current_screen()->base ?? '' ) !== 'post' ) { - return; - } - - $asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php'); - - if ( ! in_array( 'wp-react-refresh-runtime', $asset_file['dependencies'] ?? [], true ) ) { - // Either not in hot-reload mode, or plugin isn't currently built. - return; - } - - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - // SCRIPT_DEBUG configured correctly. - return; - } - - ob_start(); - ?> - wp.domReady( () => { - wp.data.dispatch( 'core/notices' ).createNotice( - 'warning', - "", - { - isDismissible: false, - } - ); - } ); - apply_filters( 'hm_media_weight_threshold', 2.50 ), + /** + * Filter the expected maximum width (in pixels) for a desktop featured image. + */ + 'featuredImageSize' => apply_filters( 'hm_media_weight_featured_image_size_slug', 'large' ), + ] + ); +} + +/** + * Show a warning if SCRIPT_DEBUG is off while we're running the dev server. + */ +function maybe_warn_on_script_debug_mode() { + if ( wp_get_environment_type() !== 'local' ) { + return; + } + + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { + // SCRIPT_DEBUG configured correctly. + return; + } + + // Only render this notice in the post editor. + if ( ( get_current_screen()->base ?? '' ) !== 'post' ) { + return; + } + + $asset_file = include( plugin_dir_path( __DIR__ ) . 'build/index.asset.php'); + + if ( ! in_array( 'wp-react-refresh-runtime', $asset_file['dependencies'] ?? [], true ) ) { + // Either not in hot-reload mode, or plugin isn't currently built. + return; + } + + ob_start(); + ?> + wp.domReady( () => { + wp.data.dispatch( 'core/notices' ).createNotice( + 'warning', + "", + { + isDismissible: false, + } + ); + } ); + 'object', + 'description' => 'File sizes for all intermediate image sizes of an attachment.', + 'single' => true, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'integer', + ], + ], + ], + 'auth_callback' => function() { + return current_user_can( 'edit_posts' ); + }, + ] ); +} + +/** + * Schedules a cron job to check file sizes for the uploaded attachment. + * + * @param int $attachment_id The ID of the uploaded attachment. + */ +function schedule_file_size_check( $attachment_id ) { + if ( ! in_array( get_post_mime_type( $attachment_id ), [ 'image/jpeg', 'image/png', 'image/gif' ], true ) ) { + return; + } + + if ( ! wp_next_scheduled( ATTACHMENT_SIZE_CRON_ID, [ $attachment_id ] ) ) { + // Ensure the cron job is scheduled for later as fallback. + wp_schedule_single_event( time(), ATTACHMENT_SIZE_CRON_ID, [ $attachment_id ] ); + } +} + +/** + * Logs the file sizes for each image size of the uploaded attachment. + * + * @param int $attachment_id The ID of the uploaded attachment. + */ +function store_intermediate_file_sizes( $attachment_id ) { + $image_sizes = get_intermediate_image_sizes(); + $file_sizes = []; + + foreach ( $image_sizes as $size ) { + $image_url = wp_get_attachment_image_url( $attachment_id, $size ); + + if ( ! $image_url ) { + continue; + } + + $response = wp_remote_head( $image_url ); + + if ( is_wp_error( $response ) ) { + continue; + } + + $headers = wp_remote_retrieve_headers( $response ); + if ( isset( $headers['content-length'] ) ) { + $file_sizes[ $size ] = (int) $headers['content-length']; + } + } + + // Save the file sizes to the attachment meta. + update_post_meta( $attachment_id, ATTACHMENT_SIZE_META_KEY, $file_sizes ); +} + +/** + * Retrieves the file sizes for an attachment. + * + * @param int $attachment_id The ID of the attachment. + * @return array|null The array of file sizes keyed by URL, or null if not available. + */ +function get_file_sizes( $attachment_id ) { + return get_post_meta( $attachment_id, ATTACHMENT_SIZE_META_KEY, true ) ?: []; +} + +/** + * Conditionally request and fill in missing file size meta before fulfilling + * an edit-context REST request for an image. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post The original attachment post. + * @param WP_REST_Request $request Request used to generate the response. + * @return WP_REST_Response The filtered response. + */ +function lookup_file_sizes_as_needed_during_rest_response( $response, $post, $request ) { + if ( $request->get_param( 'context' ) !== 'edit' ) { + return $response; + } + + if ( ! empty( $response->data['meta'][ ATTACHMENT_SIZE_META_KEY ] ?? null ) ) { + return $response; + } + + $meta_field_included = rest_is_field_included( + 'meta.' . ATTACHMENT_SIZE_META_KEY, + get_post_type_object( 'attachment' )->get_rest_controller()->get_fields_for_response( $request ) ?? [] + ); + if ( ! $meta_field_included ) { + return $response; + } + + // Update post meta and then append the stored values to the response. + store_intermediate_file_sizes( $post->ID ); + $response->data['meta'][ ATTACHMENT_SIZE_META_KEY ] = get_file_sizes( $post->ID ); + + // Ensure we consistently return object-shaped JSON, not `[]` for empty. + if ( empty( $response->data['meta'][ ATTACHMENT_SIZE_META_KEY ] ) && isset( $response->data['meta'][ ATTACHMENT_SIZE_META_KEY ] ) ) { + $response->data['meta'][ ATTACHMENT_SIZE_META_KEY ] = (object) []; + } + + return $response; +} diff --git a/src/assets/scale-icon.svg b/src/assets/scale-icon.svg new file mode 100644 index 0000000..b0055e4 --- /dev/null +++ b/src/assets/scale-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/edit.js b/src/edit.js deleted file mode 100644 index a127175..0000000 --- a/src/edit.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Retrieves the translation of text. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/ - */ -import { __ } from '@wordpress/i18n'; - -/** - * React hook that is used to mark the block wrapper element. - * It provides all the necessary props like the class name. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops - */ -import { useBlockProps } from '@wordpress/block-editor'; - -/** - * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. - * Those files can contain any CSS code that gets applied to the editor. - * - * @see https://www.npmjs.com/package/@wordpress/scripts#using-css - */ -import './editor.scss'; - -/** - * The edit function describes the structure of your block in the context of the - * editor. This represents what the editor will render when the block is used. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit - * - * @return {Element} Element to render. - */ -export default function Edit() { - return ( -

- { __( - 'HM Media Weight – hello from the editor!', - 'hm-media-weight' - ) } -

- ); -} diff --git a/src/index.js b/src/index.js index 116649b..516c30d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { __, sprintf } from '@wordpress/i18n'; -import { media } from '@wordpress/icons'; import { PluginSidebar, PluginSidebarMoreMenuItem } from '@wordpress/editor'; import { PanelRow, PanelBody, Button } from '@wordpress/components'; import { registerPlugin, unregisterPlugin } from '@wordpress/plugins'; @@ -8,10 +7,14 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { useEntityRecords } from '@wordpress/core-data'; -const { mediaThreshold } = window.mediaWeightData; +import { ReactComponent as ScalesIcon } from './assets/scale-icon.svg'; + +const { mediaThreshold, featuredImageSize } = window.mediaWeightData; const PLUGIN_NAME = 'hm-media-weight'; const SIDEBAR_NAME = PLUGIN_NAME; +const MB_IN_B = 1024 * 1024; +const KB_IN_B = 1024; const getMediaBlocks = ( blocks ) => blocks.reduce( ( mediaBlocks, block ) => { @@ -27,14 +30,15 @@ const getMediaBlocks = ( blocks ) => blocks.reduce( ); const useMediaBlocks = () => { - const blocks = useSelect( ( select ) => select( blockEditorStore ).getBlocks() ); + const mediaBlocks = useSelect( ( select ) => getMediaBlocks( select( blockEditorStore ).getBlocks() ) ); const featuredImageId = useSelect( ( select ) => select( 'core/editor' ).getEditedPostAttribute( 'featured_media' ) ); + + /* eslint-disable no-shadow */ const { imageIds, videoIds, blocksByAttributeId } = useMemo( () => { - const mediaBlocks = getMediaBlocks( blocks ); const imageIds = []; const videoIds = []; const blocksByAttributeId = {}; - for ( let block of mediaBlocks ) { + for ( const block of mediaBlocks ) { if ( ! block.attributes?.id ) { continue; } @@ -49,7 +53,9 @@ const useMediaBlocks = () => { imageIds.push( featuredImageId ); } return { imageIds, videoIds, blocksByAttributeId }; - }, [ blocks, featuredImageId ] ); + }, [ mediaBlocks, featuredImageId ] ); + /* eslint-enable no-shadow */ + const imageRecords = useEntityRecords( 'postType', 'attachment', { per_page: imageIds.length, include: imageIds, @@ -58,20 +64,23 @@ const useMediaBlocks = () => { per_page: videoIds.length, include: videoIds, } )?.records || []; + return { attachments: imageRecords.concat( videoRecords ), featuredImageId, blocksByAttributeId, + mediaBlocks, imageCount: imageIds.length, videoCount: videoIds.length, }; }; -const HMMediaWeightSidebar = ( ...args ) => { +const HMMediaWeightSidebar = () => { const { attachments, featuredImageId, blocksByAttributeId, + mediaBlocks, imageCount, videoCount } = useMediaBlocks(); @@ -79,8 +88,9 @@ const HMMediaWeightSidebar = ( ...args ) => { let imagesSize = 0; let videosSize = 0; + // eslint-disable-next-line no-shadow const DisplayTotal = ( { imagesSize, videosSize } ) => { - const total = ( imagesSize + videosSize ).toFixed( 2 ); + const total = ( ( imagesSize + videosSize ) / MB_IN_B ).toFixed( 2 ); let sizeColor; if ( total >= 0 && total <= ( mediaThreshold / 2 ) ) { @@ -94,6 +104,7 @@ const HMMediaWeightSidebar = ( ...args ) => { const warningMsg = total >= mediaThreshold ? (

{ sprintf( + /* translators: %f: Maximum allowed size (in megabytes) for all media on page. */ __( 'Warning! The media in this page exceeds the recommended threshold of %fmb', 'hm-media-weight' ), mediaThreshold ) } @@ -102,8 +113,6 @@ const HMMediaWeightSidebar = ( ...args ) => { return ( <> -

{ __( 'Images total', 'hm-media-weight' ) }: { imagesSize.toFixed( 2 ) }mb

-

{ __( 'Videos total', 'hm-media-weight' ) }: { videosSize.toFixed( 2 ) }mb

{ __( 'Total media size', 'hm-media-weight' ) }: { ' ' } @@ -119,11 +128,53 @@ const HMMediaWeightSidebar = ( ...args ) => {

+

{ __( 'Images total', 'hm-media-weight' ) }: { ( imagesSize / MB_IN_B ).toFixed( 2 ) }mb

+

{ __( 'Videos total', 'hm-media-weight' ) }: { ( videosSize / MB_IN_B ).toFixed( 2 ) }mb

{ warningMsg } ); } + const attachmentSizeDetails = attachments.map( ( attachment ) => { + const associatedBlockClientId = blocksByAttributeId[ attachment.id ]; + const blockButton = attachment.id !== featuredImageId ? ( + ) : ''; + + let type = attachment.media_type === 'image' ? __( 'Image', 'hm-media-weight' ) : __( 'Video', 'hm-media-weight' ); + if ( attachment.id === featuredImageId ) { + type = __( 'Featured image', 'hm-media-weight' ); + } + let mediaSize = attachment.media_details.filesize; + + if ( attachment.media_type === 'image' ) { + const requestedSize = attachment.id !== featuredImageId + ? mediaBlocks.find( ( block ) => block.clientId === associatedBlockClientId )?.attributes?.sizeSlug + : ( featuredImageSize || 'full' ); + // Swap in the actual measured size of the target image, if available. + mediaSize = attachment.meta?.intermediate_image_filesizes?.[ requestedSize ] || mediaSize; + imagesSize = imagesSize + mediaSize; + } else { + videosSize = videosSize + mediaSize; + } + + const thumbnail = attachment.media_type === 'image' + ? ( attachment?.media_details?.sizes?.thumbnail?.source_url || attachment.source_url ) + : null; + + return { + attachment, + thumbnail, + type, + mediaSize, + blockButton + }; + } ); + return ( <> @@ -131,51 +182,48 @@ const HMMediaWeightSidebar = ( ...args ) => {

Images: { imageCount }

Videos: { videoCount }

+ +
- { attachments.map( ( attachment ) => { - const blockButton = attachment.id !== featuredImageId ? ( - ) : ''; - - let type = attachment.media_type === 'image' ? __( 'Image', 'hm-media-weight' ) : __( 'Video', 'hm-media-weight' ); - if ( attachment.id === featuredImageId ) { - type = __( 'Featured image', 'hm-media-weight' ); - } - const mediaSize = attachment.media_details.filesize / 1000000; - - if ( attachment.media_type === 'image' ) { - imagesSize = imagesSize + mediaSize; - } else { - videosSize = videosSize + mediaSize; - } + { attachmentSizeDetails.map( ( { attachment, thumbnail, type, mediaSize, blockButton } ) => { return (
+ { thumbnail ? ( + + ) : null }

- { type }: { mediaSize.toFixed( 2 ) }mb + { type }: { + ( mediaSize < MB_IN_B ) + ? `${ ( mediaSize / KB_IN_B ).toFixed( 2 ) }kb` + : `${ ( mediaSize / MB_IN_B ).toFixed( 2 ) }mb` + }

Attachment ID: { attachment.id }
Go to the attachment post ›

-
+
{ __( 'View entity record JSON', 'hm-media-weight' ) }
@@ -191,23 +239,13 @@ const HMMediaWeightSidebar = ( ...args ) => {
 						);
 					} ) }
 				
-
-				
-					
-				
 			
 		
 	);
 };
 
 registerPlugin( PLUGIN_NAME, {
-	icon: media,
+	icon: ScalesIcon,
 	render: HMMediaWeightSidebar,
 } );