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..6410cb6be89dde 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", 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..cd2a7bb1e67965 --- /dev/null +++ b/packages/upload-media/src/index.ts @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { store as uploadStore } from './store'; + +export { uploadStore as store }; + +export { UploadError } from './upload-error'; + +export type { + ImageFormat, + ImageLibrary, + 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..4d405b53f8cae9 --- /dev/null +++ b/packages/upload-media/src/store/actions.ts @@ -0,0 +1,205 @@ +/** + * 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'; +// @ts-ignore -- No types available yet. +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { PREFERENCES_NAME } from '../constants'; +import { vipsCancelOperations } from './utils/vips'; +import type { + AdditionalData, + CancelAction, + ImageLibrary, + 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, registry }: 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 ); + } + + const imageLibrary: ImageLibrary = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; + + if ( 'vips' === imageLibrary ) { + 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..10ca93d8ffd24c --- /dev/null +++ b/packages/upload-media/src/store/types.ts @@ -0,0 +1,229 @@ +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 ImageLibrary = 'browser' | 'vips'; + +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..6719fd19e8649c --- /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' */ '@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" },