diff --git a/bin/check-licenses.mjs b/bin/check-licenses.mjs index 458590e696a9f..b453ebd84cd3a 100755 --- a/bin/check-licenses.mjs +++ b/bin/check-licenses.mjs @@ -10,7 +10,7 @@ import { spawnSync } from 'node:child_process'; */ import { checkDepsInTree } from '../packages/scripts/utils/license.js'; -const ignored = [ '@ampproject/remapping' ]; +const ignored = [ '@ampproject/remapping', 'webpack' ]; /* * `wp-scripts check-licenses` uses prod and dev dependencies of the package to scan for dependencies. With npm workspaces, workspace packages (the @wordpress/* packages) are not listed in the main package json and this approach does not work. diff --git a/package-lock.json b/package-lock.json index 98865c9d041a5..5ac451960b113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10698,6 +10698,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", @@ -10830,6 +10836,34 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "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", @@ -17470,6 +17504,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 @@ -56199,6 +56237,28 @@ "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/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", + "@wordpress/url": "file:../url", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "packages/url": { "name": "@wordpress/url", "version": "4.13.0", diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index abbb122ae3a0e..056b6aeac3cd9 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -2,8 +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, + store as uploadStore, +} from '@wordpress/upload-media'; /** * Internal dependencies @@ -14,12 +19,109 @@ 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 */ +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 { children, settings, stripExperimentalSettings = false } = props; + const { + settings: _settings, + registry, + stripExperimentalSettings = false, + } = props; + + const mediaUploadSettings = useMediaUploadSettings( _settings ); + + 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 ] + ); + } const { __experimentalUpdateSettings } = unlock( useDispatch( blockEditorStore ) @@ -44,12 +146,25 @@ 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 } ); + + 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 0000000000000..cb8e3b7024685 --- /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/README.md b/packages/editor/README.md index dd7b53f421a1d..c1c0c810be908 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/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index f5c45f431e2c8..cffecde6a6d0a 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,9 @@ 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'; @@ -290,6 +293,11 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { isDistractionFree, keepCaretInsideBlock, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, + __experimentalMediaSideload: hasUploadPermissions + ? mediaSideload + : undefined, + __experimentalValidateFileSize: validateFileSize, + __experimentalValidateMimeType: validateMimeType, __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 0000000000000..b4aa133fb2d63 --- /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 0000000000000..d84a912ec92de --- /dev/null +++ b/packages/editor/src/utils/media-sideload/index.native.js @@ -0,0 +1 @@ +export default function mediaSideload() {} diff --git a/packages/editor/src/utils/media-upload/index.js b/packages/editor/src/utils/media-upload/index.js index 6b39db2443cc3..0d970d91ce745 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/index.js b/packages/editor/src/utils/validate-file-size/index.js new file mode 100644 index 0000000000000..05ebf49c16cf3 --- /dev/null +++ b/packages/editor/src/utils/validate-file-size/index.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-file-size/index.native.js b/packages/editor/src/utils/validate-file-size/index.native.js new file mode 100644 index 0000000000000..c09fa04bd563b --- /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/index.js b/packages/editor/src/utils/validate-mime-type/index.js new file mode 100644 index 0000000000000..7ae5543aaa8e9 --- /dev/null +++ b/packages/editor/src/utils/validate-mime-type/index.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/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 0000000000000..ee3736988d770 --- /dev/null +++ b/packages/editor/src/utils/validate-mime-type/index.native.js @@ -0,0 +1 @@ +export function validateMimeType() {} diff --git a/packages/media-utils/README.md b/packages/media-utils/README.md index ddf18efb28833..51f6284e042a0 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 1bc861cfb3b60..ae3c417022cb9 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. */ @@ -63,6 +64,7 @@ export function uploadMedia( { maxUploadFileSize, onError, onFileChange, + onSuccess, signal, }: UploadMediaArgs ) { const validFiles = []; @@ -76,6 +78,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/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts index 5a5fb3f39fa18..1ac08a71550ff 100644 --- a/packages/private-apis/src/implementation.ts +++ b/packages/private-apis/src/implementation.ts @@ -32,6 +32,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/dataviews', '@wordpress/fields', '@wordpress/media-utils', + '@wordpress/upload-media', ]; /** diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md new file mode 100644 index 0000000000000..e04ce921cdfdc --- /dev/null +++ b/packages/upload-media/CHANGELOG.md @@ -0,0 +1,5 @@ + + +## Unreleased + +Initial release. diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md new file mode 100644 index 0000000000000..28a97512cf695 --- /dev/null +++ b/packages/upload-media/README.md @@ -0,0 +1,107 @@ +# `@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. + + + +### Selectors + +The following selectors are available on the object returned by `wp.data.select( 'core/upload-media' )`: + + + +#### getItems + +Returns all items currently being uploaded. + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `QueueItem[]`: Queue items. + +#### getSettings + +Returns the media upload settings. + +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `Settings`: Settings + +#### 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. + + diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json new file mode 100644 index 0000000000000..ec7eaabbb3940 --- /dev/null +++ b/packages/upload-media/package.json @@ -0,0 +1,45 @@ +{ + "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", + "wpScript": true, + "types": "build-types", + "dependencies": { + "@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", + "@wordpress/url": "file:../url", + "uuid": "^9.0.1" + }, + "publishConfig": { + "access": "public" + } +} 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 0000000000000..0bc187e6a1d86 --- /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 0000000000000..9a47a5601d33e --- /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/image-file.ts b/packages/upload-media/src/image-file.ts new file mode 100644 index 0000000000000..2c1a43ee1ab67 --- /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 0000000000000..d105c2dba9039 --- /dev/null +++ b/packages/upload-media/src/index.ts @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import { store as uploadStore } from './store'; + +export { uploadStore as store }; + +export { default as MediaUploadProvider } from './components/provider'; +export { UploadError } from './upload-error'; + +export type { ImageFormat } 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 0000000000000..5089cb80e4bb4 --- /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 0000000000000..d9026f1dbcc88 --- /dev/null +++ b/packages/upload-media/src/store/actions.ts @@ -0,0 +1,151 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; + +/** + * WordPress dependencies + */ +import type { createRegistry } from '@wordpress/data'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; + +/** + * Internal dependencies + */ +import type { + AdditionalData, + CancelAction, + OnBatchSuccessHandler, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, + QueueItemId, + State, +} 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; +}; + +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; + } + + item.abortController?.abort(); + + if ( ! silent ) { + 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?.(); + } + }; +} diff --git a/packages/upload-media/src/store/constants.ts b/packages/upload-media/src/store/constants.ts new file mode 100644 index 0000000000000..ad0960cf62f46 --- /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 new file mode 100644 index 0000000000000..c74f59ea7a7cf --- /dev/null +++ b/packages/upload-media/src/store/index.ts @@ -0,0 +1,43 @@ +/** + * 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'; +import { STORE_NAME } from './constants'; + +/** + * 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, + actions, +} ); + +register( store ); +// @ts-ignore +unlock( store ).registerPrivateActions( privateActions ); +// @ts-ignore +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 0000000000000..a4d4ee7b99c78 --- /dev/null +++ b/packages/upload-media/src/store/private-actions.ts @@ -0,0 +1,407 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; + +/** + * WordPress dependencies + */ +import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +import type { createRegistry } from '@wordpress/data'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; + +/** + * Internal dependencies + */ +import { cloneFile, convertBlobToFile } from '../utils'; +import { StubFile } from '../stub-file'; +import type { + AddAction, + AdditionalData, + AddOperationsAction, + BatchId, + CacheBlobUrlAction, + OnBatchSuccessHandler, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, + Operation, + OperationFinishAction, + OperationStartAction, + PauseQueueAction, + QueueItem, + QueueItemId, + ResumeQueueAction, + RevokeBlobUrlsAction, + Settings, + State, + UpdateSettingsAction, +} from './types'; +import { ItemStatus, OperationType, Type } from './types'; +import type { cancelItem } from './actions'; + +type ActionCreators = { + cancelItem: typeof cancelItem; + addItem: typeof addItem; + removeItem: typeof removeItem; + prepareItem: typeof prepareItem; + processItem: typeof processItem; + finishOperation: typeof finishOperation; + uploadItem: typeof uploadItem; + 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 }: ThunkArgs ) => { + 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: { + convert_format: false, + ...additionalData, + }, + onChange, + onSuccess, + onBatchSuccess, + onError, + sourceUrl, + sourceAttachmentId, + abortController: abortController || new AbortController(), + operations: Array.isArray( operations ) + ? operations + : [ OperationType.Prepare ], + }, + } ); + + 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; + + const { attachment, onChange, onSuccess, onBatchSuccess, batchId } = + item; + + const operation = Array.isArray( item.operations?.[ 0 ] ) + ? item.operations[ 0 ][ 0 ] + : item.operations?.[ 0 ]; + + 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 ( attachment ) { + onSuccess?.( [ attachment ] ); + } + + // dispatch.removeItem( id ); + dispatch.revokeBlobUrls( id ); + + if ( batchId && select.isBatchUploaded( batchId ) ) { + 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.Upload: + dispatch.uploadItem( id ); + break; + } + }; +} + +/** + * 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 ( { dispatch }: ThunkArgs ) => { + const operations: Operation[] = [ OperationType.Upload ]; + + dispatch< AddOperationsAction >( { + type: Type.AddOperations, + id, + operations, + } ); + + dispatch.finishOperation( id, {} ); + }; +} + +/** + * 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 ] ) => { + if ( ! isBlobURL( attachment.url ) ) { + dispatch.finishOperation( id, { + attachment, + } ); + } + }, + onSuccess: ( [ attachment ] ) => { + dispatch.finishOperation( id, { + attachment, + } ); + }, + onError: ( error ) => { + dispatch.cancelItem( id, error ); + }, + } ); + }; +} + +/** + * 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, + } ); + }; +} + +/** + * 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, + }; +} 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 0000000000000..f2cfdbef76df8 --- /dev/null +++ b/packages/upload-media/src/store/private-selectors.ts @@ -0,0 +1,113 @@ +/** + * Internal dependencies + */ +import { + type BatchId, + 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 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 ); +} + +/** + * 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 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 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 0000000000000..290a319fcbc1d --- /dev/null +++ b/packages/upload-media/src/store/reducer.ts @@ -0,0 +1,195 @@ +/** + * Internal dependencies + */ +import { + type AddAction, + type AddOperationsAction, + type CacheBlobUrlAction, + type CancelAction, + type OperationFinishAction, + type OperationStartAction, + type PauseQueueAction, + type QueueItem, + type RemoveAction, + type ResumeQueueAction, + type RevokeBlobUrlsAction, + type State, + Type, + type UnknownAction, + type UpdateSettingsAction, +} from './types'; + +const noop = () => {}; + +const DEFAULT_STATE: State = { + queue: [], + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: noop, + }, +}; + +type Action = + | AddAction + | RemoveAction + | CancelAction + | PauseQueueAction + | ResumeQueueAction + | AddOperationsAction + | 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 + ), + }; + + case Type.Remove: + return { + ...state, + queue: state.queue.filter( ( item ) => item.id !== action.id ), + }; + + 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, + }, + }; + } ), + }; + + 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 0000000000000..8bcb8c5d63b6a --- /dev/null +++ b/packages/upload-media/src/store/selectors.ts @@ -0,0 +1,67 @@ +/** + * Internal dependencies + */ +import type { QueueItem, Settings, State } from './types'; + +/** + * Returns all items currently being uploaded. + * + * @param state Upload state. + * + * @return Queue items. + */ +export function getItems( state: State ): QueueItem[] { + return state.queue; +} + +/** + * 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 0000000000000..e148c9fe49614 --- /dev/null +++ b/packages/upload-media/src/store/test/actions.ts @@ -0,0 +1,109 @@ +/** + * WordPress dependencies + */ +import { createRegistry } from '@wordpress/data'; + +type WPDataRegistry = ReturnType< typeof createRegistry >; + +/** + * 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(), +} ) ); + +function createRegistryWithStores() { + // Create a registry and register used stores. + const registry = createRegistry(); + // @ts-ignore + [ uploadStore ].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 0000000000000..80b92e4b14c3d --- /dev/null +++ b/packages/upload-media/src/store/test/reducer.ts @@ -0,0 +1,279 @@ +/** + * 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', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + 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', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + 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', + 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.Cancel, + id: '2', + error: new Error(), + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + 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', + 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.Remove, + id: '1', + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + queue: [ + { + id: '2', + status: ItemStatus.Processing, + }, + ], + } ); + } ); + } ); + + describe( `${ Type.AddOperations }`, () => { + it( 'appends operations to the list', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ OperationType.Upload ], + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.AddOperations, + id: '1', + operations: [ OperationType.Upload ], + } ); + + expect( state ).toEqual( { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + operations: [ + OperationType.Upload, + OperationType.Upload, + ], + }, + ], + } ); + } ); + } ); + + describe( `${ Type.OperationStart }`, () => { + it( 'marks an item as processing', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + 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', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + 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', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + 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', + blobUrls: {}, + settings: { + mediaUpload: expect.any( Function ), + }, + 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 0000000000000..716b7792ef77a --- /dev/null +++ b/packages/upload-media/src/store/test/selectors.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { + getItems, + 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', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + }; + + 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.Paused, + }, + ] as QueueItem[], + queueStatus: 'paused', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + }; + + 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.Processing, + }, + ] as QueueItem[], + queueStatus: 'paused', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + }; + + expect( + isUploadingByUrl( state, 'https://example.com/one.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, + }, + }, + ] as QueueItem[], + queueStatus: 'paused', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + }; + + expect( isUploadingById( state, 123 ) ).toBe( true ); + expect( isUploadingById( state, 789 ) ).toBe( false ); + } ); + } ); +} ); diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts new file mode 100644 index 0000000000000..a6d4b16987d2f --- /dev/null +++ b/packages/upload-media/src/store/types.ts @@ -0,0 +1,167 @@ +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; + abortController?: AbortController; +} + +export interface State { + queue: QueueItem[]; + queueStatus: QueueStatus; + 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', + 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 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; +} + +export interface Settings { + mediaUpload: ( args: UploadMediaArgs ) => void; +} + +// 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', +} + +export enum OperationType { + Prepare = 'PREPARE', + Upload = 'UPLOAD', +} + +export interface OperationArgs {} + +type OperationWithArgs< T extends keyof OperationArgs = keyof OperationArgs > = + [ T, OperationArgs[ T ] ]; + +export type Operation = OperationType | OperationWithArgs; + +export type AdditionalData = Record< string, unknown >; + +export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif'; diff --git a/packages/upload-media/src/stub-file.ts b/packages/upload-media/src/stub-file.ts new file mode 100644 index 0000000000000..f308c0d48b6f4 --- /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 0000000000000..6bf968a764346 --- /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 0000000000000..b26c4571be73f --- /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 0000000000000..6e2d497472e76 --- /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 0000000000000..e48ae2df6ebce --- /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 0000000000000..4d5f025ed8cf3 --- /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 0000000000000..d712e9dcdb696 --- /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 0000000000000..3950ec0388792 --- /dev/null +++ b/packages/upload-media/src/utils.ts @@ -0,0 +1,90 @@ +/** + * WordPress dependencies + */ +import { getFilename } from '@wordpress/url'; +import { _x } from '@wordpress/i18n'; + +/** + * 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' ); +} diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json new file mode 100644 index 0000000000000..b0bc834698905 --- /dev/null +++ b/packages/upload-media/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$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": "../compose" }, + { "path": "../data" }, + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../private-apis" }, + { "path": "../url" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 1010054ea512e..93d0bd976dd00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,6 +55,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" },