From 8a57140656252b12c588027b180ecc9499eac513 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 21 Oct 2024 21:44:58 +0200 Subject: [PATCH 01/27] Add new private `upload-media` package --- package-lock.json | 60 ++ package.json | 3 +- packages/private-apis/src/implementation.js | 1 + packages/upload-media/.npmrc | 1 + packages/upload-media/CHANGELOG.md | 5 + packages/upload-media/package.json | 43 + packages/upload-media/src/constants.ts | 1 + packages/upload-media/src/image-file.ts | 38 + packages/upload-media/src/index.ts | 16 + packages/upload-media/src/lock-unlock.ts | 10 + packages/upload-media/src/store/actions.ts | 194 ++++ packages/upload-media/src/store/index.ts | 26 + .../upload-media/src/store/private-actions.ts | 989 ++++++++++++++++++ .../src/store/private-selectors.ts | 175 ++++ packages/upload-media/src/store/reducer.ts | 268 +++++ packages/upload-media/src/store/selectors.ts | 145 +++ .../upload-media/src/store/test/actions.ts | 115 ++ .../upload-media/src/store/test/reducer.ts | 415 ++++++++ .../upload-media/src/store/test/selectors.ts | 179 ++++ packages/upload-media/src/store/types.ts | 227 ++++ packages/upload-media/src/store/utils/vips.ts | 113 ++ packages/upload-media/src/stub-file.ts | 5 + .../src/test/get-file-basename.ts | 15 + .../src/test/get-file-extension.ts | 15 + .../src/test/get-file-name-from-url.ts | 14 + packages/upload-media/src/test/image-file.ts | 15 + .../upload-media/src/test/upload-error.ts | 24 + packages/upload-media/src/upload-error.ts | 26 + packages/upload-media/src/utils.ts | 160 +++ packages/upload-media/tsconfig.json | 19 + tsconfig.json | 1 + 31 files changed, 3317 insertions(+), 1 deletion(-) create mode 100644 packages/upload-media/.npmrc create mode 100644 packages/upload-media/CHANGELOG.md create mode 100644 packages/upload-media/package.json create mode 100644 packages/upload-media/src/constants.ts create mode 100644 packages/upload-media/src/image-file.ts create mode 100644 packages/upload-media/src/index.ts create mode 100644 packages/upload-media/src/lock-unlock.ts create mode 100644 packages/upload-media/src/store/actions.ts create mode 100644 packages/upload-media/src/store/index.ts create mode 100644 packages/upload-media/src/store/private-actions.ts create mode 100644 packages/upload-media/src/store/private-selectors.ts create mode 100644 packages/upload-media/src/store/reducer.ts create mode 100644 packages/upload-media/src/store/selectors.ts create mode 100644 packages/upload-media/src/store/test/actions.ts create mode 100644 packages/upload-media/src/store/test/reducer.ts create mode 100644 packages/upload-media/src/store/test/selectors.ts create mode 100644 packages/upload-media/src/store/types.ts create mode 100644 packages/upload-media/src/store/utils/vips.ts create mode 100644 packages/upload-media/src/stub-file.ts create mode 100644 packages/upload-media/src/test/get-file-basename.ts create mode 100644 packages/upload-media/src/test/get-file-extension.ts create mode 100644 packages/upload-media/src/test/get-file-name-from-url.ts create mode 100644 packages/upload-media/src/test/image-file.ts create mode 100644 packages/upload-media/src/test/upload-error.ts create mode 100644 packages/upload-media/src/upload-error.ts create mode 100644 packages/upload-media/src/utils.ts create mode 100644 packages/upload-media/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 82cf77db40c57d..77ec1f6c7e1d53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "@wordpress/sync": "file:packages/sync", "@wordpress/token-list": "file:packages/token-list", "@wordpress/undo-manager": "file:packages/undo-manager", + "@wordpress/upload-media": "file:packages/upload-media", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "@wordpress/vips": "file:packages/vips", @@ -10539,6 +10540,12 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@remote-ui/rpc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@remote-ui/rpc/-/rpc-1.4.5.tgz", + "integrity": "sha512-Cr+06niG/vmE4A9YsmaKngRuuVSWKMY42NMwtZfy+gctRWGu6Wj9BWuMJg5CEp+JTkRBPToqT5rqnrg1G/Wvow==", + "license": "MIT" + }, "node_modules/@samverschueren/stream-to-observable": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz", @@ -10683,6 +10690,34 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/@shopify/web-worker": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@shopify/web-worker/-/web-worker-6.4.0.tgz", + "integrity": "sha512-RvY1mgRyAqawFiYBvsBkek2pVK4GVpV9mmhWFCZXwx01usxXd2HMhKNTFeRYhSp29uoUcfBlKZAwCwQzt826tg==", + "license": "MIT", + "dependencies": { + "@remote-ui/rpc": "^1.2.5" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": "^5.38.0", + "webpack-virtual-modules": "^0.4.3 || ^0.5.0 || ^0.6.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "webpack": { + "optional": true + }, + "webpack-virtual-modules": { + "optional": true + } + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -17431,6 +17466,10 @@ "resolved": "packages/undo-manager", "link": true }, + "node_modules/@wordpress/upload-media": { + "resolved": "packages/upload-media", + "link": true + }, "node_modules/@wordpress/url": { "resolved": "packages/url", "link": true @@ -57212,6 +57251,27 @@ "npm": ">=8.19.2" } }, + "packages/upload-media": { + "name": "@wordpress/upload-media", + "version": "1.0.0-prerelease", + "license": "GPL-2.0-or-later", + "dependencies": { + "@shopify/web-worker": "^6.4.0", + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/blob": "file:../blob", + "@wordpress/data": "file:../data", + "@wordpress/i18n": "file:../i18n", + "@wordpress/preferences": "file:../preferences", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/url": "file:../url", + "@wordpress/vips": "file:../vips", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "packages/url": { "name": "@wordpress/url", "version": "4.10.0", diff --git a/package.json b/package.json index c280e1b728c3df..fd5f52527310a3 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@wordpress/sync": "file:packages/sync", "@wordpress/token-list": "file:packages/token-list", "@wordpress/undo-manager": "file:packages/undo-manager", + "@wordpress/upload-media": "file:packages/upload-media", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "@wordpress/vips": "file:packages/vips", @@ -310,7 +311,7 @@ "lint:pkg-json": "wp-scripts lint-pkg-json . 'packages/*/package.json'", "native": "npm run --prefix packages/react-native-editor", "other:changelog": "node ./bin/plugin/cli.js changelog", - "other:check-licenses": "concurrently \"wp-scripts check-licenses --prod --gpl2 --ignore=@react-native-community/cli,@react-native-community/cli-platform-ios,@ampproject/remapping,human-signals,fb-watchman,walker,chrome-launcher,lighthouse-logger,chromium-edge-launcher\" \"wp-scripts check-licenses --dev\"", + "other:check-licenses": "concurrently \"wp-scripts check-licenses --prod --gpl2 --ignore=@react-native-community/cli,@react-native-community/cli-platform-ios,@ampproject/remapping,human-signals,fb-watchman,walker,chrome-launcher,lighthouse-logger,chromium-edge-launcher,webpack\" \"wp-scripts check-licenses --dev\"", "preother:check-local-changes": "npm run docs:build", "other:check-local-changes": "node ./bin/check-local-changes.js", "other:cherry-pick": "node ./bin/cherry-pick.mjs", diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index ab0ebfae7ecb05..7473e9dabc4c70 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -32,6 +32,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/router', '@wordpress/dataviews', '@wordpress/fields', + '@wordpress/upload-media', ]; /** diff --git a/packages/upload-media/.npmrc b/packages/upload-media/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/upload-media/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md new file mode 100644 index 00000000000000..e04ce921cdfdc4 --- /dev/null +++ b/packages/upload-media/CHANGELOG.md @@ -0,0 +1,5 @@ + + +## Unreleased + +Initial release. diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json new file mode 100644 index 00000000000000..7567927b143b72 --- /dev/null +++ b/packages/upload-media/package.json @@ -0,0 +1,43 @@ +{ + "name": "@wordpress/upload-media", + "version": "1.0.0-prerelease", + "private": true, + "description": "Core media upload logic.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "media" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/upload-media/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/upload-media" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "dependencies": { + "@shopify/web-worker": "^6.4.0", + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/blob": "file:../blob", + "@wordpress/data": "file:../data", + "@wordpress/i18n": "file:../i18n", + "@wordpress/preferences": "file:../preferences", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/url": "file:../url", + "@wordpress/vips": "file:../vips", + "uuid": "^9.0.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/upload-media/src/constants.ts b/packages/upload-media/src/constants.ts new file mode 100644 index 00000000000000..924edff8ea14f3 --- /dev/null +++ b/packages/upload-media/src/constants.ts @@ -0,0 +1 @@ +export const PREFERENCES_NAME = 'core/media'; diff --git a/packages/upload-media/src/image-file.ts b/packages/upload-media/src/image-file.ts new file mode 100644 index 00000000000000..2c1a43ee1ab67e --- /dev/null +++ b/packages/upload-media/src/image-file.ts @@ -0,0 +1,38 @@ +/** + * ImageFile class. + * + * Small wrapper around the `File` class to hold + * information about current dimensions and original + * dimensions, in case the image was resized. + */ +export class ImageFile extends File { + width = 0; + height = 0; + originalWidth? = 0; + originalHeight? = 0; + + get wasResized() { + return ( + ( this.originalWidth || 0 ) > this.width || + ( this.originalHeight || 0 ) > this.height + ); + } + + constructor( + file: File, + width: number, + height: number, + originalWidth?: number, + originalHeight?: number + ) { + super( [ file ], file.name, { + type: file.type, + lastModified: file.lastModified, + } ); + + this.width = width; + this.height = height; + this.originalWidth = originalWidth; + this.originalHeight = originalHeight; + } +} diff --git a/packages/upload-media/src/index.ts b/packages/upload-media/src/index.ts new file mode 100644 index 00000000000000..63a756727b25f3 --- /dev/null +++ b/packages/upload-media/src/index.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { store as uploadStore } from './store'; + +export { uploadStore as store }; + +export { UploadError } from './upload-error'; + +export type { + ImageFormat, + ImageSizeCrop, + ThumbnailGeneration, + VideoFormat, + AudioFormat, +} from './store/types'; diff --git a/packages/upload-media/src/lock-unlock.ts b/packages/upload-media/src/lock-unlock.ts new file mode 100644 index 00000000000000..5089cb80e4bb46 --- /dev/null +++ b/packages/upload-media/src/lock-unlock.ts @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/upload-media' + ); diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts new file mode 100644 index 00000000000000..1bca0f9729cf04 --- /dev/null +++ b/packages/upload-media/src/store/actions.ts @@ -0,0 +1,194 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; + +/** + * WordPress dependencies + */ +// eslint-disable-next-line no-restricted-syntax +import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; + +/** + * Internal dependencies + */ +import { vipsCancelOperations } from './utils/vips'; +import type { + AdditionalData, + CancelAction, + OnBatchSuccessHandler, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, + QueueItem, + QueueItemId, + Settings, + State, + UpdateSettingsAction, +} from './types'; +import { Type } from './types'; +import type { + addItem, + processItem, + removeItem, + revokeBlobUrls, +} from './private-actions'; + +type ActionCreators = { + addItem: typeof addItem; + addItems: typeof addItems; + removeItem: typeof removeItem; + processItem: typeof processItem; + cancelItem: typeof cancelItem; + revokeBlobUrls: typeof revokeBlobUrls; + < T = Record< string, unknown > >( args: T ): void; +}; + +type AllSelectors = typeof import('./selectors') & + typeof import('./private-selectors'); +type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R + ? ( ...args: P ) => R + : F; +type Selectors = { + [ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >; +}; + +type ThunkArgs = { + select: Selectors; + dispatch: ActionCreators; + registry: WPDataRegistry; +}; + +/** + * Returns an action object that pauses all processing in the queue. + * + * Useful for testing purposes. + * + * @param settings + * @return Action object. + */ +export function updateSettings( + settings: Partial< Settings > +): UpdateSettingsAction { + return { + type: Type.UpdateSettings, + settings, + }; +} + +interface AddItemsArgs { + files: File[]; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onBatchSuccess?: OnBatchSuccessHandler; + onError?: OnErrorHandler; + additionalData?: AdditionalData; +} + +/** + * Adds a new item to the upload queue. + * + * @param $0 + * @param $0.files Files + * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. + * @param [$0.onSuccess] Function called after the file is uploaded. + * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. + * @param [$0.onError] Function called when an error happens. + * @param [$0.additionalData] Additional data to include in the request. + */ +export function addItems( { + files, + onChange, + onSuccess, + onError, + onBatchSuccess, + additionalData, +}: AddItemsArgs ) { + return async ( { dispatch }: { dispatch: ActionCreators } ) => { + const batchId = uuidv4(); + for ( const file of files ) { + dispatch.addItem( { + file, + batchId, + onChange, + onSuccess, + onBatchSuccess, + onError, + additionalData, + } ); + } + }; +} + +/** + * Cancels an item in the queue based on an error. + * + * @param id Item ID. + * @param error Error instance. + * @param silent Whether to cancel the item silently, + * without invoking its `onError` callback. + */ +export function cancelItem( id: QueueItemId, error: Error, silent = false ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ); + + if ( ! item ) { + /* + * Do nothing if item has already been removed. + * This can happen if an upload is cancelled manually + * while transcoding with vips is still in progress. + * Then, cancelItem() is once invoked manually and once + * by the error handler in optimizeImageItem(). + */ + return; + } + + // When cancelling a parent item, cancel all the children too. + for ( const child of select.getChildItems( id ) ) { + dispatch.cancelItem( child.id, error, silent ); + } + + await vipsCancelOperations( id ); + + item.abortController?.abort(); + + if ( ! silent ) { + // TODO: Do not log error for children if cancelling a parent and all its children. + const { onError } = item; + onError?.( error ?? new Error( 'Upload cancelled' ) ); + if ( ! onError && error ) { + // TODO: Find better way to surface errors with sideloads etc. + // eslint-disable-next-line no-console -- Deliberately log errors here. + console.error( 'Upload cancelled', error ); + } + } + + dispatch< CancelAction >( { + type: Type.Cancel, + id, + error, + } ); + dispatch.removeItem( id ); + dispatch.revokeBlobUrls( id ); + + // All items of this batch were cancelled or finished. + if ( item.batchId && select.isBatchUploaded( item.batchId ) ) { + item.onBatchSuccess?.(); + + // All other side-loaded items have been removed, so remove the parent too. + if ( item.parentId ) { + const parentItem = select.getItem( item.parentId ) as QueueItem; + + dispatch.removeItem( item.parentId ); + dispatch.revokeBlobUrls( item.parentId ); + + if ( + parentItem.batchId && + select.isBatchUploaded( parentItem.batchId ) + ) { + parentItem.onBatchSuccess?.(); + } + } + } + }; +} diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts new file mode 100644 index 00000000000000..08aee8ac00987f --- /dev/null +++ b/packages/upload-media/src/store/index.ts @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as privateSelectors from './private-selectors'; +import * as actions from './actions'; +import * as privateActions from './private-actions'; +import { unlock } from '../lock-unlock'; + +export const STORE_NAME = 'core/upload-media'; + +export const store = createReduxStore( STORE_NAME, { + reducer, + selectors, + actions, +} ); + +register( store ); +unlock( store ).registerPrivateActions( privateActions ); +unlock( store ).registerPrivateSelectors( privateSelectors ); diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts new file mode 100644 index 00000000000000..ba2176998a4470 --- /dev/null +++ b/packages/upload-media/src/store/private-actions.ts @@ -0,0 +1,989 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; + +/** + * WordPress dependencies + */ +import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +// eslint-disable-next-line no-restricted-syntax +import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; +// @ts-ignore -- No types available yet. +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { ImageFile } from '../image-file'; +import { UploadError } from '../upload-error'; +import { + cloneFile, + convertBlobToFile, + getFileBasename, + getFileExtension, + isImageTypeSupported, + renameFile, +} from '../utils'; +import { PREFERENCES_NAME } from '../constants'; +import { StubFile } from '../stub-file'; +import { vipsCompressImage, vipsResizeImage } from './utils/vips'; +import type { + AddAction, + AdditionalData, + AddOperationsAction, + Attachment, + BatchId, + CacheBlobUrlAction, + ImageFormat, + OnBatchSuccessHandler, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, + Operation, + OperationArgs, + OperationFinishAction, + OperationStartAction, + PauseItemAction, + PauseQueueAction, + QueueItem, + QueueItemId, + ResumeItemAction, + ResumeQueueAction, + RevokeBlobUrlsAction, + SideloadAdditionalData, + State, + ThumbnailGeneration, +} from './types'; +import { ItemStatus, OperationType, Type } from './types'; +import type { cancelItem } from './actions'; + +type ActionCreators = { + cancelItem: typeof cancelItem; + addItem: typeof addItem; + addSideloadItem: typeof addSideloadItem; + removeItem: typeof removeItem; + prepareItem: typeof prepareItem; + processItem: typeof processItem; + finishOperation: typeof finishOperation; + uploadItem: typeof uploadItem; + sideloadItem: typeof sideloadItem; + resumeItem: typeof resumeItem; + resizeCropItem: typeof resizeCropItem; + optimizeImageItem: typeof optimizeImageItem; + generateThumbnails: typeof generateThumbnails; + uploadOriginal: typeof uploadOriginal; + revokeBlobUrls: typeof revokeBlobUrls; + < T = Record< string, unknown > >( args: T ): void; +}; + +type AllSelectors = typeof import('./selectors') & + typeof import('./private-selectors'); +type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R + ? ( ...args: P ) => R + : F; +type Selectors = { + [ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >; +}; + +type ThunkArgs = { + select: Selectors; + dispatch: ActionCreators; + registry: WPDataRegistry; +}; + +interface AddItemArgs { + // It should always be a File, but some consumers might still pass Blobs only. + file: File | Blob; + batchId?: BatchId; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onError?: OnErrorHandler; + onBatchSuccess?: OnBatchSuccessHandler; + additionalData?: AdditionalData; + sourceUrl?: string; + sourceAttachmentId?: number; + abortController?: AbortController; + operations?: Operation[]; +} + +/** + * Adds a new item to the upload queue. + * + * @param $0 + * @param $0.file File + * @param [$0.batchId] Batch ID. + * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. + * @param [$0.onSuccess] Function called after the file is uploaded. + * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. + * @param [$0.onError] Function called when an error happens. + * @param [$0.additionalData] Additional data to include in the request. + * @param [$0.sourceUrl] Source URL. Used when importing a file from a URL or optimizing an existing file. + * @param [$0.sourceAttachmentId] Source attachment ID. Used when optimizing an existing file for example. + * @param [$0.abortController] Abort controller for upload cancellation. + * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. + */ +export function addItem( { + file: fileOrBlob, + batchId, + onChange, + onSuccess, + onBatchSuccess, + onError, + additionalData = {} as AdditionalData, + sourceUrl, + sourceAttachmentId, + abortController, + operations, +}: AddItemArgs ) { + return async ( { dispatch, registry }: ThunkArgs ) => { + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + const itemId = uuidv4(); + + // Hardening in case a Blob is passed instead of a File. + // See https://github.com/WordPress/gutenberg/pull/65693 for an example. + const file = convertBlobToFile( fileOrBlob ); + + let blobUrl; + + // StubFile could be coming from addItemFromUrl(). + if ( ! ( file instanceof StubFile ) ) { + blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id: itemId, + blobUrl, + } ); + } + + dispatch< AddAction >( { + type: Type.Add, + item: { + id: itemId, + batchId, + status: ItemStatus.Processing, + sourceFile: cloneFile( file ), + file, + attachment: { + url: blobUrl, + }, + additionalData: { + generate_sub_sizes: 'server' === thumbnailGeneration, + convert_format: false, + ...additionalData, + }, + onChange, + onSuccess, + onBatchSuccess, + onError, + sourceUrl, + sourceAttachmentId, + abortController: abortController || new AbortController(), + operations: Array.isArray( operations ) + ? operations + : [ OperationType.Prepare ], + }, + } ); + + dispatch.processItem( itemId ); + }; +} + +interface AddSideloadItemArgs { + file: File; + onChange?: OnChangeHandler; + additionalData?: AdditionalData; + operations?: Operation[]; + batchId?: BatchId; + parentId?: QueueItemId; +} + +/** + * Adds a new item to the upload queue for sideloading. + * + * This is typically a poster image or a client-side generated thumbnail. + * + * @param $0 + * @param $0.file File + * @param [$0.batchId] Batch ID. + * @param [$0.parentId] Parent ID. + * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. + * @param [$0.additionalData] Additional data to include in the request. + * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. + */ +export function addSideloadItem( { + file, + onChange, + additionalData, + operations, + batchId, + parentId, +}: AddSideloadItemArgs ) { + return async ( { dispatch }: { dispatch: ActionCreators } ) => { + const itemId = uuidv4(); + dispatch< AddAction >( { + type: Type.Add, + item: { + id: itemId, + batchId, + status: ItemStatus.Processing, + sourceFile: cloneFile( file ), + file, + onChange, + additionalData: { + ...additionalData, + }, + parentId, + operations: Array.isArray( operations ) + ? operations + : [ OperationType.Prepare ], + abortController: new AbortController(), + }, + } ); + + dispatch.processItem( itemId ); + }; +} + +/** + * Processes a single item in the queue. + * + * Runs the next operation in line and invokes any callbacks. + * + * @param id Item ID. + */ +export function processItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + if ( select.isPaused() ) { + return; + } + + const item = select.getItem( id ) as QueueItem; + + if ( item.status === ItemStatus.PendingApproval ) { + return; + } + + const { + attachment, + onChange, + onSuccess, + onBatchSuccess, + batchId, + parentId, + } = item; + + const operation = Array.isArray( item.operations?.[ 0 ] ) + ? item.operations[ 0 ][ 0 ] + : item.operations?.[ 0 ]; + // TODO: Improve type here to avoid using "as" further down. + const operationArgs = Array.isArray( item.operations?.[ 0 ] ) + ? item.operations[ 0 ][ 1 ] + : undefined; + + // If we're sideloading a thumbnail, pause upload to avoid race conditions. + // It will be resumed after the previous upload finishes. + if ( + operation === OperationType.Upload && + item.parentId && + item.additionalData.post + ) { + const isAlreadyUploading = select.isUploadingToPost( + item.additionalData.post as number + ); + if ( isAlreadyUploading ) { + dispatch< PauseItemAction >( { + type: Type.PauseItem, + id, + } ); + return; + } + } + + if ( attachment ) { + onChange?.( [ attachment ] ); + } + + /* + If there are no more operations, the item can be removed from the queue, + but only if there are no thumbnails still being side-loaded, + or if itself is a side-loaded item. + */ + + if ( ! operation ) { + if ( + parentId || + ( ! parentId && ! select.isUploadingByParentId( id ) ) + ) { + if ( attachment ) { + onSuccess?.( [ attachment ] ); + } + + dispatch.removeItem( id ); + dispatch.revokeBlobUrls( id ); + + if ( batchId && select.isBatchUploaded( batchId ) ) { + onBatchSuccess?.(); + } + } + + // All other side-loaded items have been removed, so remove the parent too. + if ( parentId && batchId && select.isBatchUploaded( batchId ) ) { + const parentItem = select.getItem( parentId ) as QueueItem; + + if ( attachment ) { + parentItem.onSuccess?.( [ attachment ] ); + } + + dispatch.removeItem( parentId ); + dispatch.revokeBlobUrls( parentId ); + + if ( + parentItem.batchId && + select.isBatchUploaded( parentItem.batchId ) + ) { + parentItem.onBatchSuccess?.(); + } + } + + /* + At this point we are dealing with a parent whose children haven't fully uploaded yet. + Do nothing and let the removal happen once the last side-loaded item finishes. + */ + + return; + } + + if ( ! operation ) { + // This shouldn't really happen. + return; + } + + dispatch< OperationStartAction >( { + type: Type.OperationStart, + id, + operation, + } ); + + switch ( operation ) { + case OperationType.Prepare: + dispatch.prepareItem( item.id ); + break; + + case OperationType.ResizeCrop: + dispatch.resizeCropItem( + item.id, + operationArgs as OperationArgs[ OperationType.ResizeCrop ] + ); + break; + + case OperationType.TranscodeImage: + dispatch.optimizeImageItem( + item.id, + operationArgs as OperationArgs[ OperationType.TranscodeImage ] + ); + break; + + case OperationType.Upload: + if ( item.parentId ) { + dispatch.sideloadItem( id ); + } else { + dispatch.uploadItem( id ); + } + break; + + case OperationType.ThumbnailGeneration: + dispatch.generateThumbnails( id ); + break; + + case OperationType.UploadOriginal: + dispatch.uploadOriginal( + id, + operationArgs as OperationArgs[ OperationType.UploadOriginal ] + ); + break; + } + }; +} + +/** + * Resumes processing for a given post/attachment ID. + * + * @param postOrAttachmentId Post or attachment ID. + */ +export function resumeItem( postOrAttachmentId: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getPausedUploadForPost( postOrAttachmentId ); + if ( item ) { + dispatch< ResumeItemAction >( { + type: Type.ResumeItem, + id: item.id, + } ); + dispatch.processItem( item.id ); + } + }; +} + +/** + * Returns an action object that pauses all processing in the queue. + * + * Useful for testing purposes. + * + * @return Action object. + */ +export function pauseQueue(): PauseQueueAction { + return { + type: Type.PauseQueue, + }; +} + +/** + * Resumes all processing in the queue. + * + * Dispatches an action object for resuming the queue itself, + * and triggers processing for each remaining item in the queue individually. + */ +export function resumeQueue() { + return async ( { select, dispatch }: ThunkArgs ) => { + dispatch< ResumeQueueAction >( { + type: Type.ResumeQueue, + } ); + + for ( const item of select.getAllItems() ) { + dispatch.processItem( item.id ); + } + }; +} + +/** + * Removes a specific item from the queue. + * + * @param id Item ID. + */ +export function removeItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ); + if ( ! item ) { + return; + } + + dispatch( { + type: Type.Remove, + id, + } ); + }; +} + +/** + * Finishes an operation for a given item ID and immediately triggers processing the next one. + * + * @param id Item ID. + * @param updates Updated item data. + */ +export function finishOperation( + id: QueueItemId, + updates: Partial< QueueItem > +) { + return async ( { dispatch }: ThunkArgs ) => { + dispatch< OperationFinishAction >( { + type: Type.OperationFinish, + id, + item: updates, + } ); + + dispatch.processItem( id ); + }; +} + +/** + * Prepares an item for initial processing. + * + * Determines the list of operations to perform for a given image, + * depending on its media type. + * + * For example, HEIF images first need to be converted, resized, + * compressed, and then uploaded. + * + * Or videos need to be compressed, and then need poster generation + * before upload. + * + * @param id Item ID. + */ +export function prepareItem( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const { file } = item; + + const mediaType = + 'application/pdf' === file.type + ? 'pdf' + : file.type.split( '/' )[ 0 ]; + + const operations: Operation[] = []; + + switch ( mediaType ) { + case 'image': + // Short-circuit for file types such as SVG or ICO. + if ( ! isImageTypeSupported( file.type ) ) { + operations.push( OperationType.Upload ); + break; + } + + const optimizeOnUpload: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'optimizeOnUpload' ); + + const imageSizeThreshold: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); + + if ( imageSizeThreshold ) { + operations.push( [ + OperationType.ResizeCrop, + { + resize: { + width: imageSizeThreshold, + height: imageSizeThreshold, + }, + }, + ] ); + } + + if ( optimizeOnUpload ) { + operations.push( OperationType.TranscodeImage ); + } + + operations.push( OperationType.GenerateMetadata ); + + operations.push( + OperationType.Upload, + OperationType.ThumbnailGeneration + ); + + const keepOriginal: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'keepOriginal' ); + + if ( imageSizeThreshold && keepOriginal ) { + operations.push( OperationType.UploadOriginal ); + } + + break; + + default: + operations.push( OperationType.Upload ); + + break; + } + + dispatch< AddOperationsAction >( { + type: Type.AddOperations, + id, + operations, + } ); + + dispatch.finishOperation( id, {} ); + }; +} + +/** + * Adds thumbnail versions to the queue for sideloading. + * + * @param id Item ID. + */ +export function generateThumbnails( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const attachment: Attachment = item.attachment as Attachment; + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + // Client-side thumbnail generation. + // Works for images and PDF posters. + + if ( + ! item.parentId && + attachment.missing_image_sizes && + 'server' !== thumbnailGeneration + ) { + let file = attachment.filename + ? renameFile( item.file, attachment.filename ) + : item.file; + const batchId = uuidv4(); + + if ( 'application/pdf' === item.file.type && item.poster ) { + file = item.poster; + + const outputFormat: ImageFormat = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_outputFormat' ); + + const outputQuality: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_quality' ); + + const interlaced: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_interlaced' ); + + // Upload the "full" version without a resize param. + dispatch.addSideloadItem( { + file: item.poster, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + image_size: 'full', + convert_format: false, + }, + operations: [ + [ + OperationType.TranscodeImage, + { outputFormat, outputQuality, interlaced }, + ], + OperationType.Upload, + ], + parentId: item.id, + } ); + } + + for ( const name of attachment.missing_image_sizes ) { + const imageSize = select.getImageSize( name ); + if ( ! imageSize ) { + continue; + } + + // Force thumbnails to be soft crops, see wp_generate_attachment_metadata(). + if ( + 'application/pdf' === item.file.type && + 'thumbnail' === name + ) { + imageSize.crop = false; + } + + dispatch.addSideloadItem( { + file, + onChange: ( [ updatedAttachment ] ) => { + // If the sub-size is still being generated, there is no need + // to invoke the callback below. It would just override + // the main image in the editor with the sub-size. + if ( isBlobURL( updatedAttachment.url ) ) { + return; + } + + // This might be confusing, but the idea is to update the original + // image item in the editor with the new one with the added sub-size. + item.onChange?.( [ updatedAttachment ] ); + }, + batchId, + parentId: item.id, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + // Reference the same upload_request if needed. + upload_request: item.additionalData.upload_request, + image_size: name, + convert_format: false, + }, + operations: [ + [ OperationType.ResizeCrop, { resize: imageSize } ], + OperationType.Upload, + ], + } ); + } + } + + dispatch.finishOperation( id, {} ); + }; +} + +type UploadOriginalArgs = OperationArgs[ OperationType.UploadOriginal ]; + +/** + * Adds the original file to the queue for sideloading. + * + * If an item was downsized due to the big image size threshold, + * this adds the original file for storing. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function uploadOriginal( id: QueueItemId, args?: UploadOriginalArgs ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const attachment: Attachment = item.attachment as Attachment; + + /* + Upload the original image file if it was resized because of the big image size threshold, + or if it was converted to be web-safe (e.g. HEIC, JPEG XL) and thus + uploading the original is "forced". + */ + if ( + ! item.parentId && + ( ( item.file instanceof ImageFile && item.file?.wasResized ) || + args?.force ) + ) { + const originalBaseName = getFileBasename( + attachment.filename || item.file.name + ); + + dispatch.addSideloadItem( { + file: renameFile( + item.sourceFile, + `${ originalBaseName }-original.${ getFileExtension( + item.sourceFile.name + ) }` + ), + parentId: item.id, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + // Reference the same upload_request if needed. + upload_request: item.additionalData.upload_request, + image_size: 'original', + convert_format: false, + }, + // Skip any resizing or optimization of the original image. + operations: [ OperationType.Upload ], + } ); + } + + dispatch.finishOperation( id, {} ); + }; +} + +type OptimizeImageItemArgs = OperationArgs[ OperationType.TranscodeImage ]; + +/** + * Optimizes/Compresses an existing image item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function optimizeImageItem( + id: QueueItemId, + args?: OptimizeImageItemArgs +) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const inputFormat = item.file.type.split( '/' )[ 1 ]; + + const outputQuality: number = + args?.outputQuality || + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, `${ inputFormat }_quality` ) || + 80; + + const interlaced: boolean = + args?.interlaced || + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, `${ inputFormat }_interlaced` ) || + false; + + try { + let file: File; + + file = await vipsCompressImage( + item.id, + item.file, + outputQuality / 100, + interlaced + ); + + if ( item.file instanceof ImageFile ) { + file = new ImageFile( + file, + item.file.width, + item.file.height, + item.file.originalWidth, + item.file.originalHeight + ); + } + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + if ( args?.requireApproval ) { + dispatch.finishOperation( id, { + status: ItemStatus.PendingApproval, + file, + attachment: { + url: blobUrl, + mime_type: file.type, + }, + } ); + } else { + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } + } catch ( error ) { + dispatch.cancelItem( + id, + new UploadError( { + code: 'MEDIA_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + }; +} + +type ResizeCropItemArgs = OperationArgs[ OperationType.ResizeCrop ]; + +/** + * Resizes and crops an existing image item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + if ( ! args?.resize ) { + dispatch.finishOperation( id, { + file: item.file, + } ); + return; + } + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + const smartCrop = Boolean( thumbnailGeneration === 'smart' ); + + const addSuffix = Boolean( item.parentId ); + + try { + const file = await vipsResizeImage( + item.id, + item.file, + args.resize, + smartCrop, + addSuffix + ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + new UploadError( { + code: 'IMAGE_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + }; +} + +/** + * Uploads an item to the server. + * + * @param id Item ID. + */ +export function uploadItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + select.getSettings().mediaUpload( { + filesList: [ item.file ], + additionalData: item.additionalData, + signal: item.abortController?.signal, + onFileChange: ( [ attachment ] ) => { + dispatch.finishOperation( id, { + attachment, + } ); + }, + onError: ( error ) => { + dispatch.cancelItem( id, error ); + }, + } ); + }; +} + +/** + * Sideloads an item to the server. + * + * @param id Item ID. + */ +export function sideloadItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const { post, ...additionalData } = + item.additionalData as SideloadAdditionalData; + + select.getSettings().mediaSideload( { + file: item.file, + attachmentId: post as number, + additionalData, + signal: item.abortController?.signal, + onFileChange: ( [ attachment ] ) => { + dispatch.finishOperation( id, { attachment } ); + dispatch.resumeItem( post as number ); + }, + onError: ( error ) => { + dispatch.cancelItem( id, error ); + dispatch.resumeItem( post as number ); + }, + } ); + }; +} + +/** + * Revokes all blob URLs for a given item, freeing up memory. + * + * @param id Item ID. + */ +export function revokeBlobUrls( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const blobUrls = select.getBlobUrls( id ); + + for ( const blobUrl of blobUrls ) { + revokeBlobURL( blobUrl ); + } + + dispatch< RevokeBlobUrlsAction >( { + type: Type.RevokeBlobUrls, + id, + } ); + }; +} diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts new file mode 100644 index 00000000000000..b94674eb5e3ef5 --- /dev/null +++ b/packages/upload-media/src/store/private-selectors.ts @@ -0,0 +1,175 @@ +/** + * Internal dependencies + */ +import { + type BatchId, + type ImageSizeCrop, + ItemStatus, + OperationType, + type QueueItem, + type QueueItemId, + type State, +} from './types'; + +/** + * Returns all items currently being uploaded. + * + * @param state Upload state. + * + * @return Queue items. + */ +export function getAllItems( state: State ): QueueItem[] { + return state.queue; +} + +/** + * Returns all items currently being uploaded. + * + * @param state Upload state. + * @param parentId Parent item ID. + * + * @return Queue items. + */ +export function getChildItems( + state: State, + parentId: QueueItemId +): QueueItem[] { + return state.queue.filter( ( item ) => item.parentId === parentId ); +} + +/** + * Returns a specific item given its unique ID. + * + * @param state Upload state. + * @param id Item ID. + * + * @return Queue item. + */ +export function getItem( + state: State, + id: QueueItemId +): QueueItem | undefined { + return state.queue.find( ( item ) => item.id === id ); +} + +/** + * Returns a specific item given its associated attachment ID. + * + * @param state Upload state. + * @param attachmentId Item ID. + * + * @return Queue item. + */ +export function getItemByAttachmentId( + state: State, + attachmentId: number +): QueueItem | undefined { + return state.queue.find( + ( item ) => + item.attachment?.id === attachmentId || + item.sourceAttachmentId === attachmentId + ); +} + +/** + * Determines whether a batch has been successfully uploaded, given its unique ID. + * + * @param state Upload state. + * @param batchId Batch ID. + * + * @return Whether a batch has been uploaded. + */ +export function isBatchUploaded( state: State, batchId: BatchId ): boolean { + const batchItems = state.queue.filter( + ( item ) => batchId === item.batchId + ); + return batchItems.length === 0; +} + +/** + * Determines whether an upload is currently in progress given a post or attachment ID. + * + * @param state Upload state. + * @param postOrAttachmentId Post ID or attachment ID. + * + * @return Whether upload is currently in progress for the given post or attachment. + */ +export function isUploadingToPost( + state: State, + postOrAttachmentId: number +): boolean { + return state.queue.some( + ( item ) => + item.currentOperation === OperationType.Upload && + item.additionalData.post === postOrAttachmentId + ); +} + +/** + * Returns the next paused upload for a given post or attachment ID. + * + * @param state Upload state. + * @param postOrAttachmentId Post ID or attachment ID. + * + * @return Paused item. + */ +export function getPausedUploadForPost( + state: State, + postOrAttachmentId: number +): QueueItem | undefined { + return state.queue.find( + ( item ) => + item.status === ItemStatus.Paused && + item.additionalData.post === postOrAttachmentId + ); +} + +/** + * Determines whether an upload is currently in progress given a parent ID. + * + * @param state Upload state. + * @param parentId Parent ID. + * + * @return Whether upload is currently in progress for the given parent ID. + */ +export function isUploadingByParentId( + state: State, + parentId: QueueItemId +): boolean { + return state.queue.some( ( item ) => item.parentId === parentId ); +} + +/** + * Determines whether uploading is currently paused. + * + * @param state Upload state. + * + * @return Whether uploading is currently paused. + */ +export function isPaused( state: State ): boolean { + return state.queueStatus === 'paused'; +} + +/** + * Returns an image size given its name. + * + * @param state Upload state. + * @param name Image size name. + * + * @return Image size data. + */ +export function getImageSize( state: State, name: string ): ImageSizeCrop { + return state.settings.imageSizes[ name ]; +} + +/** + * Returns all cached blob URLs for a given item ID. + * + * @param state Upload state. + * @param id Item ID + * + * @return List of blob URLs. + */ +export function getBlobUrls( state: State, id: QueueItemId ): string[] { + return state.blobUrls[ id ] || []; +} diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts new file mode 100644 index 00000000000000..ddc2e9ea6d6a88 --- /dev/null +++ b/packages/upload-media/src/store/reducer.ts @@ -0,0 +1,268 @@ +/** + * Internal dependencies + */ +import { + type AddAction, + type AddOperationsAction, + type ApproveUploadAction, + type CacheBlobUrlAction, + type CancelAction, + ItemStatus, + type OperationFinishAction, + type OperationStartAction, + type PauseItemAction, + type PauseQueueAction, + type QueueItem, + type RemoveAction, + type ResumeItemAction, + type ResumeQueueAction, + type RevokeBlobUrlsAction, + type State, + Type, + type UnknownAction, + type UpdateSettingsAction, +} from './types'; + +const noop = () => {}; + +const DEFAULT_STATE: State = { + queue: [], + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: noop, + mediaSideload: noop, + imageSizes: {}, + }, +}; + +type Action = + | AddAction + | RemoveAction + | CancelAction + | PauseItemAction + | ResumeItemAction + | PauseQueueAction + | ResumeQueueAction + | AddOperationsAction + | ApproveUploadAction + | OperationFinishAction + | OperationStartAction + | CacheBlobUrlAction + | RevokeBlobUrlsAction + | UpdateSettingsAction + | UnknownAction; + +function reducer( + state = DEFAULT_STATE, + action: Action = { type: Type.Unknown } +) { + switch ( action.type ) { + case Type.PauseQueue: { + return { + ...state, + queueStatus: 'paused', + }; + } + + case Type.ResumeQueue: { + return { + ...state, + queueStatus: 'active', + }; + } + + case Type.Add: + return { + ...state, + queue: [ ...state.queue, action.item ], + }; + + case Type.Cancel: + return { + ...state, + queue: state.queue.map( + ( item ): QueueItem => + item.id === action.id + ? { + ...item, + error: action.error, + } + : item + ), + pendingApproval: + state.pendingApproval !== action.id + ? state.pendingApproval + : state.queue.find( + ( item ) => + item.status === + ItemStatus.PendingApproval && + item.id !== action.id + )?.id || undefined, + }; + + case Type.Remove: + return { + ...state, + queue: state.queue.filter( ( item ) => item.id !== action.id ), + }; + + case Type.PauseItem: + return { + ...state, + queue: state.queue.map( + ( item ): QueueItem => + item.id === action.id + ? { + ...item, + status: ItemStatus.Paused, + } + : item + ), + }; + + case Type.ResumeItem: + return { + ...state, + queue: state.queue.map( + ( item ): QueueItem => + item.id === action.id + ? { + ...item, + status: ItemStatus.Processing, + } + : item + ), + }; + + case Type.OperationStart: { + return { + ...state, + queue: state.queue.map( + ( item ): QueueItem => + item.id === action.id + ? { + ...item, + currentOperation: action.operation, + } + : item + ), + }; + } + + case Type.AddOperations: + return { + ...state, + queue: state.queue.map( ( item ): QueueItem => { + if ( item.id !== action.id ) { + return item; + } + + return { + ...item, + operations: [ + ...( item.operations || [] ), + ...action.operations, + ], + }; + } ), + }; + + case Type.OperationFinish: + return { + ...state, + queue: state.queue.map( ( item ): QueueItem => { + if ( item.id !== action.id ) { + return item; + } + + const operations = item.operations + ? item.operations.slice( 1 ) + : []; + + // Prevent an empty object if there's no attachment data. + const attachment = + item.attachment || action.item.attachment + ? { + ...item.attachment, + ...action.item.attachment, + } + : undefined; + + return { + ...item, + currentOperation: undefined, + operations, + ...action.item, + attachment, + additionalData: { + ...item.additionalData, + ...action.item.additionalData, + }, + }; + } ), + // eslint-disable-next-line no-nested-ternary + pendingApproval: state.pendingApproval + ? state.pendingApproval + : action.item.status === ItemStatus.PendingApproval + ? action.id + : undefined, + }; + + case Type.ApproveUpload: + return { + ...state, + queue: state.queue.map( + ( item ): QueueItem => + item.id === action.id + ? { + ...item, + status: ItemStatus.Processing, + } + : item + ), + pendingApproval: + state.queue.find( + ( item ) => + item.status === ItemStatus.PendingApproval && + item.id !== action.id + )?.id || undefined, + }; + + case Type.CacheBlobUrl: { + const blobUrls = state.blobUrls[ action.id ] || []; + return { + ...state, + blobUrls: { + ...state.blobUrls, + [ action.id ]: [ ...blobUrls, action.blobUrl ], + }, + }; + } + + case Type.RevokeBlobUrls: { + const newBlobUrls = { ...state.blobUrls }; + delete newBlobUrls[ action.id ]; + + return { + ...state, + blobUrls: newBlobUrls, + }; + } + + case Type.UpdateSettings: { + return { + ...state, + settings: { + ...state.settings, + ...action.settings, + }, + }; + } + } + + return state; +} + +export default reducer; diff --git a/packages/upload-media/src/store/selectors.ts b/packages/upload-media/src/store/selectors.ts new file mode 100644 index 00000000000000..f31181c7411bd2 --- /dev/null +++ b/packages/upload-media/src/store/selectors.ts @@ -0,0 +1,145 @@ +/** + * Internal dependencies + */ +import { ItemStatus, type QueueItem, type Settings, type State } from './types'; + +/** + * Returns all items currently being uploaded, without sub-sizes (children). + * + * @param state Upload state. + * + * @return Queue items. + */ +export function getItems( state: State ): QueueItem[] { + return state.queue.filter( ( item ) => ! item.parentId ); +} + +/** + * Determines whether there is an item pending approval. + * + * @param state Upload state. + * + * @return Whether there is an item pending approval. + */ +export function isPendingApproval( state: State ): boolean { + return state.queue.some( + ( item ) => item.status === ItemStatus.PendingApproval + ); +} + +/** + * Determines whether an item is the first one pending approval given its associated attachment ID. + * + * @param state Upload state. + * @param attachmentId Attachment ID. + * + * @return Whether the item is first in the list of items pending approval. + */ +export function isPendingApprovalByAttachmentId( + state: State, + attachmentId: number +): boolean { + if ( ! state.pendingApproval ) { + return false; + } + + return state.queue.some( + ( item ) => + item.status === ItemStatus.PendingApproval && + item.id === state.pendingApproval && + ( item.attachment?.id === attachmentId || + item.sourceAttachmentId === attachmentId ) + ); +} + +/** + * Returns data to compare the old file vs. the optimized file, given the attachment ID. + * + * Includes both the URLs and the respective file sizes and the size difference in percentage. + * + * @param state Upload state. + * @param attachmentId Attachment ID. + * + * @return Comparison data. + */ +export function getComparisonDataForApproval( + state: State, + attachmentId: number +): { + oldUrl: string | undefined; + oldSize: number; + newSize: number; + newUrl: string | undefined; + sizeDiff: number; +} | null { + const foundItem = state.queue.find( + ( item ) => + ( item.attachment?.id === attachmentId || + item.sourceAttachmentId === attachmentId ) && + item.status === ItemStatus.PendingApproval + ); + + if ( ! foundItem ) { + return null; + } + + return { + oldUrl: foundItem.sourceUrl, + oldSize: foundItem.sourceFile.size, + newSize: foundItem.file.size, + newUrl: foundItem.attachment?.url, + sizeDiff: foundItem.file.size / foundItem.sourceFile.size - 1, + }; +} + +/** + * Determines whether any upload is currently in progress. + * + * @param state Upload state. + * + * @return Whether any upload is currently in progress. + */ +export function isUploading( state: State ): boolean { + return state.queue.length >= 1; +} + +/** + * Determines whether an upload is currently in progress given an attachment URL. + * + * @param state Upload state. + * @param url Attachment URL. + * + * @return Whether upload is currently in progress for the given attachment. + */ +export function isUploadingByUrl( state: State, url: string ): boolean { + return state.queue.some( + ( item ) => item.attachment?.url === url || item.sourceUrl === url + ); +} + +/** + * Determines whether an upload is currently in progress given an attachment ID. + * + * @param state Upload state. + * @param attachmentId Attachment ID. + * + * @return Whether upload is currently in progress for the given attachment. + */ +export function isUploadingById( state: State, attachmentId: number ): boolean { + return state.queue.some( + ( item ) => + item.attachment?.id === attachmentId || + item.sourceAttachmentId === attachmentId + ); +} + +/** + * Returns the media upload settings. + * + * @param state Upload state. + * + * @return Settings + */ +export function getSettings( state: State ): Settings { + return state.settings; +} diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts new file mode 100644 index 00000000000000..2dbf6c61168b6c --- /dev/null +++ b/packages/upload-media/src/store/test/actions.ts @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { createRegistry } from '@wordpress/data'; +// eslint-disable-next-line no-restricted-syntax +import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; +// @ts-ignore -- No types available yet. +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { store as uploadStore } from '..'; +import { ItemStatus } from '../types'; +import { unlock } from '../../lock-unlock'; + +jest.mock( '@wordpress/blob', () => ( { + __esModule: true, + createBlobURL: jest.fn( () => 'blob:foo' ), + isBlobURL: jest.fn( ( str: string ) => str.startsWith( 'blob:' ) ), + revokeBlobURL: jest.fn(), +} ) ); + +jest.mock( '../utils/vips', () => ( { + vipsCancelOperations: jest.fn( () => true ), +} ) ); + +function createRegistryWithStores() { + // Create a registry and register used stores. + const registry = createRegistry(); + // @ts-ignore + [ uploadStore, preferencesStore ].forEach( registry.register ); + return registry; +} + +const jpegFile = new File( [], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', +} ); + +const mp4File = new File( [], 'amazing-video.mp4', { + lastModified: 1234567891, + type: 'video/mp4', +} ); + +describe( 'actions', () => { + let registry: WPDataRegistry; + beforeEach( () => { + registry = createRegistryWithStores(); + unlock( registry.dispatch( uploadStore ) ).pauseQueue(); + } ); + + describe( 'addItem', () => { + it( 'adds an item to the queue', () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + expect( registry.select( uploadStore ).getItems() ).toHaveLength( + 1 + ); + expect( + registry.select( uploadStore ).getItems()[ 0 ] + ).toStrictEqual( + expect.objectContaining( { + id: expect.any( String ), + file: jpegFile, + sourceFile: jpegFile, + status: ItemStatus.Processing, + attachment: { + url: expect.stringMatching( /^blob:/ ), + }, + } ) + ); + } ); + } ); + + describe( 'addItems', () => { + it( 'adds multiple items to the queue', () => { + registry.dispatch( uploadStore ).addItems( { + files: [ jpegFile, mp4File ], + } ); + + expect( registry.select( uploadStore ).getItems() ).toHaveLength( + 2 + ); + expect( + registry.select( uploadStore ).getItems()[ 0 ] + ).toStrictEqual( + expect.objectContaining( { + id: expect.any( String ), + file: jpegFile, + sourceFile: jpegFile, + status: ItemStatus.Processing, + attachment: { + url: expect.stringMatching( /^blob:/ ), + }, + } ) + ); + expect( + registry.select( uploadStore ).getItems()[ 1 ] + ).toStrictEqual( + expect.objectContaining( { + id: expect.any( String ), + file: mp4File, + sourceFile: mp4File, + status: ItemStatus.Processing, + attachment: { + url: expect.stringMatching( /^blob:/ ), + }, + } ) + ); + } ); + } ); +} ); diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts new file mode 100644 index 00000000000000..75e4d3e6e0191c --- /dev/null +++ b/packages/upload-media/src/store/test/reducer.ts @@ -0,0 +1,415 @@ +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import { + ItemStatus, + OperationType, + type QueueItem, + type State, + Type, +} from '../types'; + +describe( 'reducer', () => { + describe( `${ Type.Add }`, () => { + it( 'adds an item to the queue', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.Add, + item: { + id: '2', + status: ItemStatus.Processing, + } as QueueItem, + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '2', + status: ItemStatus.Processing, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.Cancel }`, () => { + it( 'removes an item from the queue', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '2', + status: ItemStatus.Processing, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.Cancel, + id: '2', + error: new Error(), + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + }, + { + id: '2', + status: ItemStatus.Processing, + error: expect.any( Error ), + }, + ], + } ); + } ); + } ); + + describe( `${ Type.Remove }`, () => { + it( 'removes an item from the queue', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '2', + status: ItemStatus.Processing, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.Remove, + id: '1', + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '2', + status: ItemStatus.Processing, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.PauseItem }`, () => { + it( 'marks an item as paused', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '2', + status: ItemStatus.Processing, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.PauseItem, + id: '2', + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + }, + { + id: '2', + status: ItemStatus.Paused, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.ResumeItem }`, () => { + it( 'marks an item as processing', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '2', + status: ItemStatus.Paused, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.ResumeItem, + id: '2', + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + }, + { + id: '2', + status: ItemStatus.Processing, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.AddOperations }`, () => { + it( 'appends operations to the list', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.AddOperations, + id: '1', + operations: [ OperationType.Compress ], + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ + OperationType.Upload, + OperationType.Compress, + ], + }, + ], + } ); + } ); + } ); + + describe( `${ Type.OperationStart }`, () => { + it( 'marks an item as processing', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + } as QueueItem, + { + id: '2', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.OperationStart, + id: '2', + operation: OperationType.Upload, + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + }, + { + id: '2', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + currentOperation: OperationType.Upload, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.OperationFinish }`, () => { + it( 'marks an item as processing', () => { + const initialState: State = { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + queue: [ + { + id: '1', + additionalData: {}, + attachment: {}, + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + currentOperation: OperationType.Upload, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.OperationFinish, + id: '1', + item: {}, + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + mediaSideload: expect.any( Function ), + imageSizes: {}, + }, + queue: [ + { + id: '1', + additionalData: {}, + attachment: {}, + status: ItemStatus.Processing, + currentOperation: undefined, + operations: [], + }, + ], + } ); + } ); + } ); +} ); diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts new file mode 100644 index 00000000000000..fee3837ce18110 --- /dev/null +++ b/packages/upload-media/src/store/test/selectors.ts @@ -0,0 +1,179 @@ +/** + * Internal dependencies + */ +import { + getItems, + isPendingApproval, + isUploading, + isUploadingById, + isUploadingByUrl, +} from '../selectors'; +import { ItemStatus, type QueueItem, type State } from '../types'; + +describe( 'selectors', () => { + describe( 'getItems', () => { + it( 'should return empty array by default', () => { + const state: State = { + queue: [], + queueStatus: 'paused', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + }; + + expect( getItems( state ) ).toHaveLength( 0 ); + } ); + } ); + + describe( 'isUploading', () => { + it( 'should return true if there are items in the pipeline', () => { + const state: State = { + queue: [ + { + status: ItemStatus.Processing, + }, + { + status: ItemStatus.Processing, + }, + { + status: ItemStatus.PendingApproval, + }, + { + status: ItemStatus.Paused, + }, + ] as QueueItem[], + queueStatus: 'paused', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + }; + + expect( isUploading( state ) ).toBe( true ); + } ); + } ); + + describe( 'isUploadingByUrl', () => { + it( 'should return true if there are items in the pipeline', () => { + const state: State = { + queue: [ + { + status: ItemStatus.Processing, + attachment: { + url: 'https://example.com/one.jpeg', + }, + }, + { + status: ItemStatus.PendingApproval, + sourceUrl: 'https://example.com/two.jpeg', + }, + { + status: ItemStatus.Processing, + }, + ] as QueueItem[], + queueStatus: 'paused', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + }; + + expect( + isUploadingByUrl( state, 'https://example.com/one.jpeg' ) + ).toBe( true ); + expect( + isUploadingByUrl( state, 'https://example.com/two.jpeg' ) + ).toBe( true ); + expect( + isUploadingByUrl( state, 'https://example.com/three.jpeg' ) + ).toBe( false ); + } ); + } ); + + describe( 'isUploadingById', () => { + it( 'should return true if there are items in the pipeline', () => { + const state: State = { + queue: [ + { + status: ItemStatus.Processing, + attachment: { + id: 123, + }, + }, + { + status: ItemStatus.PendingApproval, + sourceAttachmentId: 456, + }, + { + status: ItemStatus.PendingApproval, + }, + ] as QueueItem[], + queueStatus: 'paused', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + }; + + expect( isUploadingById( state, 123 ) ).toBe( true ); + expect( isUploadingById( state, 456 ) ).toBe( true ); + expect( isUploadingById( state, 789 ) ).toBe( false ); + } ); + } ); + + describe( 'isPendingApproval', () => { + it( 'should return true if there are items pending approval', () => { + const state: State = { + queue: [ + { + status: ItemStatus.Processing, + }, + { + status: ItemStatus.Processing, + }, + { + status: ItemStatus.PendingApproval, + }, + { + status: ItemStatus.Paused, + }, + { + status: ItemStatus.Processing, + }, + { + status: ItemStatus.PendingApproval, + }, + { + status: ItemStatus.Paused, + }, + { + status: ItemStatus.Processing, + }, + ] as QueueItem[], + queueStatus: 'paused', + pendingApproval: undefined, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + mediaSideload: jest.fn(), + imageSizes: {}, + }, + }; + + expect( isPendingApproval( state ) ).toBe( true ); + } ); + } ); +} ); diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts new file mode 100644 index 00000000000000..49397dd8ae04db --- /dev/null +++ b/packages/upload-media/src/store/types.ts @@ -0,0 +1,227 @@ +export type QueueItemId = string; + +export type QueueStatus = 'active' | 'paused'; + +export type BatchId = string; + +export interface QueueItem { + id: QueueItemId; + sourceFile: File; + file: File; + poster?: File; + attachment?: Partial< Attachment >; + status: ItemStatus; + additionalData: AdditionalData; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onError?: OnErrorHandler; + onBatchSuccess?: OnBatchSuccessHandler; + currentOperation?: OperationType; + operations?: Operation[]; + error?: Error; + batchId?: string; + sourceUrl?: string; + sourceAttachmentId?: number; + blurHash?: string; + dominantColor?: string; + generatedPosterId?: number; + parentId?: QueueItemId; + abortController?: AbortController; +} + +export interface State { + queue: QueueItem[]; + queueStatus: QueueStatus; + pendingApproval: QueueItemId | undefined; + blobUrls: Record< QueueItemId, string[] >; + settings: Settings; +} + +export enum Type { + Unknown = 'REDUX_UNKNOWN', + Add = 'ADD_ITEM', + Prepare = 'PREPARE_ITEM', + Cancel = 'CANCEL_ITEM', + Remove = 'REMOVE_ITEM', + PauseItem = 'PAUSE_ITEM', + ResumeItem = 'RESUME_ITEM', + PauseQueue = 'PAUSE_QUEUE', + ResumeQueue = 'RESUME_QUEUE', + ApproveUpload = 'APPROVE_UPLOAD', + OperationStart = 'OPERATION_START', + OperationFinish = 'OPERATION_FINISH', + AddOperations = 'ADD_OPERATIONS', + CacheBlobUrl = 'CACHE_BLOB_URL', + RevokeBlobUrls = 'REVOKE_BLOB_URLS', + UpdateSettings = 'UPDATE_SETTINGS', +} + +type Action< T = Type, Payload = Record< string, unknown > > = { + type: T; +} & Payload; + +export type UnknownAction = Action< Type.Unknown >; +export type AddAction = Action< + Type.Add, + { + item: Omit< QueueItem, 'operations' > & + Partial< Pick< QueueItem, 'operations' > >; + } +>; +export type OperationStartAction = Action< + Type.OperationStart, + { id: QueueItemId; operation: OperationType } +>; +export type OperationFinishAction = Action< + Type.OperationFinish, + { + id: QueueItemId; + item: Partial< QueueItem >; + } +>; +export type AddOperationsAction = Action< + Type.AddOperations, + { id: QueueItemId; operations: Operation[] } +>; +export type ApproveUploadAction = Action< + Type.ApproveUpload, + { id: QueueItemId } +>; +export type CancelAction = Action< + Type.Cancel, + { id: QueueItemId; error: Error } +>; +export type PauseItemAction = Action< Type.PauseItem, { id: QueueItemId } >; +export type ResumeItemAction = Action< Type.ResumeItem, { id: QueueItemId } >; +export type PauseQueueAction = Action< Type.PauseQueue >; +export type ResumeQueueAction = Action< Type.ResumeQueue >; +export type RemoveAction = Action< Type.Remove, { id: QueueItemId } >; +export type CacheBlobUrlAction = Action< + Type.CacheBlobUrl, + { id: QueueItemId; blobUrl: string } +>; +export type RevokeBlobUrlsAction = Action< + Type.RevokeBlobUrls, + { id: QueueItemId } +>; +export type UpdateSettingsAction = Action< + Type.UpdateSettings, + { settings: Partial< Settings > } +>; + +interface UploadMediaArgs { + // Additional data to include in the request. + additionalData?: AdditionalData; + // Array with the types of media that can be uploaded, if unset all types are allowed. + allowedTypes?: string[]; + // List of files. + filesList: File[]; + // Maximum upload size in bytes allowed for the site. + maxUploadFileSize?: number; + // Function called when an error happens. + onError?: OnErrorHandler; + // Function called each time a file or a temporary representation of the file is available. + onFileChange?: OnChangeHandler; + // Function called once a file has completely finished uploading, including thumbnails. + onSuccess?: OnSuccessHandler; + // List of allowed mime types and file extensions. + wpAllowedMimeTypes?: Record< string, string > | null; + // Abort signal. + signal?: AbortSignal; +} + +interface SideloadMediaArgs { + // Additional data to include in the request. + additionalData?: SideloadAdditionalData; + // File to sideload. + file: File; + // Attachment ID. + attachmentId: number; + // Function called when an error happens. + onError?: OnErrorHandler; + // Function called each time a file or a temporary representation of the file is available. + onFileChange?: OnChangeHandler; + // Abort signal. + signal?: AbortSignal; +} + +export interface Settings { + mediaUpload: ( args: UploadMediaArgs ) => void; + mediaSideload: ( args: SideloadMediaArgs ) => void; + imageSizes: Record< string, ImageSizeCrop >; +} + +// Must match the Attachment type from the media-utils package. +export interface Attachment { + id: number; + alt: string; + caption: string; + title: string; + url: string; + filename: string | null; + filesize: number | null; + media_type: 'image' | 'file'; + mime_type: string; + featured_media?: number; + missing_image_sizes?: string[]; + poster?: string; +} + +export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void; +export type OnSuccessHandler = ( attachments: Partial< Attachment >[] ) => void; +export type OnErrorHandler = ( error: Error ) => void; +export type OnBatchSuccessHandler = () => void; + +export enum ItemStatus { + Processing = 'PROCESSING', + Paused = 'PAUSED', + PendingApproval = 'PENDING_APPROVAL', +} + +export enum OperationType { + Prepare = 'PREPARE', + UploadOriginal = 'UPLOAD_ORIGINAL', + ThumbnailGeneration = 'THUMBNAIL_GENERATION', + ResizeCrop = 'RESIZE_CROP', + TranscodeImage = 'TRANSCODE_IMAGE', + Compress = 'TRANSCODE_COMPRESS', + GenerateMetadata = 'GENERATE_METADATA', + Upload = 'UPLOAD', +} + +export interface OperationArgs { + [ OperationType.TranscodeImage ]: { + requireApproval?: boolean; + outputFormat?: ImageFormat; + outputQuality?: number; + interlaced?: boolean; + }; + [ OperationType.ResizeCrop ]: { resize?: ImageSizeCrop }; + [ OperationType.UploadOriginal ]: { force?: boolean }; +} + +type OperationWithArgs< T extends keyof OperationArgs = keyof OperationArgs > = + [ T, OperationArgs[ T ] ]; + +export type Operation = OperationType | OperationWithArgs; + +export type AdditionalData = Record< string, unknown >; + +export type SideloadAdditionalData = Record< string, unknown >; + +export interface ImageSizeCrop { + name?: string; // Only set if dealing with sub-sizes, not for general cropping. + width: number; + height: number; + crop?: + | boolean + | [ 'left' | 'center' | 'right', 'top' | 'center' | 'bottom' ]; +} + +export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif'; + +export type VideoFormat = 'mp4' | 'webm'; + +export type AudioFormat = 'mp3' | 'ogg'; + +export type ThumbnailGeneration = 'server' | 'client' | 'smart'; diff --git a/packages/upload-media/src/store/utils/vips.ts b/packages/upload-media/src/store/utils/vips.ts new file mode 100644 index 00000000000000..013551d93ef52b --- /dev/null +++ b/packages/upload-media/src/store/utils/vips.ts @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { createWorkerFactory } from '@shopify/web-worker'; + +/** + * Internal dependencies + */ +import { ImageFile } from '../../image-file'; +import { getFileBasename } from '../../utils'; +import type { ImageSizeCrop, QueueItemId } from '../types'; + +const createVipsWorker = createWorkerFactory( + () => import( /* webpackChunkName: 'vips-worker' */ '@wordpress/vips' ) +); +const vipsWorker = createVipsWorker(); + +export async function vipsConvertImageFormat( + id: QueueItemId, + file: File, + type: + | 'image/jpeg' + | 'image/png' + | 'image/webp' + | 'image/avif' + | 'image/gif', + quality: number, + interlaced?: boolean +) { + const buffer = await vipsWorker.convertImageFormat( + id, + await file.arrayBuffer(), + file.type, + type, + quality, + interlaced + ); + const ext = type.split( '/' )[ 1 ]; + const fileName = `${ getFileBasename( file.name ) }.${ ext }`; + return new File( [ new Blob( [ buffer ] ) ], fileName, { type } ); +} + +export async function vipsCompressImage( + id: QueueItemId, + file: File, + quality: number, + interlaced?: boolean +) { + const buffer = await vipsWorker.compressImage( + id, + await file.arrayBuffer(), + file.type, + quality, + interlaced + ); + return new File( + [ new Blob( [ buffer ], { type: file.type } ) ], + file.name, + { type: file.type } + ); +} + +export async function vipsHasTransparency( url: string ) { + return vipsWorker.hasTransparency( + await ( await fetch( url ) ).arrayBuffer() + ); +} + +export async function vipsResizeImage( + id: QueueItemId, + file: File, + resize: ImageSizeCrop, + smartCrop: boolean, + addSuffix: boolean +) { + const { buffer, width, height, originalWidth, originalHeight } = + await vipsWorker.resizeImage( + id, + await file.arrayBuffer(), + file.type, + resize, + smartCrop + ); + + let fileName = file.name; + + if ( addSuffix && ( originalWidth > width || originalHeight > height ) ) { + const basename = getFileBasename( file.name ); + fileName = file.name.replace( + basename, + `${ basename }-${ width }x${ height }` + ); + } + + return new ImageFile( + new File( [ new Blob( [ buffer ], { type: file.type } ) ], fileName, { + type: file.type, + } ), + width, + height, + originalWidth, + originalHeight + ); +} + +/** + * Cancels all ongoing image operations for the given item. + * + * @param id Queue item ID to cancel operations for. + */ +export async function vipsCancelOperations( id: QueueItemId ) { + return vipsWorker.cancelOperations( id ); +} diff --git a/packages/upload-media/src/stub-file.ts b/packages/upload-media/src/stub-file.ts new file mode 100644 index 00000000000000..f308c0d48b6f49 --- /dev/null +++ b/packages/upload-media/src/stub-file.ts @@ -0,0 +1,5 @@ +export class StubFile extends File { + constructor( fileName = 'stub-file' ) { + super( [], fileName ); + } +} diff --git a/packages/upload-media/src/test/get-file-basename.ts b/packages/upload-media/src/test/get-file-basename.ts new file mode 100644 index 00000000000000..6bf968a7643468 --- /dev/null +++ b/packages/upload-media/src/test/get-file-basename.ts @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import { getFileBasename } from '../utils'; + +describe( 'getFileBasename', () => { + it.each( [ + [ 'my-video.mp4', 'my-video' ], + [ 'my.video.mp4', 'my.video' ], + [ 'my-video', 'my-video' ], + [ '', '' ], + ] )( 'for file name %s returns basename %s', ( fileName, baseName ) => { + expect( getFileBasename( fileName ) ).toStrictEqual( baseName ); + } ); +} ); diff --git a/packages/upload-media/src/test/get-file-extension.ts b/packages/upload-media/src/test/get-file-extension.ts new file mode 100644 index 00000000000000..b26c4571be73fc --- /dev/null +++ b/packages/upload-media/src/test/get-file-extension.ts @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import { getFileExtension } from '../utils'; + +describe( 'getFileExtension', () => { + it.each( [ + [ 'my-video.mp4', 'mp4' ], + [ 'my.video.mp4', 'mp4' ], + [ 'my-video', null ], + [ '', null ], + ] )( 'for file name %s returns extension %s', ( fileName, baseName ) => { + expect( getFileExtension( fileName ) ).toStrictEqual( baseName ); + } ); +} ); diff --git a/packages/upload-media/src/test/get-file-name-from-url.ts b/packages/upload-media/src/test/get-file-name-from-url.ts new file mode 100644 index 00000000000000..6e2d497472e762 --- /dev/null +++ b/packages/upload-media/src/test/get-file-name-from-url.ts @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { getFileNameFromUrl } from '../utils'; + +describe( 'getFileNameFromUrl', () => { + it.each( [ + [ 'https://example.com/', 'unnamed' ], + [ 'https://example.com/photo.jpeg', 'photo.jpeg' ], + [ 'https://example.com/path/to/video.mp4', 'video.mp4' ], + ] )( 'for %s returns %s', ( url, fileName ) => { + expect( getFileNameFromUrl( url ) ).toBe( fileName ); + } ); +} ); diff --git a/packages/upload-media/src/test/image-file.ts b/packages/upload-media/src/test/image-file.ts new file mode 100644 index 00000000000000..e48ae2df6ebcef --- /dev/null +++ b/packages/upload-media/src/test/image-file.ts @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import { ImageFile } from '../image-file'; + +describe( 'ImageFile', () => { + it( 'returns whether the file was resizes', () => { + const file = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', + } ); + + const image = new ImageFile( file, 1000, 1000, 2000, 200 ); + expect( image.wasResized ).toBe( true ); + } ); +} ); diff --git a/packages/upload-media/src/test/upload-error.ts b/packages/upload-media/src/test/upload-error.ts new file mode 100644 index 00000000000000..4d5f025ed8cf39 --- /dev/null +++ b/packages/upload-media/src/test/upload-error.ts @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { UploadError } from '../upload-error'; + +describe( 'UploadError', () => { + it( 'holds error code and file name', () => { + const file = new File( [], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + + const error = new UploadError( { + code: 'some_error', + message: 'An error occurred', + file, + } ); + + expect( error ).toStrictEqual( expect.any( Error ) ); + expect( error.code ).toBe( 'some_error' ); + expect( error.message ).toBe( 'An error occurred' ); + expect( error.file ).toBe( file ); + } ); +} ); diff --git a/packages/upload-media/src/upload-error.ts b/packages/upload-media/src/upload-error.ts new file mode 100644 index 00000000000000..d712e9dcdb6966 --- /dev/null +++ b/packages/upload-media/src/upload-error.ts @@ -0,0 +1,26 @@ +interface UploadErrorArgs { + code: string; + message: string; + file: File; + cause?: Error; +} + +/** + * MediaError class. + * + * Small wrapper around the `Error` class + * to hold an error code and a reference to a file object. + */ +export class UploadError extends Error { + code: string; + file: File; + + constructor( { code, message, file, cause }: UploadErrorArgs ) { + super( message, { cause } ); + + Object.setPrototypeOf( this, new.target.prototype ); + + this.code = code; + this.file = file; + } +} diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts new file mode 100644 index 00000000000000..397ee2ca39fc53 --- /dev/null +++ b/packages/upload-media/src/utils.ts @@ -0,0 +1,160 @@ +/** + * WordPress dependencies + */ +import { getFilename } from '@wordpress/url'; +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; + +/** + * Converts a Blob to a File with a default name like "image.png". + * + * If it is already a File object, it is returned unchanged. + * + * @param fileOrBlob Blob object. + * @return File object. + */ +export function convertBlobToFile( fileOrBlob: Blob | File ): File { + if ( fileOrBlob instanceof File ) { + return fileOrBlob; + } + + // Extension is only an approximation. + // The server will override it if incorrect. + const ext = fileOrBlob.type.split( '/' )[ 1 ]; + const mediaType = + 'application/pdf' === fileOrBlob.type + ? 'document' + : fileOrBlob.type.split( '/' )[ 0 ]; + return new File( [ fileOrBlob ], `${ mediaType }.${ ext }`, { + type: fileOrBlob.type, + } ); +} + +/** + * Renames a given file and returns a new file. + * + * Copies over the last modified time. + * + * @param file File object. + * @param name File name. + * @return Renamed file object. + */ +export function renameFile( file: File, name: string ): File { + return new File( [ file ], name, { + type: file.type, + lastModified: file.lastModified, + } ); +} + +/** + * Clones a given file object. + * + * @param file File object. + * @return New file object. + */ +export function cloneFile( file: File ): File { + return renameFile( file, file.name ); +} + +/** + * Returns the file extension from a given file name or URL. + * + * @param file File URL. + * @return File extension or null if it does not have one. + */ +export function getFileExtension( file: string ): string | null { + return file.includes( '.' ) ? file.split( '.' ).pop() || null : null; +} + +/** + * Returns file basename without extension. + * + * For example, turns "my-awesome-file.jpeg" into "my-awesome-file". + * + * @param name File name. + * @return File basename. + */ +export function getFileBasename( name: string ): string { + return name.includes( '.' ) + ? name.split( '.' ).slice( 0, -1 ).join( '.' ) + : name; +} + +/** + * Returns the file name including extension from a URL. + * + * @param url File URL. + * @return File name. + */ +export function getFileNameFromUrl( url: string ) { + return getFilename( url ) || _x( 'unnamed', 'file name' ); +} + +/** + * Verifies if the caller supports this mime type. + * + * @param file File object. + * @param allowedTypes List of allowed mime types. + */ +export function validateMimeType( file: File, allowedTypes?: string[] ) { + if ( ! allowedTypes ) { + return; + } + + // Allowed type specified by consumer. + const isAllowedType = allowedTypes.some( ( allowedType ) => { + // If a complete mimetype is specified verify if it matches exactly the mime type of the file. + if ( allowedType.includes( '/' ) ) { + return allowedType === file.type; + } + // Otherwise a general mime type is used, and we should verify if the file mimetype starts with it. + return file.type.startsWith( `${ allowedType }/` ); + } ); + + if ( file.type && ! isAllowedType ) { + throw new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: sprintf( + // translators: %s: file name. + __( '%s: Sorry, this file type is not supported here.' ), + file.name + ), + file, + } ); + } +} + +/** + * Determines whether a given file type is supported for client-side processing. + * + * @param type Mime type. + * @return Whether the file type is supported. + */ +export function isImageTypeSupported( + type: string +): type is + | 'image/avif' + | 'image/gif' + | 'image/heic' + | 'image/heif' + | 'image/jpeg' + | 'image/jxl' + | 'image/png' + | 'image/tiff' + | 'image/webp' { + return [ + 'image/avif', + 'image/gif', + 'image/heic', + 'image/heif', + 'image/jpeg', + 'image/jxl', + 'image/png', + 'image/tiff', + 'image/webp', + ].includes( type ); +} diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json new file mode 100644 index 00000000000000..2862cfa4bb1c8c --- /dev/null +++ b/packages/upload-media/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "gutenberg-env" ] + }, + "include": [ "src/**/*" ], + "references": [ + { "path": "../api-fetch" }, + { "path": "../blob" }, + { "path": "../data" }, + { "path": "../i18n" }, + { "path": "../private-apis" }, + { "path": "../url" }, + { "path": "../vips" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 51bb7f2d68924a..8a3c13ff1dc47d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,7 @@ { "path": "packages/sync" }, { "path": "packages/token-list" }, { "path": "packages/undo-manager" }, + { "path": "packages/upload-media" }, { "path": "packages/url" }, { "path": "packages/vips" }, { "path": "packages/warning" }, From 808700ef0d7d1dca02c649f4d60719af6c37a009 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 22 Oct 2024 10:40:53 +0200 Subject: [PATCH 02/27] Add missing readme --- packages/upload-media/README.md | 161 ++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 packages/upload-media/README.md diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md new file mode 100644 index 00000000000000..bcbc8dc7561e28 --- /dev/null +++ b/packages/upload-media/README.md @@ -0,0 +1,161 @@ +# `@wordpress/upload-media` + +Core media upload logic implemented with a custom `@wordpress/data` store. + +## API Reference + +### Actions + +The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'core/upload-media' )`: + + + +#### addItems + +Adds a new item to the upload queue. + +_Parameters_ + +- _$0_ `AddItemsArgs`: +- _$0.files_ `AddItemsArgs[ 'files' ]`: Files +- _$0.onChange_ `[AddItemsArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `[AddItemsArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. +- _$0.onBatchSuccess_ `[AddItemsArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. +- _$0.onError_ `[AddItemsArgs[ 'onError' ]]`: Function called when an error happens. +- _$0.additionalData_ `[AddItemsArgs[ 'additionalData' ]]`: Additional data to include in the request. + +#### cancelItem + +Cancels an item in the queue based on an error. + +_Parameters_ + +- _id_ `QueueItemId`: Item ID. +- _error_ `Error`: Error instance. +- _silent_ Whether to cancel the item silently, without invoking its `onError` callback. + +#### updateSettings + +Returns an action object that pauses all processing in the queue. + +Useful for testing purposes. + +_Parameters_ + +- _settings_ `Partial< Settings >`: + +_Returns_ + +- `UpdateSettingsAction`: Action object. + + + +### Selectors + +The following selectors are available on the object returned by `wp.data.select( 'core/upload-media' )`: + + + +#### getComparisonDataForApproval + +Returns data to compare the old file vs. the optimized file, given the attachment ID. + +Includes both the URLs and the respective file sizes and the size difference in percentage. + +_Parameters_ + +- _state_ `State`: Upload state. +- _attachmentId_ `number`: Attachment ID. + +_Returns_ + +- `{ oldUrl: string | undefined; oldSize: number; newSize: number; newUrl: string | undefined; sizeDiff: number; } | null`: Comparison data. + +#### getItems + +Returns all items currently being uploaded, without sub-sizes (children). + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `QueueItem[]`: Queue items. + +#### getSettings + +Returns the media upload settings. + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `Settings`: Settings + +#### isPendingApproval + +Determines whether there is an item pending approval. + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `boolean`: Whether there is an item pending approval. + +#### isPendingApprovalByAttachmentId + +Determines whether an item is the first one pending approval given its associated attachment ID. + +_Parameters_ + +- _state_ `State`: Upload state. +- _attachmentId_ `number`: Attachment ID. + +_Returns_ + +- `boolean`: Whether the item is first in the list of items pending approval. + +#### isUploading + +Determines whether any upload is currently in progress. + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `boolean`: Whether any upload is currently in progress. + +#### isUploadingById + +Determines whether an upload is currently in progress given an attachment ID. + +_Parameters_ + +- _state_ `State`: Upload state. +- _attachmentId_ `number`: Attachment ID. + +_Returns_ + +- `boolean`: Whether upload is currently in progress for the given attachment. + +#### isUploadingByUrl + +Determines whether an upload is currently in progress given an attachment URL. + +_Parameters_ + +- _state_ `State`: Upload state. +- _url_ `string`: Attachment URL. + +_Returns_ + +- `boolean`: Whether upload is currently in progress for the given attachment. + + From 91f7687e9afeaf79f300ff9e7f2cb22efb768fd1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 24 Oct 2024 15:13:47 +0200 Subject: [PATCH 03/27] Make `updateSettings` private --- packages/upload-media/README.md | 14 ------------- packages/upload-media/src/store/actions.ts | 19 ------------------ .../upload-media/src/store/private-actions.ts | 20 +++++++++++++++++++ 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index bcbc8dc7561e28..d49460543143a8 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -34,20 +34,6 @@ _Parameters_ - _error_ `Error`: Error instance. - _silent_ Whether to cancel the item silently, without invoking its `onError` callback. -#### updateSettings - -Returns an action object that pauses all processing in the queue. - -Useful for testing purposes. - -_Parameters_ - -- _settings_ `Partial< Settings >`: - -_Returns_ - -- `UpdateSettingsAction`: Action object. - ### Selectors diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index 1bca0f9729cf04..222123a2e9bd4c 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -22,9 +22,7 @@ import type { OnSuccessHandler, QueueItem, QueueItemId, - Settings, State, - UpdateSettingsAction, } from './types'; import { Type } from './types'; import type { @@ -59,23 +57,6 @@ type ThunkArgs = { registry: WPDataRegistry; }; -/** - * Returns an action object that pauses all processing in the queue. - * - * Useful for testing purposes. - * - * @param settings - * @return Action object. - */ -export function updateSettings( - settings: Partial< Settings > -): UpdateSettingsAction { - return { - type: Type.UpdateSettings, - settings, - }; -} - interface AddItemsArgs { files: File[]; onChange?: OnChangeHandler; diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index ba2176998a4470..b51ac283d19113 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -51,9 +51,11 @@ import type { ResumeItemAction, ResumeQueueAction, RevokeBlobUrlsAction, + Settings, SideloadAdditionalData, State, ThumbnailGeneration, + UpdateSettingsAction, } from './types'; import { ItemStatus, OperationType, Type } from './types'; import type { cancelItem } from './actions'; @@ -987,3 +989,21 @@ export function revokeBlobUrls( id: QueueItemId ) { } ); }; } + + +/** + * Returns an action object that pauses all processing in the queue. + * + * Useful for testing purposes. + * + * @param settings + * @return Action object. + */ +export function updateSettings( + settings: Partial< Settings > +): UpdateSettingsAction { + return { + type: Type.UpdateSettings, + settings, + }; +} From b1b0cb5160828fc5e76e6aefa9636084e28292f4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 24 Oct 2024 15:27:10 +0200 Subject: [PATCH 04/27] Remove empty line --- packages/upload-media/src/store/private-actions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index b51ac283d19113..4c9702c20fd70c 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -990,7 +990,6 @@ export function revokeBlobUrls( id: QueueItemId ) { }; } - /** * Returns an action object that pauses all processing in the queue. * From 5d00adbc775af24ea3f2d3a37e1dbe8ee14d77c6 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 24 Oct 2024 17:35:50 +0200 Subject: [PATCH 05/27] Add back some of the missing utils --- package-lock.json | 1 + packages/upload-media/package.json | 1 + packages/upload-media/src/store/actions.ts | 165 +++++++++++++++++- .../upload-media/src/store/private-actions.ts | 65 +++++++ packages/upload-media/src/store/types.ts | 11 ++ packages/upload-media/src/utils.ts | 45 +++++ 6 files changed, 287 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e2a447edee2ee4..c0182f88ba5250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56000,6 +56000,7 @@ "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", "@wordpress/vips": "file:../vips", + "mime": "^3.0.0", "uuid": "^9.0.1" }, "engines": { diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 88ed76685ca068..0711ebb12231ab 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -35,6 +35,7 @@ "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", "@wordpress/vips": "file:../vips", + "mime": "^3.0.0", "uuid": "^9.0.1" }, "publishConfig": { diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index 222123a2e9bd4c..ed8e614c9c72bb 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -8,13 +8,18 @@ import { v4 as uuidv4 } from 'uuid'; */ // eslint-disable-next-line no-restricted-syntax import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; +// @ts-ignore -- No types available yet. +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import { vipsCancelOperations } from './utils/vips'; import type { + AddAction, AdditionalData, + ApproveUploadAction, + BatchId, CancelAction, OnBatchSuccessHandler, OnChangeHandler, @@ -23,14 +28,19 @@ import type { QueueItem, QueueItemId, State, + ThumbnailGeneration, } from './types'; -import { Type } from './types'; +import { ItemStatus, OperationType, Type } from './types'; import type { addItem, processItem, removeItem, revokeBlobUrls, } from './private-actions'; +import { UploadError } from '../upload-error'; +import { StubFile } from '../stub-file'; +import { getFileBasename, getFileNameFromUrl } from '../utils'; +import { PREFERENCES_NAME } from '../constants'; type ActionCreators = { addItem: typeof addItem; @@ -101,6 +111,159 @@ export function addItems( { }; } +interface OptimizeExistingItemArgs { + id: number; + url: string; + fileName?: string; + poster?: string; + batchId?: BatchId; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onBatchSuccess?: OnBatchSuccessHandler; + onError?: OnErrorHandler; + additionalData?: AdditionalData; + startTime?: number; +} + +/** + * Adds a new item to the upload queue for optimizing (compressing) an existing item. + * + * @todo Rename id to sourceAttachmentId for consistency + * + * @param $0 + * @param $0.id Attachment ID. + * @param $0.url URL. + * @param [$0.fileName] File name. + * @param [$0.poster] Poster URL. + * @param [$0.batchId] Batch ID. + * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. + * @param [$0.onSuccess] Function called after the file is uploaded. + * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. + * @param [$0.onError] Function called when an error happens. + * @param [$0.additionalData] Additional data to include in the request. + */ +export function optimizeExistingItem( { + id, + url, + fileName, + poster, + batchId, + onChange, + onSuccess, + onBatchSuccess, + onError, + additionalData = {} as AdditionalData, +}: OptimizeExistingItemArgs ) { + return async ( { dispatch, registry }: ThunkArgs ) => { + fileName = fileName || getFileNameFromUrl( url ); + const baseName = getFileBasename( fileName ); + const newFileName = fileName.replace( + baseName, + `${ baseName }-optimized` + ); + + const requireApproval = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'requireApproval' ); + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + // TODO: Same considerations apply as for muteExistingVideo. + + const abortController = new AbortController(); + + const itemId = uuidv4(); + + dispatch< AddAction >( { + type: Type.Add, + item: { + id: itemId, + batchId, + status: ItemStatus.Processing, + sourceFile: new StubFile(), + file: new StubFile(), + attachment: { + url, + poster, + }, + additionalData: { + generate_sub_sizes: 'server' === thumbnailGeneration, + convert_format: false, + ...additionalData, + }, + onChange, + onSuccess, + onBatchSuccess, + onError, + sourceUrl: url, + sourceAttachmentId: id, + operations: [ + [ + OperationType.FetchRemoteFile, + { url, fileName, newFileName }, + ], + [ OperationType.Compress, { requireApproval } ], + OperationType.GenerateMetadata, + OperationType.Upload, + OperationType.ThumbnailGeneration, + ], + abortController, + }, + } ); + + dispatch.processItem( itemId ); + }; +} + +/** + * Rejects a proposed optimized/converted version of a file + * by essentially cancelling its further processing. + * + * @param id Item ID. + */ +export function rejectApproval( id: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { + return; + } + + dispatch.cancelItem( + item.id, + new UploadError( { + code: 'UPLOAD_CANCELLED', + message: 'File upload was cancelled', + file: item.file, + } ), + true + ); + }; +} + +/** + * Approves a proposed optimized/converted version of a file + * so it can continue being processed and uploaded. + * + * @param id Item ID. + */ +export function grantApproval( id: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { + return; + } + + dispatch< ApproveUploadAction >( { + type: Type.ApproveUpload, + id: item.id, + } ); + + dispatch.processItem( item.id ); + }; +} + /** * Cancels an item in the queue based on an error. * diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 4c9702c20fd70c..f1c7e1000f7505 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -20,10 +20,12 @@ import { UploadError } from '../upload-error'; import { cloneFile, convertBlobToFile, + fetchFile, getFileBasename, getFileExtension, isImageTypeSupported, renameFile, + validateMimeType, } from '../utils'; import { PREFERENCES_NAME } from '../constants'; import { StubFile } from '../stub-file'; @@ -76,6 +78,7 @@ type ActionCreators = { generateThumbnails: typeof generateThumbnails; uploadOriginal: typeof uploadOriginal; revokeBlobUrls: typeof revokeBlobUrls; + fetchRemoteFile: typeof fetchRemoteFile; < T = Record< string, unknown > >( args: T ): void; }; @@ -407,6 +410,13 @@ export function processItem( id: QueueItemId ) { operationArgs as OperationArgs[ OperationType.UploadOriginal ] ); break; + + case OperationType.FetchRemoteFile: + dispatch.fetchRemoteFile( + id, + operationArgs as OperationArgs[ OperationType.FetchRemoteFile ] + ); + break; } }; } @@ -970,6 +980,61 @@ export function sideloadItem( id: QueueItemId ) { }; } +type FetchRemoteFileArgs = OperationArgs[ OperationType.FetchRemoteFile ]; + +/** + * Fetches a remote file from another server and adds it to the item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function fetchRemoteFile( id: QueueItemId, args: FetchRemoteFileArgs ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const sourceFile = await fetchFile( args.url, args.fileName ); + + validateMimeType( sourceFile, args.allowedTypes ); + + if ( args.skipAttachment ) { + dispatch.finishOperation( id, { + sourceFile, + } ); + } else { + const file = args.newFileName + ? renameFile( cloneFile( sourceFile ), args.newFileName ) + : cloneFile( sourceFile ); + + const blobUrl = createBlobURL( sourceFile ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + sourceFile, + file, + attachment: { + url: blobUrl, + }, + } ); + } + } catch ( error ) { + dispatch.cancelItem( + id, + new UploadError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'Remote file could not be downloaded', + file: item.file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + }; +} + /** * Revokes all blob URLs for a given item, freeing up memory. * diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 49397dd8ae04db..6218dc67d2af37 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -185,11 +185,22 @@ export enum OperationType { ResizeCrop = 'RESIZE_CROP', TranscodeImage = 'TRANSCODE_IMAGE', Compress = 'TRANSCODE_COMPRESS', + FetchRemoteFile = 'FETCH_REMOTE_FILE', GenerateMetadata = 'GENERATE_METADATA', Upload = 'UPLOAD', } export interface OperationArgs { + [ OperationType.Compress ]: { + requireApproval?: boolean; + }; + [ OperationType.FetchRemoteFile ]: { + url: string; + fileName: string; + newFileName?: string; + skipAttachment?: boolean; + allowedTypes?: string[]; + }; [ OperationType.TranscodeImage ]: { requireApproval?: boolean; outputFormat?: ImageFormat; diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts index 397ee2ca39fc53..9c64cc1b8e6451 100644 --- a/packages/upload-media/src/utils.ts +++ b/packages/upload-media/src/utils.ts @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import mime from 'mime/lite'; + /** * WordPress dependencies */ @@ -94,6 +99,46 @@ export function getFileNameFromUrl( url: string ) { return getFilename( url ) || _x( 'unnamed', 'file name' ); } +/** + * Fetches a remote file and returns a File instance. + * + * @param url URL. + * @param nameOverride File name to use, instead of deriving it from the URL. + */ +export async function fetchFile( url: string, nameOverride?: string ) { + const response = await fetch( url ); + if ( ! response.ok ) { + throw new Error( `Could not fetch remote file: ${ response.status }` ); + } + + const name = nameOverride || getFileNameFromUrl( url ); + const blob = await response.blob(); + + const ext = getFileExtension( name ); + const guessedMimeType = ext ? mime.getType( ext ) : ''; + + let type = ''; + + // blob.type can be an empty string when server does not return a correct Content-Type. + if ( blob.type && blob.type !== 'application/octet-stream' ) { + type = blob.type; + } else if ( guessedMimeType ) { + type = guessedMimeType; + } + + const file = new File( [ blob ], name, { type } ); + + if ( ! type ) { + throw new UploadError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'File could not be downloaded', + file, + } ); + } + + return file; +} + /** * Verifies if the caller supports this mime type. * From 07680caf06294309a01cfbcc87f7b26bca5224aa Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 24 Oct 2024 18:01:07 +0200 Subject: [PATCH 06/27] Update docs now --- packages/upload-media/README.md | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index d49460543143a8..86f7d410918185 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -34,6 +34,40 @@ _Parameters_ - _error_ `Error`: Error instance. - _silent_ Whether to cancel the item silently, without invoking its `onError` callback. +#### grantApproval + +Approves a proposed optimized/converted version of a file so it can continue being processed and uploaded. + +_Parameters_ + +- _id_ `number`: Item ID. + +#### optimizeExistingItem + +Adds a new item to the upload queue for optimizing (compressing) an existing item. + +_Parameters_ + +- _$0_ `OptimizeExistingItemArgs`: +- _$0.id_ `OptimizeExistingItemArgs[ 'id' ]`: Attachment ID. +- _$0.url_ `OptimizeExistingItemArgs[ 'url' ]`: URL. +- _$0.fileName_ `[OptimizeExistingItemArgs[ 'fileName' ]]`: File name. +- _$0.poster_ `[OptimizeExistingItemArgs[ 'poster' ]]`: Poster URL. +- _$0.batchId_ `[OptimizeExistingItemArgs[ 'batchId' ]]`: Batch ID. +- _$0.onChange_ `[OptimizeExistingItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `[OptimizeExistingItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. +- _$0.onBatchSuccess_ `[OptimizeExistingItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. +- _$0.onError_ `[OptimizeExistingItemArgs[ 'onError' ]]`: Function called when an error happens. +- _$0.additionalData_ `[OptimizeExistingItemArgs[ 'additionalData' ]]`: Additional data to include in the request. + +#### rejectApproval + +Rejects a proposed optimized/converted version of a file by essentially cancelling its further processing. + +_Parameters_ + +- _id_ `number`: Item ID. + ### Selectors From 38c3df63185f51249a44e181894dbb69d5963e73 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Thu, 24 Oct 2024 10:31:31 -0500 Subject: [PATCH 07/27] Remove meetings (#66421) Co-authored-by: ndiego Co-authored-by: fabiankaegy Co-authored-by: carolinan --- docs/contributors/code/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contributors/code/README.md b/docs/contributors/code/README.md index 848aa8bc26bbd1..4c965e69dbcefa 100644 --- a/docs/contributors/code/README.md +++ b/docs/contributors/code/README.md @@ -4,13 +4,13 @@ A guide on how to get started contributing code to the Gutenberg project. ## Discussions -The [Make WordPress Core blog](https://make.wordpress.org/core/) is the primary spot for the latest information around WordPress development: including announcements, product goals, meeting notes, meeting agendas, and more. +The [Make WordPress Core blog](https://make.wordpress.org/core/) is the primary spot for the latest information around WordPress development, including announcements, product goals, meeting notes, meeting agendas, and more. -Real-time discussions for development take place in `#core-editor` and `#core-js` channels in [Make WordPress Slack](https://make.wordpress.org/chat) (registration required). Weekly meetings for the editor component are on Wednesdays at 14:00UTC, and for the JavaScript component on Tuesday at 15:00UTC, in their respective Slack channels. +Development discussions take place in real-time in the `#core-editor` and `#core-js` channels in [Make WordPress Slack](https://make.wordpress.org/chat) (registration required). ## Development Hub -The Gutenberg project uses GitHub for managing code and tracking issues. The main repository is at: [https://github.com/WordPress/gutenberg](https://github.com/WordPress/gutenberg). +The Gutenberg project uses GitHub to manage code and track issues. The main repository is at: [https://github.com/WordPress/gutenberg](https://github.com/WordPress/gutenberg). Browse [the issues list](https://github.com/wordpress/gutenberg/issues) to find issues to work on. The [good first issue](https://github.com/wordpress/gutenberg/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+First+Issue%22) and [good first review](https://github.com/WordPress/gutenberg/pulls?q=is%3Aopen+is%3Apr+label%3A%22Good+First+Review%22) labels are good starting points. From 06e345e39358db94eb02dea741114cafe5a2c5a3 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Thu, 24 Oct 2024 20:44:26 +0200 Subject: [PATCH 08/27] Site editor: remove "default" admin CSS (#66431) --- backport-changelog/6.8/7642.md | 3 +++ lib/compat/wordpress-6.8/remove-default-css.php | 11 +++++++++++ lib/load.php | 1 + 3 files changed, 15 insertions(+) create mode 100644 backport-changelog/6.8/7642.md create mode 100644 lib/compat/wordpress-6.8/remove-default-css.php diff --git a/backport-changelog/6.8/7642.md b/backport-changelog/6.8/7642.md new file mode 100644 index 00000000000000..f00d4a5473aac8 --- /dev/null +++ b/backport-changelog/6.8/7642.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7642 + +* https://github.com/WordPress/gutenberg/pull/66431 \ No newline at end of file diff --git a/lib/compat/wordpress-6.8/remove-default-css.php b/lib/compat/wordpress-6.8/remove-default-css.php new file mode 100644 index 00000000000000..1076d84f299214 --- /dev/null +++ b/lib/compat/wordpress-6.8/remove-default-css.php @@ -0,0 +1,11 @@ + Date: Fri, 25 Oct 2024 11:26:29 +0900 Subject: [PATCH 09/27] Fix: JSON Schema Docgen doesn't work on Windows OS (#66414) * Fix: JSON Schema Docgen doesn't work on Windows OS * Update bin/api-docs/gen-theme-reference.mjs Co-authored-by: Jon Surrell * Lint --------- Co-authored-by: t-hamano Co-authored-by: sirreal --- bin/api-docs/gen-theme-reference.mjs | 29 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/bin/api-docs/gen-theme-reference.mjs b/bin/api-docs/gen-theme-reference.mjs index 6dc7791e288b9e..f50d194c17ee79 100644 --- a/bin/api-docs/gen-theme-reference.mjs +++ b/bin/api-docs/gen-theme-reference.mjs @@ -9,6 +9,7 @@ */ import fs from 'node:fs/promises'; import $RefParser from '@apidevtools/json-schema-ref-parser'; +import { fileURLToPath } from 'node:url'; /** * @typedef {import('@apidevtools/json-schema-ref-parser').JSONSchema} JSONSchema @@ -19,9 +20,8 @@ import $RefParser from '@apidevtools/json-schema-ref-parser'; * * @type {URL} */ -const THEME_JSON_SCHEMA_URL = new URL( - '../../schemas/json/theme.json', - import.meta.url +const THEME_JSON_SCHEMA_PATH = fileURLToPath( + new URL( '../../schemas/json/theme.json', import.meta.url ) ); /** @@ -29,9 +29,11 @@ const THEME_JSON_SCHEMA_URL = new URL( * * @type {URL} */ -const REFERENCE_DOC_URL = new URL( - '../../docs/reference-guides/theme-json-reference/theme-json-living.md', - import.meta.url +const REFERENCE_DOC_PATH = fileURLToPath( + new URL( + '../../docs/reference-guides/theme-json-reference/theme-json-living.md', + import.meta.url + ) ); /** @@ -265,15 +267,12 @@ function generateDocs( themeJson ) { * Main function. */ async function main() { - const themeJson = await $RefParser.dereference( - THEME_JSON_SCHEMA_URL.pathname, - { - parse: { binary: false, text: false, yaml: false }, - resolve: { external: false }, - } - ); + const themeJson = await $RefParser.dereference( THEME_JSON_SCHEMA_PATH, { + parse: { binary: false, text: false, yaml: false }, + resolve: { external: false }, + } ); - const themeJsonReference = await fs.readFile( REFERENCE_DOC_URL, { + const themeJsonReference = await fs.readFile( REFERENCE_DOC_PATH, { encoding: 'utf8', flag: 'r', } ); @@ -285,7 +284,7 @@ async function main() { `${ START_TOKEN }\n${ generatedDocs }\n${ END_TOKEN }` ); - await fs.writeFile( REFERENCE_DOC_URL, updatedThemeJsonReference, { + await fs.writeFile( REFERENCE_DOC_PATH, updatedThemeJsonReference, { encoding: 'utf8', } ); } From a198bd2b61cf452d10df2fa9fc2ef76ab5fc7de9 Mon Sep 17 00:00:00 2001 From: Mitchell Austin Date: Thu, 24 Oct 2024 19:33:52 -0700 Subject: [PATCH 10/27] Revise zoom layout shift fix (#66390) * Contain margins with BFC instead of border * Move to block canvas styles in the visual editor --------- Co-authored-by: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Co-authored-by: stokesman Co-authored-by: andrewserong Co-authored-by: ramonjd Co-authored-by: t-hamano --- packages/block-editor/src/components/iframe/content.scss | 1 - packages/editor/src/components/visual-editor/index.js | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/block-editor/src/components/iframe/content.scss b/packages/block-editor/src/components/iframe/content.scss index 1d01f84b5fd1fd..596c177eab2f32 100644 --- a/packages/block-editor/src/components/iframe/content.scss +++ b/packages/block-editor/src/components/iframe/content.scss @@ -1,6 +1,5 @@ .block-editor-iframe__body { position: relative; - border: 0.01px solid transparent; } .block-editor-iframe__html { diff --git a/packages/editor/src/components/visual-editor/index.js b/packages/editor/src/components/visual-editor/index.js index 133057d9f388f1..1c8eb5c3b77640 100644 --- a/packages/editor/src/components/visual-editor/index.js +++ b/packages/editor/src/components/visual-editor/index.js @@ -356,7 +356,10 @@ function VisualEditor( { return [ ...( styles ?? [] ), { - css: `.is-root-container{display:flow-root;${ + // Ensures margins of children are contained so that the body background paints behind them. + // Otherwise, the background of html (when zoomed out) would show there and appear broken. It’s + // important mostly for post-only views yet conceivably an issue in templated views too. + css: `:where(.block-editor-iframe__body){display:flow-root;}.is-root-container{display:flow-root;${ // Some themes will have `min-height: 100vh` for the root container, // which isn't a requirement in auto resize mode. enableResizing ? 'min-height:0!important;' : '' From 4d4142c4495f4d1906053ee85e946ee587f9b414 Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Fri, 25 Oct 2024 09:34:20 +0300 Subject: [PATCH 11/27] Zoom out: Add keyboard shortcut in editor (#66400) Co-authored-by: ntsekouras Co-authored-by: Mamaduka Co-authored-by: t-hamano Co-authored-by: youknowriad Co-authored-by: stokesman --- .../src/components/zoom-out-toggle/index.js | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/zoom-out-toggle/index.js b/packages/editor/src/components/zoom-out-toggle/index.js index 619cc06c689c0b..81506add699c97 100644 --- a/packages/editor/src/components/zoom-out-toggle/index.js +++ b/packages/editor/src/components/zoom-out-toggle/index.js @@ -3,11 +3,15 @@ */ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; - +import { useEffect } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { square as zoomOutIcon } from '@wordpress/icons'; import { store as preferencesStore } from '@wordpress/preferences'; +import { + useShortcut, + store as keyboardShortcutsStore, +} from '@wordpress/keyboard-shortcuts'; /** * Internal dependencies @@ -26,6 +30,32 @@ const ZoomOutToggle = ( { disabled } ) => { const { resetZoomLevel, setZoomLevel } = unlock( useDispatch( blockEditorStore ) ); + const { registerShortcut, unregisterShortcut } = useDispatch( + keyboardShortcutsStore + ); + + useEffect( () => { + registerShortcut( { + name: 'core/editor/zoom', + category: 'global', + description: __( 'Enter or exit zoom out.' ), + keyCombination: { + modifier: 'primaryShift', + character: '0', + }, + } ); + return () => { + unregisterShortcut( 'core/editor/zoom' ); + }; + }, [ registerShortcut, unregisterShortcut ] ); + + useShortcut( 'core/editor/zoom', () => { + if ( isZoomOut ) { + resetZoomLevel(); + } else { + setZoomLevel( 'auto-scaled' ); + } + } ); const handleZoomOut = () => { if ( isZoomOut ) { From 7f2549c5053e1842ef7aeab3ced04bda38f7ca46 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Fri, 25 Oct 2024 12:07:01 +0400 Subject: [PATCH 12/27] Compose: Fix React Complier error for 'useCopyToClipboard' (#66444) Co-authored-by: Mamaduka Co-authored-by: youknowriad --- packages/compose/src/hooks/use-copy-to-clipboard/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compose/src/hooks/use-copy-to-clipboard/index.js b/packages/compose/src/hooks/use-copy-to-clipboard/index.js index 94ab6048a4b467..fbd7b77fecc4ce 100644 --- a/packages/compose/src/hooks/use-copy-to-clipboard/index.js +++ b/packages/compose/src/hooks/use-copy-to-clipboard/index.js @@ -6,7 +6,7 @@ import Clipboard from 'clipboard'; /** * WordPress dependencies */ -import { useRef } from '@wordpress/element'; +import { useRef, useLayoutEffect } from '@wordpress/element'; /** * Internal dependencies @@ -20,7 +20,9 @@ import useRefEffect from '../use-ref-effect'; */ function useUpdatedRef( value ) { const ref = useRef( value ); - ref.current = value; + useLayoutEffect( () => { + ref.current = value; + }, [ value ] ); return ref; } From f0b988c4e480123983826cd65eb4a9a3b7abc538 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Fri, 25 Oct 2024 12:27:15 +0400 Subject: [PATCH 13/27] Style Book: Fix React Compiler error (#66445) Co-authored-by: Mamaduka Co-authored-by: ramonjd --- .../edit-site/src/components/style-book/index.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index 93bbc6311c6865..8f4023777868ca 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -179,22 +179,20 @@ function StyleBook( { ( select ) => select( blockEditorStore ).getSettings(), [] ); + const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig ); const settings = useMemo( () => ( { ...originalSettings, + styles: + ! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig ) + ? globalStyles + : originalSettings.styles, isPreviewMode: true, } ), - [ originalSettings ] + [ globalStyles, originalSettings, userConfig ] ); - const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig ); - - settings.styles = - ! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig ) - ? globalStyles - : settings.styles; - return ( Date: Mon, 4 Nov 2024 11:19:00 +0100 Subject: [PATCH 14/27] Add `ts-ignore` --- packages/upload-media/src/store/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts index 08aee8ac00987f..c6a42f6cd395ba 100644 --- a/packages/upload-media/src/store/index.ts +++ b/packages/upload-media/src/store/index.ts @@ -22,5 +22,7 @@ export const store = createReduxStore( STORE_NAME, { } ); register( store ); +// @ts-ignore unlock( store ).registerPrivateActions( privateActions ); +// @ts-ignore unlock( store ).registerPrivateSelectors( privateSelectors ); From 5b3c022e63f03665342e0543c8d29ab54e93e93d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 25 Nov 2024 22:19:15 +0100 Subject: [PATCH 15/27] Block editor: Add `MediaUploadProvider` (#66380) Co-authored-by: Jon Surrell --- package-lock.json | 2 + .../src/components/provider/index.js | 24 +++++++- .../provider/use-media-upload-settings.js | 23 ++++++++ .../provider/use-block-editor-settings.js | 4 ++ .../editor/src/utils/media-sideload/index.js | 13 ++++ .../src/utils/media-sideload/index.native.js | 1 + packages/upload-media/package.json | 2 + .../src/components/provider/index.tsx | 25 ++++++++ .../provider/with-registry-provider.tsx | 59 +++++++++++++++++++ packages/upload-media/src/index.ts | 1 + packages/upload-media/src/store/constants.ts | 1 + packages/upload-media/src/store/index.ts | 17 +++++- packages/upload-media/tsconfig.json | 2 + 13 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 packages/block-editor/src/components/provider/use-media-upload-settings.js create mode 100644 packages/editor/src/utils/media-sideload/index.js create mode 100644 packages/editor/src/utils/media-sideload/index.native.js create mode 100644 packages/upload-media/src/components/provider/index.tsx create mode 100644 packages/upload-media/src/components/provider/with-registry-provider.tsx create mode 100644 packages/upload-media/src/store/constants.ts diff --git a/package-lock.json b/package-lock.json index 298070d78c64f5..f037f767cfb087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56263,7 +56263,9 @@ "@shopify/web-worker": "^6.4.0", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", + "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index abbb122ae3a0e0..38e67f23e60518 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -4,6 +4,8 @@ import { useDispatch } from '@wordpress/data'; import { useEffect } from '@wordpress/element'; import { SlotFillProvider } from '@wordpress/components'; +//eslint-disable-next-line import/no-extraneous-dependencies -- Experimental package, not published. +import { MediaUploadProvider } from '@wordpress/upload-media'; /** * Internal dependencies @@ -14,12 +16,13 @@ import { store as blockEditorStore } from '../../store'; import { BlockRefsProvider } from './block-refs-provider'; import { unlock } from '../../lock-unlock'; import KeyboardShortcuts from '../keyboard-shortcuts'; +import useMediaUploadSettings from './use-media-upload-settings'; /** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */ export const ExperimentalBlockEditorProvider = withRegistryProvider( ( props ) => { - const { children, settings, stripExperimentalSettings = false } = props; + const { settings, stripExperimentalSettings = false } = props; const { __experimentalUpdateSettings } = unlock( useDispatch( blockEditorStore ) @@ -44,12 +47,27 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( // Syncs the entity provider with changes in the block-editor store. useBlockSync( props ); - return ( + const children = ( { ! settings?.isPreviewMode && } - { children } + { props.children } ); + + const mediaUploadSettings = useMediaUploadSettings( settings ); + + if ( window.__experimentalMediaProcessing ) { + return ( + + { children } + + ); + } + + return children; } ); diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js new file mode 100644 index 00000000000000..cb8e3b70246854 --- /dev/null +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * React hook used to compute the media upload settings to use in the post editor. + * + * @param {Object} settings Media upload settings prop. + * + * @return {Object} Media upload settings. + */ +function useMediaUploadSettings( settings ) { + return useMemo( + () => ( { + mediaUpload: settings.mediaUpload, + mediaSideload: settings.__experimentalMediaSideload, + } ), + [ settings ] + ); +} + +export default useMediaUploadSettings; diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index f5c45f431e2c85..17c55531906b44 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -23,6 +23,7 @@ import { */ import inserterMediaCategories from '../media-categories'; import { mediaUpload } from '../../utils'; +import { default as mediaSideload } from '../../utils/media-sideload'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; import { useGlobalStylesContext } from '../global-styles-provider'; @@ -290,6 +291,9 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { isDistractionFree, keepCaretInsideBlock, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, + __experimentalMediaSideload: hasUploadPermissions + ? mediaSideload + : undefined, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/editor/src/utils/media-sideload/index.js b/packages/editor/src/utils/media-sideload/index.js new file mode 100644 index 00000000000000..b4aa133fb2d63e --- /dev/null +++ b/packages/editor/src/utils/media-sideload/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { privateApis } from '@wordpress/media-utils'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { sideloadMedia: mediaSideload = () => {} } = unlock( privateApis ); + +export default mediaSideload; diff --git a/packages/editor/src/utils/media-sideload/index.native.js b/packages/editor/src/utils/media-sideload/index.native.js new file mode 100644 index 00000000000000..d84a912ec92de0 --- /dev/null +++ b/packages/editor/src/utils/media-sideload/index.native.js @@ -0,0 +1 @@ +export default function mediaSideload() {} diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 0711ebb12231ab..72523c1bb33a14 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -29,7 +29,9 @@ "@shopify/web-worker": "^6.4.0", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", + "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/upload-media/src/components/provider/index.tsx b/packages/upload-media/src/components/provider/index.tsx new file mode 100644 index 00000000000000..0bc187e6a1d861 --- /dev/null +++ b/packages/upload-media/src/components/provider/index.tsx @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import withRegistryProvider from './with-registry-provider'; +import { unlock } from '../../lock-unlock'; +import { store as uploadStore } from '../../store'; + +const MediaUploadProvider = withRegistryProvider( ( props: any ) => { + const { children, settings } = props; + const { updateSettings } = unlock( useDispatch( uploadStore ) ); + + useEffect( () => { + updateSettings( settings ); + }, [ settings, updateSettings ] ); + + return <>{ children }; +} ); + +export default MediaUploadProvider; diff --git a/packages/upload-media/src/components/provider/with-registry-provider.tsx b/packages/upload-media/src/components/provider/with-registry-provider.tsx new file mode 100644 index 00000000000000..9a47a5601d33ed --- /dev/null +++ b/packages/upload-media/src/components/provider/with-registry-provider.tsx @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { useRegistry, createRegistry, RegistryProvider } from '@wordpress/data'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { storeConfig } from '../../store'; +import { STORE_NAME as mediaUploadStoreName } from '../../store/constants'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; + +function getSubRegistry( + subRegistries: WeakMap< WPDataRegistry, WPDataRegistry >, + registry: WPDataRegistry, + useSubRegistry: boolean +) { + if ( ! useSubRegistry ) { + return registry; + } + let subRegistry = subRegistries.get( registry ); + if ( ! subRegistry ) { + subRegistry = createRegistry( {}, registry ); + subRegistry.registerStore( mediaUploadStoreName, storeConfig ); + subRegistries.set( registry, subRegistry ); + } + return subRegistry; +} + +const withRegistryProvider = createHigherOrderComponent( + ( WrappedComponent ) => + ( { useSubRegistry = true, ...props } ) => { + const registry = useRegistry() as unknown as WPDataRegistry; + const [ subRegistries ] = useState< + WeakMap< WPDataRegistry, WPDataRegistry > + >( () => new WeakMap() ); + const subRegistry = getSubRegistry( + subRegistries, + registry, + useSubRegistry + ); + + if ( subRegistry === registry ) { + return ; + } + + return ( + + + + ); + }, + 'withRegistryProvider' +); + +export default withRegistryProvider; diff --git a/packages/upload-media/src/index.ts b/packages/upload-media/src/index.ts index 63a756727b25f3..406439a5d96f69 100644 --- a/packages/upload-media/src/index.ts +++ b/packages/upload-media/src/index.ts @@ -5,6 +5,7 @@ import { store as uploadStore } from './store'; export { uploadStore as store }; +export { default as MediaUploadProvider } from './components/provider'; export { UploadError } from './upload-error'; export type { diff --git a/packages/upload-media/src/store/constants.ts b/packages/upload-media/src/store/constants.ts new file mode 100644 index 00000000000000..ad0960cf62f46d --- /dev/null +++ b/packages/upload-media/src/store/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'core/upload-media'; diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts index c6a42f6cd395ba..c74f59ea7a7cf3 100644 --- a/packages/upload-media/src/store/index.ts +++ b/packages/upload-media/src/store/index.ts @@ -12,9 +12,24 @@ import * as privateSelectors from './private-selectors'; import * as actions from './actions'; import * as privateActions from './private-actions'; import { unlock } from '../lock-unlock'; +import { STORE_NAME } from './constants'; -export const STORE_NAME = 'core/upload-media'; +/** + * Media upload data store configuration. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#registerStore + */ +export const storeConfig = { + reducer, + selectors, + actions, +}; +/** + * Store definition for the media upload namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + */ export const store = createReduxStore( STORE_NAME, { reducer, selectors, diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json index 2862cfa4bb1c8c..bd7ebfb1023d14 100644 --- a/packages/upload-media/tsconfig.json +++ b/packages/upload-media/tsconfig.json @@ -10,7 +10,9 @@ "references": [ { "path": "../api-fetch" }, { "path": "../blob" }, + { "path": "../compose" }, { "path": "../data" }, + { "path": "../element" }, { "path": "../i18n" }, { "path": "../private-apis" }, { "path": "../url" }, From 1dc086df19951249ab89b101aebcf031331b9c97 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 25 Nov 2024 22:21:50 +0100 Subject: [PATCH 16/27] Change order in `package.json` --- packages/upload-media/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 72523c1bb33a14..cb50ee9d1fd30c 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -24,6 +24,7 @@ }, "main": "build/index.js", "module": "build-module/index.js", + "wpScript": true, "types": "build-types", "dependencies": { "@shopify/web-worker": "^6.4.0", @@ -42,6 +43,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } From 845cf788469c221caa836600f7cc8ebe7523c8e5 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 25 Nov 2024 22:56:00 +0100 Subject: [PATCH 17/27] Rip out most code --- package-lock.json | 2 - packages/upload-media/package.json | 2 - .../provider/with-registry-provider.tsx | 6 +- packages/upload-media/src/constants.ts | 1 - packages/upload-media/src/index.ts | 8 +- packages/upload-media/src/store/actions.ts | 195 +----- .../upload-media/src/store/private-actions.ts | 621 +----------------- .../src/store/private-selectors.ts | 62 -- packages/upload-media/src/store/reducer.ts | 40 -- packages/upload-media/src/store/selectors.ts | 82 +-- .../upload-media/src/store/test/actions.ts | 12 +- .../upload-media/src/store/test/reducer.ts | 52 +- .../upload-media/src/store/test/selectors.ts | 74 --- packages/upload-media/src/store/types.ts | 73 +- packages/upload-media/src/store/utils/vips.ts | 113 ---- packages/upload-media/src/utils.ts | 117 +--- packages/upload-media/tsconfig.json | 3 +- 17 files changed, 38 insertions(+), 1425 deletions(-) delete mode 100644 packages/upload-media/src/constants.ts delete mode 100644 packages/upload-media/src/store/utils/vips.ts diff --git a/package-lock.json b/package-lock.json index f037f767cfb087..1b7d05c0437f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56270,8 +56270,6 @@ "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", - "@wordpress/vips": "file:../vips", - "mime": "^3.0.0", "uuid": "^9.0.1" }, "engines": { diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index cb50ee9d1fd30c..ec7eaabbb3940e 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -37,8 +37,6 @@ "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", - "@wordpress/vips": "file:../vips", - "mime": "^3.0.0", "uuid": "^9.0.1" }, "publishConfig": { diff --git a/packages/upload-media/src/components/provider/with-registry-provider.tsx b/packages/upload-media/src/components/provider/with-registry-provider.tsx index 9a47a5601d33ed..9345a879311913 100644 --- a/packages/upload-media/src/components/provider/with-registry-provider.tsx +++ b/packages/upload-media/src/components/provider/with-registry-provider.tsx @@ -2,7 +2,11 @@ * WordPress dependencies */ import { useState } from '@wordpress/element'; -import { useRegistry, createRegistry, RegistryProvider } from '@wordpress/data'; +import { + useRegistry, + createRegistry, + RegistryProvider, +} from '@wordpress/data'; import { createHigherOrderComponent } from '@wordpress/compose'; /** diff --git a/packages/upload-media/src/constants.ts b/packages/upload-media/src/constants.ts deleted file mode 100644 index 924edff8ea14f3..00000000000000 --- a/packages/upload-media/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const PREFERENCES_NAME = 'core/media'; diff --git a/packages/upload-media/src/index.ts b/packages/upload-media/src/index.ts index 406439a5d96f69..d105c2dba90392 100644 --- a/packages/upload-media/src/index.ts +++ b/packages/upload-media/src/index.ts @@ -8,10 +8,4 @@ export { uploadStore as store }; export { default as MediaUploadProvider } from './components/provider'; export { UploadError } from './upload-error'; -export type { - ImageFormat, - ImageSizeCrop, - ThumbnailGeneration, - VideoFormat, - AudioFormat, -} from './store/types'; +export type { ImageFormat } from './store/types'; diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index ed8e614c9c72bb..d9026f1dbcc885 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -6,41 +6,30 @@ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ -// eslint-disable-next-line no-restricted-syntax -import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; -// @ts-ignore -- No types available yet. -import { store as preferencesStore } from '@wordpress/preferences'; +import type { createRegistry } from '@wordpress/data'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ -import { vipsCancelOperations } from './utils/vips'; import type { - AddAction, AdditionalData, - ApproveUploadAction, - BatchId, CancelAction, OnBatchSuccessHandler, OnChangeHandler, OnErrorHandler, OnSuccessHandler, - QueueItem, QueueItemId, State, - ThumbnailGeneration, } from './types'; -import { ItemStatus, OperationType, Type } from './types'; +import { Type } from './types'; import type { addItem, processItem, removeItem, revokeBlobUrls, } from './private-actions'; -import { UploadError } from '../upload-error'; -import { StubFile } from '../stub-file'; -import { getFileBasename, getFileNameFromUrl } from '../utils'; -import { PREFERENCES_NAME } from '../constants'; type ActionCreators = { addItem: typeof addItem; @@ -111,159 +100,6 @@ export function addItems( { }; } -interface OptimizeExistingItemArgs { - id: number; - url: string; - fileName?: string; - poster?: string; - batchId?: BatchId; - onChange?: OnChangeHandler; - onSuccess?: OnSuccessHandler; - onBatchSuccess?: OnBatchSuccessHandler; - onError?: OnErrorHandler; - additionalData?: AdditionalData; - startTime?: number; -} - -/** - * Adds a new item to the upload queue for optimizing (compressing) an existing item. - * - * @todo Rename id to sourceAttachmentId for consistency - * - * @param $0 - * @param $0.id Attachment ID. - * @param $0.url URL. - * @param [$0.fileName] File name. - * @param [$0.poster] Poster URL. - * @param [$0.batchId] Batch ID. - * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. - * @param [$0.onSuccess] Function called after the file is uploaded. - * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. - * @param [$0.onError] Function called when an error happens. - * @param [$0.additionalData] Additional data to include in the request. - */ -export function optimizeExistingItem( { - id, - url, - fileName, - poster, - batchId, - onChange, - onSuccess, - onBatchSuccess, - onError, - additionalData = {} as AdditionalData, -}: OptimizeExistingItemArgs ) { - return async ( { dispatch, registry }: ThunkArgs ) => { - fileName = fileName || getFileNameFromUrl( url ); - const baseName = getFileBasename( fileName ); - const newFileName = fileName.replace( - baseName, - `${ baseName }-optimized` - ); - - const requireApproval = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'requireApproval' ); - - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - // TODO: Same considerations apply as for muteExistingVideo. - - const abortController = new AbortController(); - - const itemId = uuidv4(); - - dispatch< AddAction >( { - type: Type.Add, - item: { - id: itemId, - batchId, - status: ItemStatus.Processing, - sourceFile: new StubFile(), - file: new StubFile(), - attachment: { - url, - poster, - }, - additionalData: { - generate_sub_sizes: 'server' === thumbnailGeneration, - convert_format: false, - ...additionalData, - }, - onChange, - onSuccess, - onBatchSuccess, - onError, - sourceUrl: url, - sourceAttachmentId: id, - operations: [ - [ - OperationType.FetchRemoteFile, - { url, fileName, newFileName }, - ], - [ OperationType.Compress, { requireApproval } ], - OperationType.GenerateMetadata, - OperationType.Upload, - OperationType.ThumbnailGeneration, - ], - abortController, - }, - } ); - - dispatch.processItem( itemId ); - }; -} - -/** - * Rejects a proposed optimized/converted version of a file - * by essentially cancelling its further processing. - * - * @param id Item ID. - */ -export function rejectApproval( id: number ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItemByAttachmentId( id ); - if ( ! item ) { - return; - } - - dispatch.cancelItem( - item.id, - new UploadError( { - code: 'UPLOAD_CANCELLED', - message: 'File upload was cancelled', - file: item.file, - } ), - true - ); - }; -} - -/** - * Approves a proposed optimized/converted version of a file - * so it can continue being processed and uploaded. - * - * @param id Item ID. - */ -export function grantApproval( id: number ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItemByAttachmentId( id ); - if ( ! item ) { - return; - } - - dispatch< ApproveUploadAction >( { - type: Type.ApproveUpload, - id: item.id, - } ); - - dispatch.processItem( item.id ); - }; -} - /** * Cancels an item in the queue based on an error. * @@ -287,17 +123,9 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { return; } - // When cancelling a parent item, cancel all the children too. - for ( const child of select.getChildItems( id ) ) { - dispatch.cancelItem( child.id, error, silent ); - } - - await vipsCancelOperations( id ); - item.abortController?.abort(); if ( ! silent ) { - // TODO: Do not log error for children if cancelling a parent and all its children. const { onError } = item; onError?.( error ?? new Error( 'Upload cancelled' ) ); if ( ! onError && error ) { @@ -318,21 +146,6 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { // All items of this batch were cancelled or finished. if ( item.batchId && select.isBatchUploaded( item.batchId ) ) { item.onBatchSuccess?.(); - - // All other side-loaded items have been removed, so remove the parent too. - if ( item.parentId ) { - const parentItem = select.getItem( item.parentId ) as QueueItem; - - dispatch.removeItem( item.parentId ); - dispatch.revokeBlobUrls( item.parentId ); - - if ( - parentItem.batchId && - select.isBatchUploaded( parentItem.batchId ) - ) { - parentItem.onBatchSuccess?.(); - } - } } }; } diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index f1c7e1000f7505..03afd89e9db8ff 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -6,44 +6,27 @@ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ -import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; -// eslint-disable-next-line no-restricted-syntax -import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; -// @ts-ignore -- No types available yet. -import { store as preferencesStore } from '@wordpress/preferences'; +import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; +import type { createRegistry } from '@wordpress/data'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ -import { ImageFile } from '../image-file'; -import { UploadError } from '../upload-error'; -import { - cloneFile, - convertBlobToFile, - fetchFile, - getFileBasename, - getFileExtension, - isImageTypeSupported, - renameFile, - validateMimeType, -} from '../utils'; -import { PREFERENCES_NAME } from '../constants'; +import { cloneFile, convertBlobToFile } from '../utils'; import { StubFile } from '../stub-file'; -import { vipsCompressImage, vipsResizeImage } from './utils/vips'; import type { AddAction, AdditionalData, AddOperationsAction, - Attachment, BatchId, CacheBlobUrlAction, - ImageFormat, OnBatchSuccessHandler, OnChangeHandler, OnErrorHandler, OnSuccessHandler, Operation, - OperationArgs, OperationFinishAction, OperationStartAction, PauseItemAction, @@ -54,9 +37,7 @@ import type { ResumeQueueAction, RevokeBlobUrlsAction, Settings, - SideloadAdditionalData, State, - ThumbnailGeneration, UpdateSettingsAction, } from './types'; import { ItemStatus, OperationType, Type } from './types'; @@ -65,20 +46,13 @@ import type { cancelItem } from './actions'; type ActionCreators = { cancelItem: typeof cancelItem; addItem: typeof addItem; - addSideloadItem: typeof addSideloadItem; removeItem: typeof removeItem; prepareItem: typeof prepareItem; processItem: typeof processItem; finishOperation: typeof finishOperation; uploadItem: typeof uploadItem; - sideloadItem: typeof sideloadItem; resumeItem: typeof resumeItem; - resizeCropItem: typeof resizeCropItem; - optimizeImageItem: typeof optimizeImageItem; - generateThumbnails: typeof generateThumbnails; - uploadOriginal: typeof uploadOriginal; revokeBlobUrls: typeof revokeBlobUrls; - fetchRemoteFile: typeof fetchRemoteFile; < T = Record< string, unknown > >( args: T ): void; }; @@ -141,11 +115,7 @@ export function addItem( { abortController, operations, }: AddItemArgs ) { - return async ( { dispatch, registry }: ThunkArgs ) => { - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - + return async ( { dispatch }: ThunkArgs ) => { const itemId = uuidv4(); // Hardening in case a Blob is passed instead of a File. @@ -176,7 +146,6 @@ export function addItem( { url: blobUrl, }, additionalData: { - generate_sub_sizes: 'server' === thumbnailGeneration, convert_format: false, ...additionalData, }, @@ -203,7 +172,6 @@ interface AddSideloadItemArgs { additionalData?: AdditionalData; operations?: Operation[]; batchId?: BatchId; - parentId?: QueueItemId; } /** @@ -214,7 +182,6 @@ interface AddSideloadItemArgs { * @param $0 * @param $0.file File * @param [$0.batchId] Batch ID. - * @param [$0.parentId] Parent ID. * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. * @param [$0.additionalData] Additional data to include in the request. * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. @@ -225,7 +192,6 @@ export function addSideloadItem( { additionalData, operations, batchId, - parentId, }: AddSideloadItemArgs ) { return async ( { dispatch }: { dispatch: ActionCreators } ) => { const itemId = uuidv4(); @@ -241,7 +207,6 @@ export function addSideloadItem( { additionalData: { ...additionalData, }, - parentId, operations: Array.isArray( operations ) ? operations : [ OperationType.Prepare ], @@ -268,34 +233,16 @@ export function processItem( id: QueueItemId ) { const item = select.getItem( id ) as QueueItem; - if ( item.status === ItemStatus.PendingApproval ) { - return; - } - - const { - attachment, - onChange, - onSuccess, - onBatchSuccess, - batchId, - parentId, - } = item; + const { attachment, onChange, onSuccess, onBatchSuccess, batchId } = + item; const operation = Array.isArray( item.operations?.[ 0 ] ) ? item.operations[ 0 ][ 0 ] : item.operations?.[ 0 ]; - // TODO: Improve type here to avoid using "as" further down. - const operationArgs = Array.isArray( item.operations?.[ 0 ] ) - ? item.operations[ 0 ][ 1 ] - : undefined; // If we're sideloading a thumbnail, pause upload to avoid race conditions. // It will be resumed after the previous upload finishes. - if ( - operation === OperationType.Upload && - item.parentId && - item.additionalData.post - ) { + if ( operation === OperationType.Upload && item.additionalData.post ) { const isAlreadyUploading = select.isUploadingToPost( item.additionalData.post as number ); @@ -319,39 +266,15 @@ export function processItem( id: QueueItemId ) { */ if ( ! operation ) { - if ( - parentId || - ( ! parentId && ! select.isUploadingByParentId( id ) ) - ) { - if ( attachment ) { - onSuccess?.( [ attachment ] ); - } - - dispatch.removeItem( id ); - dispatch.revokeBlobUrls( id ); - - if ( batchId && select.isBatchUploaded( batchId ) ) { - onBatchSuccess?.(); - } + if ( attachment ) { + onSuccess?.( [ attachment ] ); } - // All other side-loaded items have been removed, so remove the parent too. - if ( parentId && batchId && select.isBatchUploaded( batchId ) ) { - const parentItem = select.getItem( parentId ) as QueueItem; - - if ( attachment ) { - parentItem.onSuccess?.( [ attachment ] ); - } - - dispatch.removeItem( parentId ); - dispatch.revokeBlobUrls( parentId ); + dispatch.removeItem( id ); + dispatch.revokeBlobUrls( id ); - if ( - parentItem.batchId && - select.isBatchUploaded( parentItem.batchId ) - ) { - parentItem.onBatchSuccess?.(); - } + if ( batchId && select.isBatchUploaded( batchId ) ) { + onBatchSuccess?.(); } /* @@ -378,44 +301,8 @@ export function processItem( id: QueueItemId ) { dispatch.prepareItem( item.id ); break; - case OperationType.ResizeCrop: - dispatch.resizeCropItem( - item.id, - operationArgs as OperationArgs[ OperationType.ResizeCrop ] - ); - break; - - case OperationType.TranscodeImage: - dispatch.optimizeImageItem( - item.id, - operationArgs as OperationArgs[ OperationType.TranscodeImage ] - ); - break; - case OperationType.Upload: - if ( item.parentId ) { - dispatch.sideloadItem( id ); - } else { - dispatch.uploadItem( id ); - } - break; - - case OperationType.ThumbnailGeneration: - dispatch.generateThumbnails( id ); - break; - - case OperationType.UploadOriginal: - dispatch.uploadOriginal( - id, - operationArgs as OperationArgs[ OperationType.UploadOriginal ] - ); - break; - - case OperationType.FetchRemoteFile: - dispatch.fetchRemoteFile( - id, - operationArgs as OperationArgs[ OperationType.FetchRemoteFile ] - ); + dispatch.uploadItem( id ); break; } }; @@ -525,72 +412,8 @@ export function finishOperation( * @param id Item ID. */ export function prepareItem( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const { file } = item; - - const mediaType = - 'application/pdf' === file.type - ? 'pdf' - : file.type.split( '/' )[ 0 ]; - - const operations: Operation[] = []; - - switch ( mediaType ) { - case 'image': - // Short-circuit for file types such as SVG or ICO. - if ( ! isImageTypeSupported( file.type ) ) { - operations.push( OperationType.Upload ); - break; - } - - const optimizeOnUpload: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'optimizeOnUpload' ); - - const imageSizeThreshold: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); - - if ( imageSizeThreshold ) { - operations.push( [ - OperationType.ResizeCrop, - { - resize: { - width: imageSizeThreshold, - height: imageSizeThreshold, - }, - }, - ] ); - } - - if ( optimizeOnUpload ) { - operations.push( OperationType.TranscodeImage ); - } - - operations.push( OperationType.GenerateMetadata ); - - operations.push( - OperationType.Upload, - OperationType.ThumbnailGeneration - ); - - const keepOriginal: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'keepOriginal' ); - - if ( imageSizeThreshold && keepOriginal ) { - operations.push( OperationType.UploadOriginal ); - } - - break; - - default: - operations.push( OperationType.Upload ); - - break; - } + return async ( { dispatch }: ThunkArgs ) => { + const operations: Operation[] = [ OperationType.Upload ]; dispatch< AddOperationsAction >( { type: Type.AddOperations, @@ -602,330 +425,6 @@ export function prepareItem( id: QueueItemId ) { }; } -/** - * Adds thumbnail versions to the queue for sideloading. - * - * @param id Item ID. - */ -export function generateThumbnails( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const attachment: Attachment = item.attachment as Attachment; - - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - // Client-side thumbnail generation. - // Works for images and PDF posters. - - if ( - ! item.parentId && - attachment.missing_image_sizes && - 'server' !== thumbnailGeneration - ) { - let file = attachment.filename - ? renameFile( item.file, attachment.filename ) - : item.file; - const batchId = uuidv4(); - - if ( 'application/pdf' === item.file.type && item.poster ) { - file = item.poster; - - const outputFormat: ImageFormat = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_outputFormat' ); - - const outputQuality: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_quality' ); - - const interlaced: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_interlaced' ); - - // Upload the "full" version without a resize param. - dispatch.addSideloadItem( { - file: item.poster, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - image_size: 'full', - convert_format: false, - }, - operations: [ - [ - OperationType.TranscodeImage, - { outputFormat, outputQuality, interlaced }, - ], - OperationType.Upload, - ], - parentId: item.id, - } ); - } - - for ( const name of attachment.missing_image_sizes ) { - const imageSize = select.getImageSize( name ); - if ( ! imageSize ) { - continue; - } - - // Force thumbnails to be soft crops, see wp_generate_attachment_metadata(). - if ( - 'application/pdf' === item.file.type && - 'thumbnail' === name - ) { - imageSize.crop = false; - } - - dispatch.addSideloadItem( { - file, - onChange: ( [ updatedAttachment ] ) => { - // If the sub-size is still being generated, there is no need - // to invoke the callback below. It would just override - // the main image in the editor with the sub-size. - if ( isBlobURL( updatedAttachment.url ) ) { - return; - } - - // This might be confusing, but the idea is to update the original - // image item in the editor with the new one with the added sub-size. - item.onChange?.( [ updatedAttachment ] ); - }, - batchId, - parentId: item.id, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - // Reference the same upload_request if needed. - upload_request: item.additionalData.upload_request, - image_size: name, - convert_format: false, - }, - operations: [ - [ OperationType.ResizeCrop, { resize: imageSize } ], - OperationType.Upload, - ], - } ); - } - } - - dispatch.finishOperation( id, {} ); - }; -} - -type UploadOriginalArgs = OperationArgs[ OperationType.UploadOriginal ]; - -/** - * Adds the original file to the queue for sideloading. - * - * If an item was downsized due to the big image size threshold, - * this adds the original file for storing. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function uploadOriginal( id: QueueItemId, args?: UploadOriginalArgs ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const attachment: Attachment = item.attachment as Attachment; - - /* - Upload the original image file if it was resized because of the big image size threshold, - or if it was converted to be web-safe (e.g. HEIC, JPEG XL) and thus - uploading the original is "forced". - */ - if ( - ! item.parentId && - ( ( item.file instanceof ImageFile && item.file?.wasResized ) || - args?.force ) - ) { - const originalBaseName = getFileBasename( - attachment.filename || item.file.name - ); - - dispatch.addSideloadItem( { - file: renameFile( - item.sourceFile, - `${ originalBaseName }-original.${ getFileExtension( - item.sourceFile.name - ) }` - ), - parentId: item.id, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - // Reference the same upload_request if needed. - upload_request: item.additionalData.upload_request, - image_size: 'original', - convert_format: false, - }, - // Skip any resizing or optimization of the original image. - operations: [ OperationType.Upload ], - } ); - } - - dispatch.finishOperation( id, {} ); - }; -} - -type OptimizeImageItemArgs = OperationArgs[ OperationType.TranscodeImage ]; - -/** - * Optimizes/Compresses an existing image item. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function optimizeImageItem( - id: QueueItemId, - args?: OptimizeImageItemArgs -) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const inputFormat = item.file.type.split( '/' )[ 1 ]; - - const outputQuality: number = - args?.outputQuality || - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, `${ inputFormat }_quality` ) || - 80; - - const interlaced: boolean = - args?.interlaced || - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, `${ inputFormat }_interlaced` ) || - false; - - try { - let file: File; - - file = await vipsCompressImage( - item.id, - item.file, - outputQuality / 100, - interlaced - ); - - if ( item.file instanceof ImageFile ) { - file = new ImageFile( - file, - item.file.width, - item.file.height, - item.file.originalWidth, - item.file.originalHeight - ); - } - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - if ( args?.requireApproval ) { - dispatch.finishOperation( id, { - status: ItemStatus.PendingApproval, - file, - attachment: { - url: blobUrl, - mime_type: file.type, - }, - } ); - } else { - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } - } catch ( error ) { - dispatch.cancelItem( - id, - new UploadError( { - code: 'MEDIA_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - cause: error instanceof Error ? error : undefined, - } ) - ); - } - }; -} - -type ResizeCropItemArgs = OperationArgs[ OperationType.ResizeCrop ]; - -/** - * Resizes and crops an existing image item. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - if ( ! args?.resize ) { - dispatch.finishOperation( id, { - file: item.file, - } ); - return; - } - - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - const smartCrop = Boolean( thumbnailGeneration === 'smart' ); - - const addSuffix = Boolean( item.parentId ); - - try { - const file = await vipsResizeImage( - item.id, - item.file, - args.resize, - smartCrop, - addSuffix - ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - new UploadError( { - code: 'IMAGE_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - cause: error instanceof Error ? error : undefined, - } ) - ); - } - }; -} - /** * Uploads an item to the server. * @@ -951,90 +450,6 @@ export function uploadItem( id: QueueItemId ) { }; } -/** - * Sideloads an item to the server. - * - * @param id Item ID. - */ -export function sideloadItem( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const { post, ...additionalData } = - item.additionalData as SideloadAdditionalData; - - select.getSettings().mediaSideload( { - file: item.file, - attachmentId: post as number, - additionalData, - signal: item.abortController?.signal, - onFileChange: ( [ attachment ] ) => { - dispatch.finishOperation( id, { attachment } ); - dispatch.resumeItem( post as number ); - }, - onError: ( error ) => { - dispatch.cancelItem( id, error ); - dispatch.resumeItem( post as number ); - }, - } ); - }; -} - -type FetchRemoteFileArgs = OperationArgs[ OperationType.FetchRemoteFile ]; - -/** - * Fetches a remote file from another server and adds it to the item. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function fetchRemoteFile( id: QueueItemId, args: FetchRemoteFileArgs ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - try { - const sourceFile = await fetchFile( args.url, args.fileName ); - - validateMimeType( sourceFile, args.allowedTypes ); - - if ( args.skipAttachment ) { - dispatch.finishOperation( id, { - sourceFile, - } ); - } else { - const file = args.newFileName - ? renameFile( cloneFile( sourceFile ), args.newFileName ) - : cloneFile( sourceFile ); - - const blobUrl = createBlobURL( sourceFile ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - sourceFile, - file, - attachment: { - url: blobUrl, - }, - } ); - } - } catch ( error ) { - dispatch.cancelItem( - id, - new UploadError( { - code: 'FETCH_REMOTE_FILE_ERROR', - message: 'Remote file could not be downloaded', - file: item.file, - cause: error instanceof Error ? error : undefined, - } ) - ); - } - }; -} - /** * Revokes all blob URLs for a given item, freeing up memory. * diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts index b94674eb5e3ef5..f2cfdbef76df86 100644 --- a/packages/upload-media/src/store/private-selectors.ts +++ b/packages/upload-media/src/store/private-selectors.ts @@ -3,7 +3,6 @@ */ import { type BatchId, - type ImageSizeCrop, ItemStatus, OperationType, type QueueItem, @@ -22,21 +21,6 @@ export function getAllItems( state: State ): QueueItem[] { return state.queue; } -/** - * Returns all items currently being uploaded. - * - * @param state Upload state. - * @param parentId Parent item ID. - * - * @return Queue items. - */ -export function getChildItems( - state: State, - parentId: QueueItemId -): QueueItem[] { - return state.queue.filter( ( item ) => item.parentId === parentId ); -} - /** * Returns a specific item given its unique ID. * @@ -52,25 +36,6 @@ export function getItem( return state.queue.find( ( item ) => item.id === id ); } -/** - * Returns a specific item given its associated attachment ID. - * - * @param state Upload state. - * @param attachmentId Item ID. - * - * @return Queue item. - */ -export function getItemByAttachmentId( - state: State, - attachmentId: number -): QueueItem | undefined { - return state.queue.find( - ( item ) => - item.attachment?.id === attachmentId || - item.sourceAttachmentId === attachmentId - ); -} - /** * Determines whether a batch has been successfully uploaded, given its unique ID. * @@ -124,21 +89,6 @@ export function getPausedUploadForPost( ); } -/** - * Determines whether an upload is currently in progress given a parent ID. - * - * @param state Upload state. - * @param parentId Parent ID. - * - * @return Whether upload is currently in progress for the given parent ID. - */ -export function isUploadingByParentId( - state: State, - parentId: QueueItemId -): boolean { - return state.queue.some( ( item ) => item.parentId === parentId ); -} - /** * Determines whether uploading is currently paused. * @@ -150,18 +100,6 @@ export function isPaused( state: State ): boolean { return state.queueStatus === 'paused'; } -/** - * Returns an image size given its name. - * - * @param state Upload state. - * @param name Image size name. - * - * @return Image size data. - */ -export function getImageSize( state: State, name: string ): ImageSizeCrop { - return state.settings.imageSizes[ name ]; -} - /** * Returns all cached blob URLs for a given item ID. * diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts index ddc2e9ea6d6a88..9fa0e7a0ad5fa0 100644 --- a/packages/upload-media/src/store/reducer.ts +++ b/packages/upload-media/src/store/reducer.ts @@ -4,7 +4,6 @@ import { type AddAction, type AddOperationsAction, - type ApproveUploadAction, type CacheBlobUrlAction, type CancelAction, ItemStatus, @@ -28,12 +27,9 @@ const noop = () => {}; const DEFAULT_STATE: State = { queue: [], queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: noop, - mediaSideload: noop, - imageSizes: {}, }, }; @@ -46,7 +42,6 @@ type Action = | PauseQueueAction | ResumeQueueAction | AddOperationsAction - | ApproveUploadAction | OperationFinishAction | OperationStartAction | CacheBlobUrlAction @@ -91,15 +86,6 @@ function reducer( } : item ), - pendingApproval: - state.pendingApproval !== action.id - ? state.pendingApproval - : state.queue.find( - ( item ) => - item.status === - ItemStatus.PendingApproval && - item.id !== action.id - )?.id || undefined, }; case Type.Remove: @@ -202,32 +188,6 @@ function reducer( }, }; } ), - // eslint-disable-next-line no-nested-ternary - pendingApproval: state.pendingApproval - ? state.pendingApproval - : action.item.status === ItemStatus.PendingApproval - ? action.id - : undefined, - }; - - case Type.ApproveUpload: - return { - ...state, - queue: state.queue.map( - ( item ): QueueItem => - item.id === action.id - ? { - ...item, - status: ItemStatus.Processing, - } - : item - ), - pendingApproval: - state.queue.find( - ( item ) => - item.status === ItemStatus.PendingApproval && - item.id !== action.id - )?.id || undefined, }; case Type.CacheBlobUrl: { diff --git a/packages/upload-media/src/store/selectors.ts b/packages/upload-media/src/store/selectors.ts index f31181c7411bd2..95731943f9e0a9 100644 --- a/packages/upload-media/src/store/selectors.ts +++ b/packages/upload-media/src/store/selectors.ts @@ -4,92 +4,14 @@ import { ItemStatus, type QueueItem, type Settings, type State } from './types'; /** - * Returns all items currently being uploaded, without sub-sizes (children). + * Returns all items currently being uploaded. * * @param state Upload state. * * @return Queue items. */ export function getItems( state: State ): QueueItem[] { - return state.queue.filter( ( item ) => ! item.parentId ); -} - -/** - * Determines whether there is an item pending approval. - * - * @param state Upload state. - * - * @return Whether there is an item pending approval. - */ -export function isPendingApproval( state: State ): boolean { - return state.queue.some( - ( item ) => item.status === ItemStatus.PendingApproval - ); -} - -/** - * Determines whether an item is the first one pending approval given its associated attachment ID. - * - * @param state Upload state. - * @param attachmentId Attachment ID. - * - * @return Whether the item is first in the list of items pending approval. - */ -export function isPendingApprovalByAttachmentId( - state: State, - attachmentId: number -): boolean { - if ( ! state.pendingApproval ) { - return false; - } - - return state.queue.some( - ( item ) => - item.status === ItemStatus.PendingApproval && - item.id === state.pendingApproval && - ( item.attachment?.id === attachmentId || - item.sourceAttachmentId === attachmentId ) - ); -} - -/** - * Returns data to compare the old file vs. the optimized file, given the attachment ID. - * - * Includes both the URLs and the respective file sizes and the size difference in percentage. - * - * @param state Upload state. - * @param attachmentId Attachment ID. - * - * @return Comparison data. - */ -export function getComparisonDataForApproval( - state: State, - attachmentId: number -): { - oldUrl: string | undefined; - oldSize: number; - newSize: number; - newUrl: string | undefined; - sizeDiff: number; -} | null { - const foundItem = state.queue.find( - ( item ) => - ( item.attachment?.id === attachmentId || - item.sourceAttachmentId === attachmentId ) && - item.status === ItemStatus.PendingApproval - ); - - if ( ! foundItem ) { - return null; - } - - return { - oldUrl: foundItem.sourceUrl, - oldSize: foundItem.sourceFile.size, - newSize: foundItem.file.size, - newUrl: foundItem.attachment?.url, - sizeDiff: foundItem.file.size / foundItem.sourceFile.size - 1, - }; + return state.queue; } /** diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 2dbf6c61168b6c..e148c9fe496148 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -2,10 +2,8 @@ * WordPress dependencies */ import { createRegistry } from '@wordpress/data'; -// eslint-disable-next-line no-restricted-syntax -import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; -// @ts-ignore -- No types available yet. -import { store as preferencesStore } from '@wordpress/preferences'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies @@ -21,15 +19,11 @@ jest.mock( '@wordpress/blob', () => ( { revokeBlobURL: jest.fn(), } ) ); -jest.mock( '../utils/vips', () => ( { - vipsCancelOperations: jest.fn( () => true ), -} ) ); - function createRegistryWithStores() { // Create a registry and register used stores. const registry = createRegistry(); // @ts-ignore - [ uploadStore, preferencesStore ].forEach( registry.register ); + [ uploadStore ].forEach( registry.register ); return registry; } diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts index 75e4d3e6e0191c..4414e1d8886126 100644 --- a/packages/upload-media/src/store/test/reducer.ts +++ b/packages/upload-media/src/store/test/reducer.ts @@ -15,12 +15,9 @@ describe( 'reducer', () => { it( 'adds an item to the queue', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -39,12 +36,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -64,12 +58,9 @@ describe( 'reducer', () => { it( 'removes an item from the queue', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -90,12 +81,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -116,12 +104,9 @@ describe( 'reducer', () => { it( 'removes an item from the queue', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -141,12 +126,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -162,12 +144,9 @@ describe( 'reducer', () => { it( 'marks an item as paused', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -187,12 +166,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -212,12 +188,9 @@ describe( 'reducer', () => { it( 'marks an item as processing', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -237,12 +210,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -262,12 +232,9 @@ describe( 'reducer', () => { it( 'appends operations to the list', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -280,17 +247,14 @@ describe( 'reducer', () => { const state = reducer( initialState, { type: Type.AddOperations, id: '1', - operations: [ OperationType.Compress ], + operations: [ OperationType.Upload ], } ); expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -298,7 +262,7 @@ describe( 'reducer', () => { status: ItemStatus.Processing, operations: [ OperationType.Upload, - OperationType.Compress, + OperationType.Upload, ], }, ], @@ -310,12 +274,9 @@ describe( 'reducer', () => { it( 'marks an item as processing', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -338,12 +299,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { @@ -366,12 +324,9 @@ describe( 'reducer', () => { it( 'marks an item as processing', () => { const initialState: State = { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, queue: [ { @@ -392,12 +347,9 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), - mediaSideload: expect.any( Function ), - imageSizes: {}, }, queue: [ { diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts index fee3837ce18110..716b7792ef77a4 100644 --- a/packages/upload-media/src/store/test/selectors.ts +++ b/packages/upload-media/src/store/test/selectors.ts @@ -3,7 +3,6 @@ */ import { getItems, - isPendingApproval, isUploading, isUploadingById, isUploadingByUrl, @@ -16,12 +15,9 @@ describe( 'selectors', () => { const state: State = { queue: [], queueStatus: 'paused', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, }; @@ -39,20 +35,14 @@ describe( 'selectors', () => { { status: ItemStatus.Processing, }, - { - status: ItemStatus.PendingApproval, - }, { status: ItemStatus.Paused, }, ] as QueueItem[], queueStatus: 'paused', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, }; @@ -70,30 +60,20 @@ describe( 'selectors', () => { url: 'https://example.com/one.jpeg', }, }, - { - status: ItemStatus.PendingApproval, - sourceUrl: 'https://example.com/two.jpeg', - }, { status: ItemStatus.Processing, }, ] as QueueItem[], queueStatus: 'paused', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, }; expect( isUploadingByUrl( state, 'https://example.com/one.jpeg' ) ).toBe( true ); - expect( - isUploadingByUrl( state, 'https://example.com/two.jpeg' ) - ).toBe( true ); expect( isUploadingByUrl( state, 'https://example.com/three.jpeg' ) ).toBe( false ); @@ -110,70 +90,16 @@ describe( 'selectors', () => { id: 123, }, }, - { - status: ItemStatus.PendingApproval, - sourceAttachmentId: 456, - }, - { - status: ItemStatus.PendingApproval, - }, ] as QueueItem[], queueStatus: 'paused', - pendingApproval: undefined, blobUrls: {}, settings: { mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, }, }; expect( isUploadingById( state, 123 ) ).toBe( true ); - expect( isUploadingById( state, 456 ) ).toBe( true ); expect( isUploadingById( state, 789 ) ).toBe( false ); } ); } ); - - describe( 'isPendingApproval', () => { - it( 'should return true if there are items pending approval', () => { - const state: State = { - queue: [ - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.PendingApproval, - }, - { - status: ItemStatus.Paused, - }, - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.PendingApproval, - }, - { - status: ItemStatus.Paused, - }, - { - status: ItemStatus.Processing, - }, - ] as QueueItem[], - queueStatus: 'paused', - pendingApproval: undefined, - blobUrls: {}, - settings: { - mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - imageSizes: {}, - }, - }; - - expect( isPendingApproval( state ) ).toBe( true ); - } ); - } ); } ); diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 6218dc67d2af37..a6d4b16987d2f3 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -22,17 +22,12 @@ export interface QueueItem { batchId?: string; sourceUrl?: string; sourceAttachmentId?: number; - blurHash?: string; - dominantColor?: string; - generatedPosterId?: number; - parentId?: QueueItemId; abortController?: AbortController; } export interface State { queue: QueueItem[]; queueStatus: QueueStatus; - pendingApproval: QueueItemId | undefined; blobUrls: Record< QueueItemId, string[] >; settings: Settings; } @@ -47,7 +42,6 @@ export enum Type { ResumeItem = 'RESUME_ITEM', PauseQueue = 'PAUSE_QUEUE', ResumeQueue = 'RESUME_QUEUE', - ApproveUpload = 'APPROVE_UPLOAD', OperationStart = 'OPERATION_START', OperationFinish = 'OPERATION_FINISH', AddOperations = 'ADD_OPERATIONS', @@ -83,10 +77,6 @@ export type AddOperationsAction = Action< Type.AddOperations, { id: QueueItemId; operations: Operation[] } >; -export type ApproveUploadAction = Action< - Type.ApproveUpload, - { id: QueueItemId } ->; export type CancelAction = Action< Type.Cancel, { id: QueueItemId; error: Error } @@ -130,25 +120,8 @@ interface UploadMediaArgs { signal?: AbortSignal; } -interface SideloadMediaArgs { - // Additional data to include in the request. - additionalData?: SideloadAdditionalData; - // File to sideload. - file: File; - // Attachment ID. - attachmentId: number; - // Function called when an error happens. - onError?: OnErrorHandler; - // Function called each time a file or a temporary representation of the file is available. - onFileChange?: OnChangeHandler; - // Abort signal. - signal?: AbortSignal; -} - export interface Settings { mediaUpload: ( args: UploadMediaArgs ) => void; - mediaSideload: ( args: SideloadMediaArgs ) => void; - imageSizes: Record< string, ImageSizeCrop >; } // Must match the Attachment type from the media-utils package. @@ -175,41 +148,14 @@ export type OnBatchSuccessHandler = () => void; export enum ItemStatus { Processing = 'PROCESSING', Paused = 'PAUSED', - PendingApproval = 'PENDING_APPROVAL', } export enum OperationType { Prepare = 'PREPARE', - UploadOriginal = 'UPLOAD_ORIGINAL', - ThumbnailGeneration = 'THUMBNAIL_GENERATION', - ResizeCrop = 'RESIZE_CROP', - TranscodeImage = 'TRANSCODE_IMAGE', - Compress = 'TRANSCODE_COMPRESS', - FetchRemoteFile = 'FETCH_REMOTE_FILE', - GenerateMetadata = 'GENERATE_METADATA', Upload = 'UPLOAD', } -export interface OperationArgs { - [ OperationType.Compress ]: { - requireApproval?: boolean; - }; - [ OperationType.FetchRemoteFile ]: { - url: string; - fileName: string; - newFileName?: string; - skipAttachment?: boolean; - allowedTypes?: string[]; - }; - [ OperationType.TranscodeImage ]: { - requireApproval?: boolean; - outputFormat?: ImageFormat; - outputQuality?: number; - interlaced?: boolean; - }; - [ OperationType.ResizeCrop ]: { resize?: ImageSizeCrop }; - [ OperationType.UploadOriginal ]: { force?: boolean }; -} +export interface OperationArgs {} type OperationWithArgs< T extends keyof OperationArgs = keyof OperationArgs > = [ T, OperationArgs[ T ] ]; @@ -218,21 +164,4 @@ export type Operation = OperationType | OperationWithArgs; export type AdditionalData = Record< string, unknown >; -export type SideloadAdditionalData = Record< string, unknown >; - -export interface ImageSizeCrop { - name?: string; // Only set if dealing with sub-sizes, not for general cropping. - width: number; - height: number; - crop?: - | boolean - | [ 'left' | 'center' | 'right', 'top' | 'center' | 'bottom' ]; -} - export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif'; - -export type VideoFormat = 'mp4' | 'webm'; - -export type AudioFormat = 'mp3' | 'ogg'; - -export type ThumbnailGeneration = 'server' | 'client' | 'smart'; diff --git a/packages/upload-media/src/store/utils/vips.ts b/packages/upload-media/src/store/utils/vips.ts deleted file mode 100644 index 013551d93ef52b..00000000000000 --- a/packages/upload-media/src/store/utils/vips.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * External dependencies - */ -import { createWorkerFactory } from '@shopify/web-worker'; - -/** - * Internal dependencies - */ -import { ImageFile } from '../../image-file'; -import { getFileBasename } from '../../utils'; -import type { ImageSizeCrop, QueueItemId } from '../types'; - -const createVipsWorker = createWorkerFactory( - () => import( /* webpackChunkName: 'vips-worker' */ '@wordpress/vips' ) -); -const vipsWorker = createVipsWorker(); - -export async function vipsConvertImageFormat( - id: QueueItemId, - file: File, - type: - | 'image/jpeg' - | 'image/png' - | 'image/webp' - | 'image/avif' - | 'image/gif', - quality: number, - interlaced?: boolean -) { - const buffer = await vipsWorker.convertImageFormat( - id, - await file.arrayBuffer(), - file.type, - type, - quality, - interlaced - ); - const ext = type.split( '/' )[ 1 ]; - const fileName = `${ getFileBasename( file.name ) }.${ ext }`; - return new File( [ new Blob( [ buffer ] ) ], fileName, { type } ); -} - -export async function vipsCompressImage( - id: QueueItemId, - file: File, - quality: number, - interlaced?: boolean -) { - const buffer = await vipsWorker.compressImage( - id, - await file.arrayBuffer(), - file.type, - quality, - interlaced - ); - return new File( - [ new Blob( [ buffer ], { type: file.type } ) ], - file.name, - { type: file.type } - ); -} - -export async function vipsHasTransparency( url: string ) { - return vipsWorker.hasTransparency( - await ( await fetch( url ) ).arrayBuffer() - ); -} - -export async function vipsResizeImage( - id: QueueItemId, - file: File, - resize: ImageSizeCrop, - smartCrop: boolean, - addSuffix: boolean -) { - const { buffer, width, height, originalWidth, originalHeight } = - await vipsWorker.resizeImage( - id, - await file.arrayBuffer(), - file.type, - resize, - smartCrop - ); - - let fileName = file.name; - - if ( addSuffix && ( originalWidth > width || originalHeight > height ) ) { - const basename = getFileBasename( file.name ); - fileName = file.name.replace( - basename, - `${ basename }-${ width }x${ height }` - ); - } - - return new ImageFile( - new File( [ new Blob( [ buffer ], { type: file.type } ) ], fileName, { - type: file.type, - } ), - width, - height, - originalWidth, - originalHeight - ); -} - -/** - * Cancels all ongoing image operations for the given item. - * - * @param id Queue item ID to cancel operations for. - */ -export async function vipsCancelOperations( id: QueueItemId ) { - return vipsWorker.cancelOperations( id ); -} diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts index 9c64cc1b8e6451..3950ec03887928 100644 --- a/packages/upload-media/src/utils.ts +++ b/packages/upload-media/src/utils.ts @@ -1,18 +1,8 @@ -/** - * External dependencies - */ -import mime from 'mime/lite'; - /** * WordPress dependencies */ import { getFilename } from '@wordpress/url'; -import { __, _x, sprintf } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { UploadError } from './upload-error'; +import { _x } from '@wordpress/i18n'; /** * Converts a Blob to a File with a default name like "image.png". @@ -98,108 +88,3 @@ export function getFileBasename( name: string ): string { export function getFileNameFromUrl( url: string ) { return getFilename( url ) || _x( 'unnamed', 'file name' ); } - -/** - * Fetches a remote file and returns a File instance. - * - * @param url URL. - * @param nameOverride File name to use, instead of deriving it from the URL. - */ -export async function fetchFile( url: string, nameOverride?: string ) { - const response = await fetch( url ); - if ( ! response.ok ) { - throw new Error( `Could not fetch remote file: ${ response.status }` ); - } - - const name = nameOverride || getFileNameFromUrl( url ); - const blob = await response.blob(); - - const ext = getFileExtension( name ); - const guessedMimeType = ext ? mime.getType( ext ) : ''; - - let type = ''; - - // blob.type can be an empty string when server does not return a correct Content-Type. - if ( blob.type && blob.type !== 'application/octet-stream' ) { - type = blob.type; - } else if ( guessedMimeType ) { - type = guessedMimeType; - } - - const file = new File( [ blob ], name, { type } ); - - if ( ! type ) { - throw new UploadError( { - code: 'FETCH_REMOTE_FILE_ERROR', - message: 'File could not be downloaded', - file, - } ); - } - - return file; -} - -/** - * Verifies if the caller supports this mime type. - * - * @param file File object. - * @param allowedTypes List of allowed mime types. - */ -export function validateMimeType( file: File, allowedTypes?: string[] ) { - if ( ! allowedTypes ) { - return; - } - - // Allowed type specified by consumer. - const isAllowedType = allowedTypes.some( ( allowedType ) => { - // If a complete mimetype is specified verify if it matches exactly the mime type of the file. - if ( allowedType.includes( '/' ) ) { - return allowedType === file.type; - } - // Otherwise a general mime type is used, and we should verify if the file mimetype starts with it. - return file.type.startsWith( `${ allowedType }/` ); - } ); - - if ( file.type && ! isAllowedType ) { - throw new UploadError( { - code: 'MIME_TYPE_NOT_SUPPORTED', - message: sprintf( - // translators: %s: file name. - __( '%s: Sorry, this file type is not supported here.' ), - file.name - ), - file, - } ); - } -} - -/** - * Determines whether a given file type is supported for client-side processing. - * - * @param type Mime type. - * @return Whether the file type is supported. - */ -export function isImageTypeSupported( - type: string -): type is - | 'image/avif' - | 'image/gif' - | 'image/heic' - | 'image/heif' - | 'image/jpeg' - | 'image/jxl' - | 'image/png' - | 'image/tiff' - | 'image/webp' { - return [ - 'image/avif', - 'image/gif', - 'image/heic', - 'image/heif', - 'image/jpeg', - 'image/jxl', - 'image/png', - 'image/tiff', - 'image/webp', - ].includes( type ); -} diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json index bd7ebfb1023d14..b0bc834698905c 100644 --- a/packages/upload-media/tsconfig.json +++ b/packages/upload-media/tsconfig.json @@ -15,7 +15,6 @@ { "path": "../element" }, { "path": "../i18n" }, { "path": "../private-apis" }, - { "path": "../url" }, - { "path": "../vips" } + { "path": "../url" } ] } From a1f028202b7d2ffb1ebddef931dc3865209e3b83 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Dec 2024 18:34:45 +0100 Subject: [PATCH 18/27] A bunch of fixes --- .../src/components/provider/index.js | 114 +++++++++++++++++- .../provider/use-block-editor-settings.js | 4 + .../editor/src/utils/media-upload/index.js | 5 +- .../editor/src/utils/validate-file-size.js | 23 ++++ .../editor/src/utils/validate-mime-type.js | 27 +++++ .../media-utils/src/utils/upload-media.ts | 2 + .../provider/with-registry-provider.tsx | 6 +- .../upload-media/src/store/private-actions.ts | 99 ++------------- packages/upload-media/src/store/reducer.ts | 33 +---- packages/upload-media/src/store/selectors.ts | 2 +- .../upload-media/src/store/test/reducer.ts | 88 -------------- 11 files changed, 181 insertions(+), 222 deletions(-) create mode 100644 packages/editor/src/utils/validate-file-size.js create mode 100644 packages/editor/src/utils/validate-mime-type.js diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 38e67f23e60518..7a75368afe0e4e 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -2,10 +2,13 @@ * WordPress dependencies */ import { useDispatch } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useMemo } from '@wordpress/element'; import { SlotFillProvider } from '@wordpress/components'; //eslint-disable-next-line import/no-extraneous-dependencies -- Experimental package, not published. -import { MediaUploadProvider } from '@wordpress/upload-media'; +import { + MediaUploadProvider, + store as uploadStore, +} from '@wordpress/upload-media'; /** * Internal dependencies @@ -20,9 +23,112 @@ import useMediaUploadSettings from './use-media-upload-settings'; /** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */ +const noop = () => {}; + +/** + * Upload a media file when the file upload button is activated + * or when adding a file to the editor via drag & drop. + * + * This function is intended to eventually live + * in the `@wordpress/block-editor` package, allowing + * to perform the client-side file processing before eventually + * uploading the media to WordPress. + * + * @param {WPDataRegistry} registry + * @param {Function} validateMimeType + * @param {Function} validateFileSize + * @param {Object} $3 Parameters object passed to the function. + * @param {Array} $3.allowedTypes Array with the types of media that can be uploaded, if unset all types are allowed. + * @param {Object} $3.additionalData Additional data to include in the request. + * @param {Array} $3.filesList List of files. + * @param {Function} $3.onError Function called when an error happens. + * @param {Function} $3.onFileChange Function called each time a file or a temporary representation of the file is available. + * @param {Function} $3.onSuccess Function called once a file has completely finished uploading, including thumbnails. + * @param {Function} $3.onBatchSuccess Function called once all files in a group have completely finished uploading, including thumbnails. + */ +function mediaUpload( + registry, + validateMimeType, + validateFileSize, + { + allowedTypes, + additionalData = {}, + filesList, + onError = noop, + onFileChange, + onSuccess, + onBatchSuccess, + } +) { + const validFiles = []; + + for ( const mediaFile of filesList ) { + /* + Check if the caller (e.g. a block) supports this mime type. + Special case for file types such as HEIC which will be converted before upload anyway. + Another check will be done before upload. + */ + try { + validateMimeType( mediaFile, allowedTypes ); + } catch ( error ) { + onError( error ); + continue; + } + + try { + validateFileSize( mediaFile ); + } catch ( error ) { + onError( error ); + continue; + } + + validFiles.push( mediaFile ); + } + + void registry.dispatch( uploadStore ).addItems( { + files: validFiles, + onChange: onFileChange, + onSuccess, + onBatchSuccess, + onError: ( { message } ) => onError( message ), + additionalData, + } ); +} + export const ExperimentalBlockEditorProvider = withRegistryProvider( ( props ) => { - const { settings, stripExperimentalSettings = false } = props; + const { + settings: _settings, + registry, + stripExperimentalSettings = false, + } = props; + + const mediaUploadSettings = useMediaUploadSettings( _settings ); + + // Create a new variable so that the original props.settings.mediaUpload is not modified. + const settings = useMemo( + () => ( { + ..._settings, + mediaUpload: _settings.mediaUpload + ? mediaUpload.bind( + null, + registry, + _settings.experimentalValidateMimeType || noop, + _settings.validateFileSize || noop + ) + : undefined, + } ), + [ _settings, registry ] + ); + + if ( window.__experimentalMediaProcessing && settings.mediaUpload ) { + settings.mediaUpload = mediaUpload.bind( + null, + registry, + settings.experimentalValidateMimeType || noop, + settings.validateFileSize || noop + ); + } const { __experimentalUpdateSettings } = unlock( useDispatch( blockEditorStore ) @@ -54,8 +160,6 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( ); - const mediaUploadSettings = useMediaUploadSettings( settings ); - if ( window.__experimentalMediaProcessing ) { return ( { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/editor/src/utils/media-upload/index.js b/packages/editor/src/utils/media-upload/index.js index 6b39db2443cc33..0d970d91ce745c 100644 --- a/packages/editor/src/utils/media-upload/index.js +++ b/packages/editor/src/utils/media-upload/index.js @@ -27,6 +27,7 @@ const noop = () => {}; * @param {?number} $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. * @param {Function} $0.onError Function called when an error happens. * @param {Function} $0.onFileChange Function called each time a file or a temporary representation of the file is available. + * @param {Function} $0.onSuccess Function called after the final representation of the file is available. */ export default function mediaUpload( { additionalData = {}, @@ -35,6 +36,7 @@ export default function mediaUpload( { maxUploadFileSize, onError = noop, onFileChange, + onSuccess, } ) { const { getCurrentPost, getEditorSettings } = select( editorStore ); const { @@ -77,8 +79,9 @@ export default function mediaUpload( { } else { clearSaveLock(); } - onFileChange( file ); + onFileChange?.( file ); }, + onSuccess, additionalData: { ...postData, ...additionalData, diff --git a/packages/editor/src/utils/validate-file-size.js b/packages/editor/src/utils/validate-file-size.js new file mode 100644 index 00000000000000..ba0fb5713a0921 --- /dev/null +++ b/packages/editor/src/utils/validate-file-size.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; +import { validateFileSize as originalValidateFileSize } from '@wordpress/media-utils'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; + +/** + * Verifies whether the file is within the file upload size limits for the site. + * + * @param {File} file File object. + */ +export function validateFileSize( file ) { + const { getEditorSettings } = select( editorStore ); + return originalValidateFileSize( + file, + getEditorSettings().maxUploadFileSize + ); +} diff --git a/packages/editor/src/utils/validate-mime-type.js b/packages/editor/src/utils/validate-mime-type.js new file mode 100644 index 00000000000000..a4d03e628d40c5 --- /dev/null +++ b/packages/editor/src/utils/validate-mime-type.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; +import { + validateMimeType as originalValidateMimeType, + validateMimeTypeForUser as originalValidateMimeTypeForUser, +} from '@wordpress/media-utils'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; + +/** + * Verifies if the caller (e.g. a block) supports this mime type. + * + * @param {File} file File object. + * @param {string[]} [allowedTypes] Array with the types of media that can be uploaded, if unset all types are allowed. + */ +export function validateMimeType( file, allowedTypes ) { + const { getEditorSettings } = select( editorStore ); + const wpAllowedMimeTypes = getEditorSettings().allowedMimeTypes; + + originalValidateMimeTypeForUser( file, wpAllowedMimeTypes ); + originalValidateMimeType( file, allowedTypes ); +} diff --git a/packages/media-utils/src/utils/upload-media.ts b/packages/media-utils/src/utils/upload-media.ts index 1bc861cfb3b607..e10823bac06d11 100644 --- a/packages/media-utils/src/utils/upload-media.ts +++ b/packages/media-utils/src/utils/upload-media.ts @@ -63,6 +63,7 @@ export function uploadMedia( { maxUploadFileSize, onError, onFileChange, + onSuccess, signal, }: UploadMediaArgs ) { const validFiles = []; @@ -76,6 +77,7 @@ export function uploadMedia( { onFileChange?.( filesSet.filter( ( attachment ) => attachment !== null ) ); + onSuccess?.( filesSet.filter( ( attachment ) => attachment !== null ) ); }; for ( const mediaFile of filesList ) { diff --git a/packages/upload-media/src/components/provider/with-registry-provider.tsx b/packages/upload-media/src/components/provider/with-registry-provider.tsx index 9345a879311913..9a47a5601d33ed 100644 --- a/packages/upload-media/src/components/provider/with-registry-provider.tsx +++ b/packages/upload-media/src/components/provider/with-registry-provider.tsx @@ -2,11 +2,7 @@ * WordPress dependencies */ import { useState } from '@wordpress/element'; -import { - useRegistry, - createRegistry, - RegistryProvider, -} from '@wordpress/data'; +import { useRegistry, createRegistry, RegistryProvider } from '@wordpress/data'; import { createHigherOrderComponent } from '@wordpress/compose'; /** diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 03afd89e9db8ff..a4d4ee7b99c781 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ -import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; +import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -29,11 +29,9 @@ import type { Operation, OperationFinishAction, OperationStartAction, - PauseItemAction, PauseQueueAction, QueueItem, QueueItemId, - ResumeItemAction, ResumeQueueAction, RevokeBlobUrlsAction, Settings, @@ -51,7 +49,6 @@ type ActionCreators = { processItem: typeof processItem; finishOperation: typeof finishOperation; uploadItem: typeof uploadItem; - resumeItem: typeof resumeItem; revokeBlobUrls: typeof revokeBlobUrls; < T = Record< string, unknown > >( args: T ): void; }; @@ -166,58 +163,6 @@ export function addItem( { }; } -interface AddSideloadItemArgs { - file: File; - onChange?: OnChangeHandler; - additionalData?: AdditionalData; - operations?: Operation[]; - batchId?: BatchId; -} - -/** - * Adds a new item to the upload queue for sideloading. - * - * This is typically a poster image or a client-side generated thumbnail. - * - * @param $0 - * @param $0.file File - * @param [$0.batchId] Batch ID. - * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. - * @param [$0.additionalData] Additional data to include in the request. - * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. - */ -export function addSideloadItem( { - file, - onChange, - additionalData, - operations, - batchId, -}: AddSideloadItemArgs ) { - return async ( { dispatch }: { dispatch: ActionCreators } ) => { - const itemId = uuidv4(); - dispatch< AddAction >( { - type: Type.Add, - item: { - id: itemId, - batchId, - status: ItemStatus.Processing, - sourceFile: cloneFile( file ), - file, - onChange, - additionalData: { - ...additionalData, - }, - operations: Array.isArray( operations ) - ? operations - : [ OperationType.Prepare ], - abortController: new AbortController(), - }, - } ); - - dispatch.processItem( itemId ); - }; -} - /** * Processes a single item in the queue. * @@ -240,21 +185,6 @@ export function processItem( id: QueueItemId ) { ? item.operations[ 0 ][ 0 ] : item.operations?.[ 0 ]; - // If we're sideloading a thumbnail, pause upload to avoid race conditions. - // It will be resumed after the previous upload finishes. - if ( operation === OperationType.Upload && item.additionalData.post ) { - const isAlreadyUploading = select.isUploadingToPost( - item.additionalData.post as number - ); - if ( isAlreadyUploading ) { - dispatch< PauseItemAction >( { - type: Type.PauseItem, - id, - } ); - return; - } - } - if ( attachment ) { onChange?.( [ attachment ] ); } @@ -270,7 +200,7 @@ export function processItem( id: QueueItemId ) { onSuccess?.( [ attachment ] ); } - dispatch.removeItem( id ); + // dispatch.removeItem( id ); dispatch.revokeBlobUrls( id ); if ( batchId && select.isBatchUploaded( batchId ) ) { @@ -308,24 +238,6 @@ export function processItem( id: QueueItemId ) { }; } -/** - * Resumes processing for a given post/attachment ID. - * - * @param postOrAttachmentId Post or attachment ID. - */ -export function resumeItem( postOrAttachmentId: number ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getPausedUploadForPost( postOrAttachmentId ); - if ( item ) { - dispatch< ResumeItemAction >( { - type: Type.ResumeItem, - id: item.id, - } ); - dispatch.processItem( item.id ); - } - }; -} - /** * Returns an action object that pauses all processing in the queue. * @@ -439,6 +351,13 @@ export function uploadItem( id: QueueItemId ) { additionalData: item.additionalData, signal: item.abortController?.signal, onFileChange: ( [ attachment ] ) => { + if ( ! isBlobURL( attachment.url ) ) { + dispatch.finishOperation( id, { + attachment, + } ); + } + }, + onSuccess: ( [ attachment ] ) => { dispatch.finishOperation( id, { attachment, } ); diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts index 9fa0e7a0ad5fa0..dd5dabca143bc0 100644 --- a/packages/upload-media/src/store/reducer.ts +++ b/packages/upload-media/src/store/reducer.ts @@ -9,11 +9,9 @@ import { ItemStatus, type OperationFinishAction, type OperationStartAction, - type PauseItemAction, type PauseQueueAction, type QueueItem, type RemoveAction, - type ResumeItemAction, type ResumeQueueAction, type RevokeBlobUrlsAction, type State, @@ -37,8 +35,6 @@ type Action = | AddAction | RemoveAction | CancelAction - | PauseItemAction - | ResumeItemAction | PauseQueueAction | ResumeQueueAction | AddOperationsAction @@ -53,6 +49,7 @@ function reducer( state = DEFAULT_STATE, action: Action = { type: Type.Unknown } ) { + console.log( 'reducer', state, action ); switch ( action.type ) { case Type.PauseQueue: { return { @@ -94,34 +91,6 @@ function reducer( queue: state.queue.filter( ( item ) => item.id !== action.id ), }; - case Type.PauseItem: - return { - ...state, - queue: state.queue.map( - ( item ): QueueItem => - item.id === action.id - ? { - ...item, - status: ItemStatus.Paused, - } - : item - ), - }; - - case Type.ResumeItem: - return { - ...state, - queue: state.queue.map( - ( item ): QueueItem => - item.id === action.id - ? { - ...item, - status: ItemStatus.Processing, - } - : item - ), - }; - case Type.OperationStart: { return { ...state, diff --git a/packages/upload-media/src/store/selectors.ts b/packages/upload-media/src/store/selectors.ts index 95731943f9e0a9..8bcb8c5d63b6a7 100644 --- a/packages/upload-media/src/store/selectors.ts +++ b/packages/upload-media/src/store/selectors.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { ItemStatus, type QueueItem, type Settings, type State } from './types'; +import type { QueueItem, Settings, State } from './types'; /** * Returns all items currently being uploaded. diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts index 4414e1d8886126..80b92e4b14c3d1 100644 --- a/packages/upload-media/src/store/test/reducer.ts +++ b/packages/upload-media/src/store/test/reducer.ts @@ -140,94 +140,6 @@ describe( 'reducer', () => { } ); } ); - describe( `${ Type.PauseItem }`, () => { - it( 'marks an item as paused', () => { - const initialState: State = { - queueStatus: 'active', - blobUrls: {}, - settings: { - mediaUpload: jest.fn(), - }, - queue: [ - { - id: '1', - status: ItemStatus.Processing, - } as QueueItem, - { - id: '2', - status: ItemStatus.Processing, - } as QueueItem, - ], - }; - const state = reducer( initialState, { - type: Type.PauseItem, - id: '2', - } ); - - expect( state ).toEqual( { - queueStatus: 'active', - blobUrls: {}, - settings: { - mediaUpload: expect.any( Function ), - }, - queue: [ - { - id: '1', - status: ItemStatus.Processing, - }, - { - id: '2', - status: ItemStatus.Paused, - }, - ], - } ); - } ); - } ); - - describe( `${ Type.ResumeItem }`, () => { - it( 'marks an item as processing', () => { - const initialState: State = { - queueStatus: 'active', - blobUrls: {}, - settings: { - mediaUpload: jest.fn(), - }, - queue: [ - { - id: '1', - status: ItemStatus.Processing, - } as QueueItem, - { - id: '2', - status: ItemStatus.Paused, - } as QueueItem, - ], - }; - const state = reducer( initialState, { - type: Type.ResumeItem, - id: '2', - } ); - - expect( state ).toEqual( { - queueStatus: 'active', - blobUrls: {}, - settings: { - mediaUpload: expect.any( Function ), - }, - queue: [ - { - id: '1', - status: ItemStatus.Processing, - }, - { - id: '2', - status: ItemStatus.Processing, - }, - ], - } ); - } ); - } ); - describe( `${ Type.AddOperations }`, () => { it( 'appends operations to the list', () => { const initialState: State = { From 93e9ec9624e17d81826777f7560a17db6b51f469 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Dec 2024 20:50:57 +0100 Subject: [PATCH 19/27] Fixes --- .../src/components/provider/index.js | 37 ++++----- packages/editor/README.md | 1 + packages/media-utils/README.md | 1 + .../media-utils/src/utils/upload-media.ts | 1 + packages/upload-media/README.md | 76 +------------------ packages/upload-media/src/store/reducer.ts | 2 - 6 files changed, 19 insertions(+), 99 deletions(-) diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 7a75368afe0e4e..056b6aeac3cd9a 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -105,28 +105,21 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( const mediaUploadSettings = useMediaUploadSettings( _settings ); - // Create a new variable so that the original props.settings.mediaUpload is not modified. - const settings = useMemo( - () => ( { - ..._settings, - mediaUpload: _settings.mediaUpload - ? mediaUpload.bind( - null, - registry, - _settings.experimentalValidateMimeType || noop, - _settings.validateFileSize || noop - ) - : undefined, - } ), - [ _settings, registry ] - ); - - if ( window.__experimentalMediaProcessing && settings.mediaUpload ) { - settings.mediaUpload = mediaUpload.bind( - null, - registry, - settings.experimentalValidateMimeType || noop, - settings.validateFileSize || noop + let settings = _settings; + + if ( window.__experimentalMediaProcessing && _settings.mediaUpload ) { + // Create a new variable so that the original props.settings.mediaUpload is not modified. + settings = useMemo( + () => ( { + ..._settings, + mediaUpload: mediaUpload.bind( + null, + registry, + _settings.experimentalValidateMimeType || noop, + _settings.validateFileSize || noop + ), + } ), + [ _settings, registry ] ); } diff --git a/packages/editor/README.md b/packages/editor/README.md index dd7b53f421a1db..c1c0c810be908c 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -499,6 +499,7 @@ _Parameters_ - _$0.maxUploadFileSize_ `?number`: Maximum upload size in bytes allowed for the site. - _$0.onError_ `Function`: Function called when an error happens. - _$0.onFileChange_ `Function`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `Function`: Function called after the final representation of the file is available. ### MediaUploadCheck diff --git a/packages/media-utils/README.md b/packages/media-utils/README.md index ddf18efb288339..51f6284e042a03 100644 --- a/packages/media-utils/README.md +++ b/packages/media-utils/README.md @@ -54,6 +54,7 @@ _Parameters_ - _$0.maxUploadFileSize_ `UploadMediaArgs[ 'maxUploadFileSize' ]`: Maximum upload size in bytes allowed for the site. - _$0.onError_ `UploadMediaArgs[ 'onError' ]`: Function called when an error happens. - _$0.onFileChange_ `UploadMediaArgs[ 'onFileChange' ]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `UploadMediaArgs[ 'onSuccess' ]`: Function called after the final representation of the file is available. - _$0.wpAllowedMimeTypes_ `UploadMediaArgs[ 'wpAllowedMimeTypes' ]`: List of allowed mime types and file extensions. - _$0.signal_ `UploadMediaArgs[ 'signal' ]`: Abort signal. diff --git a/packages/media-utils/src/utils/upload-media.ts b/packages/media-utils/src/utils/upload-media.ts index e10823bac06d11..ae3c417022cb97 100644 --- a/packages/media-utils/src/utils/upload-media.ts +++ b/packages/media-utils/src/utils/upload-media.ts @@ -52,6 +52,7 @@ interface UploadMediaArgs { * @param $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. * @param $0.onError Function called when an error happens. * @param $0.onFileChange Function called each time a file or a temporary representation of the file is available. + * @param $0.onSuccess Function called after the final representation of the file is available. * @param $0.wpAllowedMimeTypes List of allowed mime types and file extensions. * @param $0.signal Abort signal. */ diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index 86f7d410918185..28a97512cf695c 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -34,40 +34,6 @@ _Parameters_ - _error_ `Error`: Error instance. - _silent_ Whether to cancel the item silently, without invoking its `onError` callback. -#### grantApproval - -Approves a proposed optimized/converted version of a file so it can continue being processed and uploaded. - -_Parameters_ - -- _id_ `number`: Item ID. - -#### optimizeExistingItem - -Adds a new item to the upload queue for optimizing (compressing) an existing item. - -_Parameters_ - -- _$0_ `OptimizeExistingItemArgs`: -- _$0.id_ `OptimizeExistingItemArgs[ 'id' ]`: Attachment ID. -- _$0.url_ `OptimizeExistingItemArgs[ 'url' ]`: URL. -- _$0.fileName_ `[OptimizeExistingItemArgs[ 'fileName' ]]`: File name. -- _$0.poster_ `[OptimizeExistingItemArgs[ 'poster' ]]`: Poster URL. -- _$0.batchId_ `[OptimizeExistingItemArgs[ 'batchId' ]]`: Batch ID. -- _$0.onChange_ `[OptimizeExistingItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. -- _$0.onSuccess_ `[OptimizeExistingItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. -- _$0.onBatchSuccess_ `[OptimizeExistingItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. -- _$0.onError_ `[OptimizeExistingItemArgs[ 'onError' ]]`: Function called when an error happens. -- _$0.additionalData_ `[OptimizeExistingItemArgs[ 'additionalData' ]]`: Additional data to include in the request. - -#### rejectApproval - -Rejects a proposed optimized/converted version of a file by essentially cancelling its further processing. - -_Parameters_ - -- _id_ `number`: Item ID. - ### Selectors @@ -76,24 +42,9 @@ The following selectors are available on the object returned by `wp.data.select( -#### getComparisonDataForApproval - -Returns data to compare the old file vs. the optimized file, given the attachment ID. - -Includes both the URLs and the respective file sizes and the size difference in percentage. - -_Parameters_ - -- _state_ `State`: Upload state. -- _attachmentId_ `number`: Attachment ID. - -_Returns_ - -- `{ oldUrl: string | undefined; oldSize: number; newSize: number; newUrl: string | undefined; sizeDiff: number; } | null`: Comparison data. - #### getItems -Returns all items currently being uploaded, without sub-sizes (children). +Returns all items currently being uploaded. _Parameters_ @@ -115,31 +66,6 @@ _Returns_ - `Settings`: Settings -#### isPendingApproval - -Determines whether there is an item pending approval. - -_Parameters_ - -- _state_ `State`: Upload state. - -_Returns_ - -- `boolean`: Whether there is an item pending approval. - -#### isPendingApprovalByAttachmentId - -Determines whether an item is the first one pending approval given its associated attachment ID. - -_Parameters_ - -- _state_ `State`: Upload state. -- _attachmentId_ `number`: Attachment ID. - -_Returns_ - -- `boolean`: Whether the item is first in the list of items pending approval. - #### isUploading Determines whether any upload is currently in progress. diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts index dd5dabca143bc0..290a319fcbc1da 100644 --- a/packages/upload-media/src/store/reducer.ts +++ b/packages/upload-media/src/store/reducer.ts @@ -6,7 +6,6 @@ import { type AddOperationsAction, type CacheBlobUrlAction, type CancelAction, - ItemStatus, type OperationFinishAction, type OperationStartAction, type PauseQueueAction, @@ -49,7 +48,6 @@ function reducer( state = DEFAULT_STATE, action: Action = { type: Type.Unknown } ) { - console.log( 'reducer', state, action ); switch ( action.type ) { case Type.PauseQueue: { return { From 17dc47e42e0afbbf124c607fd6f578b91fb3bc5d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Dec 2024 21:05:31 +0100 Subject: [PATCH 20/27] Move to folder for RN compat --- .../{validate-file-size.js => validate-file-size/index.js} | 2 +- packages/editor/src/utils/validate-file-size/index.native.js | 1 + .../{validate-mime-type.js => validate-mime-type/index.js} | 2 +- packages/editor/src/utils/validate-mime-type/index.native.js | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) rename packages/editor/src/utils/{validate-file-size.js => validate-file-size/index.js} (90%) create mode 100644 packages/editor/src/utils/validate-file-size/index.native.js rename packages/editor/src/utils/{validate-mime-type.js => validate-mime-type/index.js} (93%) create mode 100644 packages/editor/src/utils/validate-mime-type/index.native.js diff --git a/packages/editor/src/utils/validate-file-size.js b/packages/editor/src/utils/validate-file-size/index.js similarity index 90% rename from packages/editor/src/utils/validate-file-size.js rename to packages/editor/src/utils/validate-file-size/index.js index ba0fb5713a0921..05ebf49c16cf37 100644 --- a/packages/editor/src/utils/validate-file-size.js +++ b/packages/editor/src/utils/validate-file-size/index.js @@ -7,7 +7,7 @@ import { validateFileSize as originalValidateFileSize } from '@wordpress/media-u /** * Internal dependencies */ -import { store as editorStore } from '../store'; +import { store as editorStore } from '../../store'; /** * Verifies whether the file is within the file upload size limits for the site. diff --git a/packages/editor/src/utils/validate-file-size/index.native.js b/packages/editor/src/utils/validate-file-size/index.native.js new file mode 100644 index 00000000000000..c09fa04bd563b6 --- /dev/null +++ b/packages/editor/src/utils/validate-file-size/index.native.js @@ -0,0 +1 @@ +export function validateFileSize() {} diff --git a/packages/editor/src/utils/validate-mime-type.js b/packages/editor/src/utils/validate-mime-type/index.js similarity index 93% rename from packages/editor/src/utils/validate-mime-type.js rename to packages/editor/src/utils/validate-mime-type/index.js index a4d03e628d40c5..7ae5543aaa8e97 100644 --- a/packages/editor/src/utils/validate-mime-type.js +++ b/packages/editor/src/utils/validate-mime-type/index.js @@ -10,7 +10,7 @@ import { /** * Internal dependencies */ -import { store as editorStore } from '../store'; +import { store as editorStore } from '../../store'; /** * Verifies if the caller (e.g. a block) supports this mime type. diff --git a/packages/editor/src/utils/validate-mime-type/index.native.js b/packages/editor/src/utils/validate-mime-type/index.native.js new file mode 100644 index 00000000000000..ee3736988d770a --- /dev/null +++ b/packages/editor/src/utils/validate-mime-type/index.native.js @@ -0,0 +1 @@ +export function validateMimeType() {} From c772bd5dd01cf0f6b9b9d4b3584b975a39b4b569 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Dec 2024 14:35:21 +0100 Subject: [PATCH 21/27] Remove default value --- packages/editor/src/utils/media-sideload/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/utils/media-sideload/index.js b/packages/editor/src/utils/media-sideload/index.js index b4aa133fb2d63e..86fcdc688abf8f 100644 --- a/packages/editor/src/utils/media-sideload/index.js +++ b/packages/editor/src/utils/media-sideload/index.js @@ -8,6 +8,6 @@ import { privateApis } from '@wordpress/media-utils'; */ import { unlock } from '../../lock-unlock'; -const { sideloadMedia: mediaSideload = () => {} } = unlock( privateApis ); +const { sideloadMedia: mediaSideload } = unlock( privateApis ); export default mediaSideload; From addc7ffc701a7cf7668daced9ca83c8913ad42c0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Dec 2024 14:35:36 +0100 Subject: [PATCH 22/27] Pass to `privateSettings` instead --- packages/block-editor/src/components/provider/index.js | 2 +- packages/block-editor/src/store/private-actions.js | 3 +++ .../src/components/provider/use-block-editor-settings.js | 8 +++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 056b6aeac3cd9a..97266bdc917eb9 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -115,7 +115,7 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( mediaUpload: mediaUpload.bind( null, registry, - _settings.experimentalValidateMimeType || noop, + _settings.validateMimeType || noop, _settings.validateFileSize || noop ), } ), diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index e79833e0a73da7..66fa3106312258 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -26,6 +26,9 @@ const castArray = ( maybeArray ) => const privateSettings = [ 'inserterMediaCategories', 'blockInspectorAnimation', + 'mediaSideload', + 'validateFileSize', + 'validateMimeType', ]; /** diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index cffecde6a6d0a1..762529fec27561 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -293,11 +293,9 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { isDistractionFree, keepCaretInsideBlock, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, - __experimentalMediaSideload: hasUploadPermissions - ? mediaSideload - : undefined, - __experimentalValidateFileSize: validateFileSize, - __experimentalValidateMimeType: validateMimeType, + mediaSideload: hasUploadPermissions ? mediaSideload : undefined, + validateFileSize, + validateMimeType, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = From d3ec207c88fb8be1219ee331ebe07cdae968ee28 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 11 Dec 2024 20:59:29 +0100 Subject: [PATCH 23/27] Move validation to block-editor/upload-media --- .../src/components/provider/index.js | 44 +----------- .../provider/use-media-upload-settings.js | 4 +- .../provider/use-block-editor-settings.js | 5 +- .../src/utils/validate-file-size/index.js | 23 ------ .../utils/validate-file-size/index.native.js | 1 - .../src/utils/validate-mime-type/index.js | 27 ------- .../utils/validate-mime-type/index.native.js | 1 - packages/media-utils/src/utils/types.ts | 1 - .../media-utils/src/utils/upload-media.ts | 30 ++++---- .../upload-media/src/get-mime-types-array.ts | 29 ++++++++ packages/upload-media/src/store/actions.ts | 34 ++++++++- packages/upload-media/src/store/types.ts | 5 ++ .../src/test/get-mime-types-array.ts | 47 +++++++++++++ .../src/test/validate-file-size.ts | 70 +++++++++++++++++++ .../src/test/validate-mime-type-for-user.ts | 37 ++++++++++ .../src/test/validate-mime-type.ts | 57 +++++++++++++++ .../upload-media/src/validate-file-size.ts | 44 ++++++++++++ .../src/validate-mime-type-for-user.ts | 46 ++++++++++++ .../upload-media/src/validate-mime-type.ts | 43 ++++++++++++ 19 files changed, 436 insertions(+), 112 deletions(-) delete mode 100644 packages/editor/src/utils/validate-file-size/index.js delete mode 100644 packages/editor/src/utils/validate-file-size/index.native.js delete mode 100644 packages/editor/src/utils/validate-mime-type/index.js delete mode 100644 packages/editor/src/utils/validate-mime-type/index.native.js create mode 100644 packages/upload-media/src/get-mime-types-array.ts create mode 100644 packages/upload-media/src/test/get-mime-types-array.ts create mode 100644 packages/upload-media/src/test/validate-file-size.ts create mode 100644 packages/upload-media/src/test/validate-mime-type-for-user.ts create mode 100644 packages/upload-media/src/test/validate-mime-type.ts create mode 100644 packages/upload-media/src/validate-file-size.ts create mode 100644 packages/upload-media/src/validate-mime-type-for-user.ts create mode 100644 packages/upload-media/src/validate-mime-type.ts diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 97266bdc917eb9..97aa0b95216870 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -29,14 +29,7 @@ const noop = () => {}; * Upload a media file when the file upload button is activated * or when adding a file to the editor via drag & drop. * - * This function is intended to eventually live - * in the `@wordpress/block-editor` package, allowing - * to perform the client-side file processing before eventually - * uploading the media to WordPress. - * * @param {WPDataRegistry} registry - * @param {Function} validateMimeType - * @param {Function} validateFileSize * @param {Object} $3 Parameters object passed to the function. * @param {Array} $3.allowedTypes Array with the types of media that can be uploaded, if unset all types are allowed. * @param {Object} $3.additionalData Additional data to include in the request. @@ -48,8 +41,6 @@ const noop = () => {}; */ function mediaUpload( registry, - validateMimeType, - validateFileSize, { allowedTypes, additionalData = {}, @@ -60,38 +51,14 @@ function mediaUpload( onBatchSuccess, } ) { - const validFiles = []; - - for ( const mediaFile of filesList ) { - /* - Check if the caller (e.g. a block) supports this mime type. - Special case for file types such as HEIC which will be converted before upload anyway. - Another check will be done before upload. - */ - try { - validateMimeType( mediaFile, allowedTypes ); - } catch ( error ) { - onError( error ); - continue; - } - - try { - validateFileSize( mediaFile ); - } catch ( error ) { - onError( error ); - continue; - } - - validFiles.push( mediaFile ); - } - void registry.dispatch( uploadStore ).addItems( { - files: validFiles, + files: filesList, onChange: onFileChange, onSuccess, onBatchSuccess, onError: ( { message } ) => onError( message ), additionalData, + allowedTypes, } ); } @@ -112,12 +79,7 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( settings = useMemo( () => ( { ..._settings, - mediaUpload: mediaUpload.bind( - null, - registry, - _settings.validateMimeType || noop, - _settings.validateFileSize || noop - ), + mediaUpload: mediaUpload.bind( null, registry ), } ), [ _settings, registry ] ); diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index cb8e3b70246854..486066c7aa7303 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -14,7 +14,9 @@ function useMediaUploadSettings( settings ) { return useMemo( () => ( { mediaUpload: settings.mediaUpload, - mediaSideload: settings.__experimentalMediaSideload, + mediaSideload: settings.mediaSideload, + maxUploadFileSize: settings.maxUploadFileSize, + allowedMimeTypes: settings.allowedMimeTypes, } ), [ settings ] ); diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 762529fec27561..d0c2e36d474433 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -24,8 +24,6 @@ import { import inserterMediaCategories from '../media-categories'; import { mediaUpload } from '../../utils'; import { default as mediaSideload } from '../../utils/media-sideload'; -import { validateFileSize } from '../../utils/validate-file-size'; -import { validateMimeType } from '../../utils/validate-mime-type'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; import { useGlobalStylesContext } from '../global-styles-provider'; @@ -48,6 +46,7 @@ const BLOCK_EDITOR_SETTINGS = [ '__experimentalGlobalStylesBaseStyles', 'alignWide', 'blockInspectorTabs', + 'maxUploadFileSize', 'allowedMimeTypes', 'bodyPlaceholder', 'canLockBlocks', @@ -294,8 +293,6 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { keepCaretInsideBlock, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, mediaSideload: hasUploadPermissions ? mediaSideload : undefined, - validateFileSize, - validateMimeType, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/editor/src/utils/validate-file-size/index.js b/packages/editor/src/utils/validate-file-size/index.js deleted file mode 100644 index 05ebf49c16cf37..00000000000000 --- a/packages/editor/src/utils/validate-file-size/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * WordPress dependencies - */ -import { select } from '@wordpress/data'; -import { validateFileSize as originalValidateFileSize } from '@wordpress/media-utils'; - -/** - * Internal dependencies - */ -import { store as editorStore } from '../../store'; - -/** - * Verifies whether the file is within the file upload size limits for the site. - * - * @param {File} file File object. - */ -export function validateFileSize( file ) { - const { getEditorSettings } = select( editorStore ); - return originalValidateFileSize( - file, - getEditorSettings().maxUploadFileSize - ); -} diff --git a/packages/editor/src/utils/validate-file-size/index.native.js b/packages/editor/src/utils/validate-file-size/index.native.js deleted file mode 100644 index c09fa04bd563b6..00000000000000 --- a/packages/editor/src/utils/validate-file-size/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export function validateFileSize() {} diff --git a/packages/editor/src/utils/validate-mime-type/index.js b/packages/editor/src/utils/validate-mime-type/index.js deleted file mode 100644 index 7ae5543aaa8e97..00000000000000 --- a/packages/editor/src/utils/validate-mime-type/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * WordPress dependencies - */ -import { select } from '@wordpress/data'; -import { - validateMimeType as originalValidateMimeType, - validateMimeTypeForUser as originalValidateMimeTypeForUser, -} from '@wordpress/media-utils'; - -/** - * Internal dependencies - */ -import { store as editorStore } from '../../store'; - -/** - * Verifies if the caller (e.g. a block) supports this mime type. - * - * @param {File} file File object. - * @param {string[]} [allowedTypes] Array with the types of media that can be uploaded, if unset all types are allowed. - */ -export function validateMimeType( file, allowedTypes ) { - const { getEditorSettings } = select( editorStore ); - const wpAllowedMimeTypes = getEditorSettings().allowedMimeTypes; - - originalValidateMimeTypeForUser( file, wpAllowedMimeTypes ); - originalValidateMimeType( file, allowedTypes ); -} diff --git a/packages/editor/src/utils/validate-mime-type/index.native.js b/packages/editor/src/utils/validate-mime-type/index.native.js deleted file mode 100644 index ee3736988d770a..00000000000000 --- a/packages/editor/src/utils/validate-mime-type/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export function validateMimeType() {} diff --git a/packages/media-utils/src/utils/types.ts b/packages/media-utils/src/utils/types.ts index c91d4c67cfc466..c4c6882ea2532e 100644 --- a/packages/media-utils/src/utils/types.ts +++ b/packages/media-utils/src/utils/types.ts @@ -199,7 +199,6 @@ export type Attachment = BetterOmit< }; export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void; -export type OnSuccessHandler = ( attachments: Partial< Attachment >[] ) => void; export type OnErrorHandler = ( error: Error ) => void; export type CreateRestAttachment = Partial< RestAttachment >; diff --git a/packages/media-utils/src/utils/upload-media.ts b/packages/media-utils/src/utils/upload-media.ts index ae3c417022cb97..ff3f718076512b 100644 --- a/packages/media-utils/src/utils/upload-media.ts +++ b/packages/media-utils/src/utils/upload-media.ts @@ -12,7 +12,6 @@ import type { Attachment, OnChangeHandler, OnErrorHandler, - OnSuccessHandler, } from './types'; import { uploadToServer } from './upload-to-server'; import { validateMimeType } from './validate-mime-type'; @@ -20,6 +19,12 @@ import { validateMimeTypeForUser } from './validate-mime-type-for-user'; import { validateFileSize } from './validate-file-size'; import { UploadError } from './upload-error'; +declare global { + interface Window { + __experimentalMediaProcessing?: boolean; + } +} + interface UploadMediaArgs { // Additional data to include in the request. additionalData?: AdditionalData; @@ -33,8 +38,6 @@ interface UploadMediaArgs { onError?: OnErrorHandler; // Function called each time a file or a temporary representation of the file is available. onFileChange?: OnChangeHandler; - // Function called once a file has completely finished uploading, including thumbnails. - onSuccess?: OnSuccessHandler; // List of allowed mime types and file extensions. wpAllowedMimeTypes?: Record< string, string > | null; // Abort signal. @@ -52,7 +55,6 @@ interface UploadMediaArgs { * @param $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. * @param $0.onError Function called when an error happens. * @param $0.onFileChange Function called each time a file or a temporary representation of the file is available. - * @param $0.onSuccess Function called after the final representation of the file is available. * @param $0.wpAllowedMimeTypes List of allowed mime types and file extensions. * @param $0.signal Abort signal. */ @@ -64,21 +66,22 @@ export function uploadMedia( { maxUploadFileSize, onError, onFileChange, - onSuccess, signal, }: UploadMediaArgs ) { const validFiles = []; const filesSet: Array< Partial< Attachment > | null > = []; const setAndUpdateFiles = ( index: number, value: Attachment | null ) => { - if ( filesSet[ index ]?.url ) { - revokeBlobURL( filesSet[ index ].url ); + // For client-side media processing, this is handled by the upload-media package. + if ( ! window.__experimentalMediaProcessing ) { + if ( filesSet[ index ]?.url ) { + revokeBlobURL( filesSet[ index ].url ); + } } filesSet[ index ] = value; onFileChange?.( filesSet.filter( ( attachment ) => attachment !== null ) ); - onSuccess?.( filesSet.filter( ( attachment ) => attachment !== null ) ); }; for ( const mediaFile of filesList ) { @@ -110,10 +113,13 @@ export function uploadMedia( { validFiles.push( mediaFile ); - // Set temporary URL to create placeholder media file, this is replaced - // with final file from media gallery when upload is `done` below. - filesSet.push( { url: createBlobURL( mediaFile ) } ); - onFileChange?.( filesSet as Array< Partial< Attachment > > ); + // For client-side media processing, this is handled by the upload-media package. + if ( ! window.__experimentalMediaProcessing ) { + // Set temporary URL to create placeholder media file, this is replaced + // with final file from media gallery when upload is `done` below. + filesSet.push( { url: createBlobURL( mediaFile ) } ); + onFileChange?.( filesSet as Array< Partial< Attachment > > ); + } } validFiles.map( async ( file, index ) => { diff --git a/packages/upload-media/src/get-mime-types-array.ts b/packages/upload-media/src/get-mime-types-array.ts new file mode 100644 index 00000000000000..d4940d36cd6ae5 --- /dev/null +++ b/packages/upload-media/src/get-mime-types-array.ts @@ -0,0 +1,29 @@ +/** + * Browsers may use unexpected mime types, and they differ from browser to browser. + * This function computes a flexible array of mime types from the mime type structured provided by the server. + * Converts { jpg|jpeg|jpe: "image/jpeg" } into [ "image/jpeg", "image/jpg", "image/jpeg", "image/jpe" ] + * + * @param {?Object} wpMimeTypesObject Mime type object received from the server. + * Extensions are keys separated by '|' and values are mime types associated with an extension. + * + * @return An array of mime types or null + */ +export function getMimeTypesArray( + wpMimeTypesObject?: Record< string, string > | null +) { + if ( ! wpMimeTypesObject ) { + return null; + } + return Object.entries( wpMimeTypesObject ).flatMap( + ( [ extensionsString, mime ] ) => { + const [ type ] = mime.split( '/' ); + const extensions = extensionsString.split( '|' ); + return [ + mime, + ...extensions.map( + ( extension ) => `${ type }/${ extension }` + ), + ]; + } + ); +} diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index d9026f1dbcc885..4cc3c3e31ae0e2 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -30,6 +30,9 @@ import type { removeItem, revokeBlobUrls, } from './private-actions'; +import { validateMimeType } from '../validate-mime-type'; +import { validateMimeTypeForUser } from '../validate-mime-type-for-user'; +import { validateFileSize } from '../validate-file-size'; type ActionCreators = { addItem: typeof addItem; @@ -63,6 +66,7 @@ interface AddItemsArgs { onBatchSuccess?: OnBatchSuccessHandler; onError?: OnErrorHandler; additionalData?: AdditionalData; + allowedTypes?: string[]; } /** @@ -75,6 +79,7 @@ interface AddItemsArgs { * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. * @param [$0.onError] Function called when an error happens. * @param [$0.additionalData] Additional data to include in the request. + * @param [$0.allowedTypes] Array with the types of media that can be uploaded, if unset all types are allowed. */ export function addItems( { files, @@ -83,10 +88,37 @@ export function addItems( { onError, onBatchSuccess, additionalData, + allowedTypes, }: AddItemsArgs ) { - return async ( { dispatch }: { dispatch: ActionCreators } ) => { + return async ( { select, dispatch }: ThunkArgs ) => { const batchId = uuidv4(); for ( const file of files ) { + /* + Check if the caller (e.g. a block) supports this mime type. + Special case for file types such as HEIC which will be converted before upload anyway. + Another check will be done before upload. + */ + try { + validateMimeType( file, allowedTypes ); + validateMimeTypeForUser( + file, + select.getSettings().allowedMimeTypes + ); + } catch ( error: unknown ) { + onError?.( error as Error ); + continue; + } + + try { + validateFileSize( + file, + select.getSettings().maxUploadFileSize + ); + } catch ( error: unknown ) { + onError?.( error as Error ); + continue; + } + dispatch.addItem( { file, batchId, diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index a6d4b16987d2f3..5084e006a2cfa9 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -121,7 +121,12 @@ interface UploadMediaArgs { } export interface Settings { + // Function for uploading files to the server. mediaUpload: ( args: UploadMediaArgs ) => void; + // List of allowed mime types and file extensions. + allowedMimeTypes?: Record< string, string > | null; + // Maximum upload file size + maxUploadFileSize?: number; } // Must match the Attachment type from the media-utils package. diff --git a/packages/upload-media/src/test/get-mime-types-array.ts b/packages/upload-media/src/test/get-mime-types-array.ts new file mode 100644 index 00000000000000..156955373bd0da --- /dev/null +++ b/packages/upload-media/src/test/get-mime-types-array.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { getMimeTypesArray } from '../get-mime-types-array'; + +describe( 'getMimeTypesArray', () => { + it( 'should return null if it is "falsy" e.g: undefined or null', () => { + expect( getMimeTypesArray( null ) ).toEqual( null ); + expect( getMimeTypesArray( undefined ) ).toEqual( null ); + } ); + + it( 'should return an empty array if an empty object is passed', () => { + expect( getMimeTypesArray( {} ) ).toEqual( [] ); + } ); + + it( 'should return the type plus a new mime type with type and subtype with the extension if a type is passed', () => { + expect( getMimeTypesArray( { ext: 'chicken' } ) ).toEqual( [ + 'chicken', + 'chicken/ext', + ] ); + } ); + + it( 'should return the mime type passed and a new mime type with type and the extension as subtype', () => { + expect( getMimeTypesArray( { ext: 'chicken/ribs' } ) ).toEqual( [ + 'chicken/ribs', + 'chicken/ext', + ] ); + } ); + + it( 'should return the mime type passed and an additional mime type per extension supported', () => { + expect( getMimeTypesArray( { 'jpg|jpeg|jpe': 'image/jpeg' } ) ).toEqual( + [ 'image/jpeg', 'image/jpg', 'image/jpeg', 'image/jpe' ] + ); + } ); + + it( 'should handle multiple mime types', () => { + expect( + getMimeTypesArray( { 'ext|aaa': 'chicken/ribs', aaa: 'bbb' } ) + ).toEqual( [ + 'chicken/ribs', + 'chicken/ext', + 'chicken/aaa', + 'bbb', + 'bbb/aaa', + ] ); + } ); +} ); diff --git a/packages/upload-media/src/test/validate-file-size.ts b/packages/upload-media/src/test/validate-file-size.ts new file mode 100644 index 00000000000000..31d6af0e7e4a55 --- /dev/null +++ b/packages/upload-media/src/test/validate-file-size.ts @@ -0,0 +1,70 @@ +/** + * Internal dependencies + */ +import { validateFileSize } from '../validate-file-size'; +import { UploadError } from '../upload-error'; + +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +const emptyFile = new window.File( [], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateFileSize', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should error if the file is empty', () => { + expect( () => { + validateFileSize( emptyFile ); + } ).toThrow( + new UploadError( { + code: 'EMPTY_FILE', + message: 'test.jpeg: This file is empty.', + file: imageFile, + } ) + ); + } ); + + it( 'should error if the file is is greater than the maximum', () => { + expect( () => { + validateFileSize( imageFile, 2 ); + } ).toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); + + it( 'should not error if the file is below the limit', () => { + expect( () => { + validateFileSize( imageFile, 100 ); + } ).not.toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); + + it( 'should not error if there is no limit', () => { + expect( () => { + validateFileSize( imageFile ); + } ).not.toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); +} ); diff --git a/packages/upload-media/src/test/validate-mime-type-for-user.ts b/packages/upload-media/src/test/validate-mime-type-for-user.ts new file mode 100644 index 00000000000000..d2566566862142 --- /dev/null +++ b/packages/upload-media/src/test/validate-mime-type-for-user.ts @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import { validateMimeTypeForUser } from '../validate-mime-type-for-user'; +import { UploadError } from '../upload-error'; + +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateMimeTypeForUser', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should not error if wpAllowedMimeTypes is null or missing', async () => { + expect( () => { + validateMimeTypeForUser( imageFile ); + } ).not.toThrow(); + expect( () => { + validateMimeTypeForUser( imageFile, null ); + } ).not.toThrow(); + } ); + + it( 'should error if file type is not allowed for user', async () => { + expect( () => { + validateMimeTypeForUser( imageFile, { aac: 'audio/aac' } ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: + 'test.jpeg: Sorry, you are not allowed to upload this file type.', + file: imageFile, + } ) + ); + } ); +} ); diff --git a/packages/upload-media/src/test/validate-mime-type.ts b/packages/upload-media/src/test/validate-mime-type.ts new file mode 100644 index 00000000000000..a83cdcefe5f99a --- /dev/null +++ b/packages/upload-media/src/test/validate-mime-type.ts @@ -0,0 +1,57 @@ +/** + * Internal dependencies + */ +import { validateMimeType } from '../validate-mime-type'; +import { UploadError } from '../upload-error'; + +const xmlFile = new window.File( [ 'fake_file' ], 'test.xml', { + type: 'text/xml', +} ); +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateMimeType', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should error if allowedTypes contains a partial mime type and the validation fails', async () => { + expect( () => { + validateMimeType( xmlFile, [ 'image' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); + + it( 'should error if allowedTypes contains a complete mime type and the validation fails', async () => { + expect( () => { + validateMimeType( imageFile, [ 'image/gif' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.jpeg: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); + + it( 'should error if allowedTypes contains multiple types and the validation fails', async () => { + expect( () => { + validateMimeType( xmlFile, [ 'video', 'image' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); +} ); diff --git a/packages/upload-media/src/validate-file-size.ts b/packages/upload-media/src/validate-file-size.ts new file mode 100644 index 00000000000000..cc34462b268dda --- /dev/null +++ b/packages/upload-media/src/validate-file-size.ts @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; + +/** + * Verifies whether the file is within the file upload size limits for the site. + * + * @param file File object. + * @param maxUploadFileSize Maximum upload size in bytes allowed for the site. + */ +export function validateFileSize( file: File, maxUploadFileSize?: number ) { + // Don't allow empty files to be uploaded. + if ( file.size <= 0 ) { + throw new UploadError( { + code: 'EMPTY_FILE', + message: sprintf( + // translators: %s: file name. + __( '%s: This file is empty.' ), + file.name + ), + file, + } ); + } + + if ( maxUploadFileSize && file.size > maxUploadFileSize ) { + throw new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: sprintf( + // translators: %s: file name. + __( + '%s: This file exceeds the maximum upload size for this site.' + ), + file.name + ), + file, + } ); + } +} diff --git a/packages/upload-media/src/validate-mime-type-for-user.ts b/packages/upload-media/src/validate-mime-type-for-user.ts new file mode 100644 index 00000000000000..858c583561978e --- /dev/null +++ b/packages/upload-media/src/validate-mime-type-for-user.ts @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; +import { getMimeTypesArray } from './get-mime-types-array'; + +/** + * Verifies if the user is allowed to upload this mime type. + * + * @param file File object. + * @param wpAllowedMimeTypes List of allowed mime types and file extensions. + */ +export function validateMimeTypeForUser( + file: File, + wpAllowedMimeTypes?: Record< string, string > | null +) { + // Allowed types for the current WP_User. + const allowedMimeTypesForUser = getMimeTypesArray( wpAllowedMimeTypes ); + + if ( ! allowedMimeTypesForUser ) { + return; + } + + const isAllowedMimeTypeForUser = allowedMimeTypesForUser.includes( + file.type + ); + + if ( file.type && ! isAllowedMimeTypeForUser ) { + throw new UploadError( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: sprintf( + // translators: %s: file name. + __( + '%s: Sorry, you are not allowed to upload this file type.' + ), + file.name + ), + file, + } ); + } +} diff --git a/packages/upload-media/src/validate-mime-type.ts b/packages/upload-media/src/validate-mime-type.ts new file mode 100644 index 00000000000000..2d99455d7b60f1 --- /dev/null +++ b/packages/upload-media/src/validate-mime-type.ts @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; + +/** + * Verifies if the caller (e.g. a block) supports this mime type. + * + * @param file File object. + * @param allowedTypes List of allowed mime types. + */ +export function validateMimeType( file: File, allowedTypes?: string[] ) { + if ( ! allowedTypes ) { + return; + } + + // Allowed type specified by consumer. + const isAllowedType = allowedTypes.some( ( allowedType ) => { + // If a complete mimetype is specified verify if it matches exactly the mime type of the file. + if ( allowedType.includes( '/' ) ) { + return allowedType === file.type; + } + // Otherwise a general mime type is used, and we should verify if the file mimetype starts with it. + return file.type.startsWith( `${ allowedType }/` ); + } ); + + if ( file.type && ! isAllowedType ) { + throw new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: sprintf( + // translators: %s: file name. + __( '%s: Sorry, this file type is not supported here.' ), + file.name + ), + file, + } ); + } +} From ad01ff2f2bbc2ef92f491ab2901378d4cf086eff Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 11 Dec 2024 21:05:07 +0100 Subject: [PATCH 24/27] Use non-empty files in test --- packages/upload-media/src/store/test/actions.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index e148c9fe496148..adb38ab27128e3 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -27,12 +27,12 @@ function createRegistryWithStores() { return registry; } -const jpegFile = new File( [], 'example.jpg', { +const jpegFile = new File( [ 'foo' ], 'example.jpg', { lastModified: 1234567891, type: 'image/jpeg', } ); -const mp4File = new File( [], 'amazing-video.mp4', { +const mp4File = new File( [ 'foo' ], 'amazing-video.mp4', { lastModified: 1234567891, type: 'video/mp4', } ); @@ -71,10 +71,13 @@ describe( 'actions', () => { describe( 'addItems', () => { it( 'adds multiple items to the queue', () => { + const onError = jest.fn(); registry.dispatch( uploadStore ).addItems( { files: [ jpegFile, mp4File ], + onError, } ); + expect( onError ).not.toHaveBeenCalled(); expect( registry.select( uploadStore ).getItems() ).toHaveLength( 2 ); From e49898673ee5120182fdcf9f5c839f7e49bf9292 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 11 Dec 2024 22:11:34 +0100 Subject: [PATCH 25/27] fix api reference --- packages/media-utils/README.md | 1 - packages/upload-media/README.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/media-utils/README.md b/packages/media-utils/README.md index 51f6284e042a03..ddf18efb288339 100644 --- a/packages/media-utils/README.md +++ b/packages/media-utils/README.md @@ -54,7 +54,6 @@ _Parameters_ - _$0.maxUploadFileSize_ `UploadMediaArgs[ 'maxUploadFileSize' ]`: Maximum upload size in bytes allowed for the site. - _$0.onError_ `UploadMediaArgs[ 'onError' ]`: Function called when an error happens. - _$0.onFileChange_ `UploadMediaArgs[ 'onFileChange' ]`: Function called each time a file or a temporary representation of the file is available. -- _$0.onSuccess_ `UploadMediaArgs[ 'onSuccess' ]`: Function called after the final representation of the file is available. - _$0.wpAllowedMimeTypes_ `UploadMediaArgs[ 'wpAllowedMimeTypes' ]`: List of allowed mime types and file extensions. - _$0.signal_ `UploadMediaArgs[ 'signal' ]`: Abort signal. diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index 28a97512cf695c..abe0409bb828e1 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -23,6 +23,7 @@ _Parameters_ - _$0.onBatchSuccess_ `[AddItemsArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. - _$0.onError_ `[AddItemsArgs[ 'onError' ]]`: Function called when an error happens. - _$0.additionalData_ `[AddItemsArgs[ 'additionalData' ]]`: Additional data to include in the request. +- _$0.allowedTypes_ `[AddItemsArgs[ 'allowedTypes' ]]`: Array with the types of media that can be uploaded, if unset all types are allowed. #### cancelItem From b77e5275a4b30fbae6ce8b72f1784da7fcd1e133 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Dec 2024 16:16:38 +0100 Subject: [PATCH 26/27] Update readme a little bit --- packages/upload-media/README.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index abe0409bb828e1..982e59148fe87c 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -1,6 +1,34 @@ -# `@wordpress/upload-media` +# (Experimental) Upload Media -Core media upload logic implemented with a custom `@wordpress/data` store. +This module is a media upload handler with a queue-like system that is implemented using a custom `@wordpress/data` store. + +Such a system is useful for additional client-side processing of media files (e.g. image compression) before uploading them to a server. + +It is typically used by `@wordpress/block-editor` but can also be leveraged outside of it. + +## Installation + +Install the module + +```bash +npm install @wordpress/upload-media --save +``` + +## Usage + +This is a basic example of how one can interact with the upload data store: + +```js +import { store as uploadStore } from '@wordpress/upload-media'; +import { dispatch } from '@wordpress/data'; + +dispatch( uploadStore ).updateSettings( /* ... */ ); +dispatch( uploadStore ).addItems( [ + /* ... */ +] ); +``` + +Refer to the API reference below or the TypeScript types for further help. ## API Reference From ba97bbfd34a9afa0a839a56761511ba7deb332b3 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 13 Dec 2024 12:33:26 +0100 Subject: [PATCH 27/27] Undo change to privateSettings --- packages/block-editor/src/store/private-actions.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 66fa3106312258..f085eb2807c6fd 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -27,8 +27,6 @@ const privateSettings = [ 'inserterMediaCategories', 'blockInspectorAnimation', 'mediaSideload', - 'validateFileSize', - 'validateMimeType', ]; /**