From 5d00adbc775af24ea3f2d3a37e1dbe8ee14d77c6 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 24 Oct 2024 17:35:50 +0200 Subject: [PATCH] Add back some of the missing utils --- package-lock.json | 1 + packages/upload-media/package.json | 1 + packages/upload-media/src/store/actions.ts | 165 +++++++++++++++++- .../upload-media/src/store/private-actions.ts | 65 +++++++ packages/upload-media/src/store/types.ts | 11 ++ packages/upload-media/src/utils.ts | 45 +++++ 6 files changed, 287 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e2a447edee2ee..c0182f88ba525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56000,6 +56000,7 @@ "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", "@wordpress/vips": "file:../vips", + "mime": "^3.0.0", "uuid": "^9.0.1" }, "engines": { diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 88ed76685ca06..0711ebb12231a 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -35,6 +35,7 @@ "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", "@wordpress/vips": "file:../vips", + "mime": "^3.0.0", "uuid": "^9.0.1" }, "publishConfig": { diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index 222123a2e9bd4..ed8e614c9c72b 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -8,13 +8,18 @@ import { v4 as uuidv4 } from 'uuid'; */ // eslint-disable-next-line no-restricted-syntax import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; +// @ts-ignore -- No types available yet. +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import { vipsCancelOperations } from './utils/vips'; import type { + AddAction, AdditionalData, + ApproveUploadAction, + BatchId, CancelAction, OnBatchSuccessHandler, OnChangeHandler, @@ -23,14 +28,19 @@ import type { QueueItem, QueueItemId, State, + ThumbnailGeneration, } from './types'; -import { Type } from './types'; +import { ItemStatus, OperationType, Type } from './types'; import type { addItem, processItem, removeItem, revokeBlobUrls, } from './private-actions'; +import { UploadError } from '../upload-error'; +import { StubFile } from '../stub-file'; +import { getFileBasename, getFileNameFromUrl } from '../utils'; +import { PREFERENCES_NAME } from '../constants'; type ActionCreators = { addItem: typeof addItem; @@ -101,6 +111,159 @@ export function addItems( { }; } +interface OptimizeExistingItemArgs { + id: number; + url: string; + fileName?: string; + poster?: string; + batchId?: BatchId; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onBatchSuccess?: OnBatchSuccessHandler; + onError?: OnErrorHandler; + additionalData?: AdditionalData; + startTime?: number; +} + +/** + * Adds a new item to the upload queue for optimizing (compressing) an existing item. + * + * @todo Rename id to sourceAttachmentId for consistency + * + * @param $0 + * @param $0.id Attachment ID. + * @param $0.url URL. + * @param [$0.fileName] File name. + * @param [$0.poster] Poster URL. + * @param [$0.batchId] Batch ID. + * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. + * @param [$0.onSuccess] Function called after the file is uploaded. + * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. + * @param [$0.onError] Function called when an error happens. + * @param [$0.additionalData] Additional data to include in the request. + */ +export function optimizeExistingItem( { + id, + url, + fileName, + poster, + batchId, + onChange, + onSuccess, + onBatchSuccess, + onError, + additionalData = {} as AdditionalData, +}: OptimizeExistingItemArgs ) { + return async ( { dispatch, registry }: ThunkArgs ) => { + fileName = fileName || getFileNameFromUrl( url ); + const baseName = getFileBasename( fileName ); + const newFileName = fileName.replace( + baseName, + `${ baseName }-optimized` + ); + + const requireApproval = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'requireApproval' ); + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + // TODO: Same considerations apply as for muteExistingVideo. + + const abortController = new AbortController(); + + const itemId = uuidv4(); + + dispatch< AddAction >( { + type: Type.Add, + item: { + id: itemId, + batchId, + status: ItemStatus.Processing, + sourceFile: new StubFile(), + file: new StubFile(), + attachment: { + url, + poster, + }, + additionalData: { + generate_sub_sizes: 'server' === thumbnailGeneration, + convert_format: false, + ...additionalData, + }, + onChange, + onSuccess, + onBatchSuccess, + onError, + sourceUrl: url, + sourceAttachmentId: id, + operations: [ + [ + OperationType.FetchRemoteFile, + { url, fileName, newFileName }, + ], + [ OperationType.Compress, { requireApproval } ], + OperationType.GenerateMetadata, + OperationType.Upload, + OperationType.ThumbnailGeneration, + ], + abortController, + }, + } ); + + dispatch.processItem( itemId ); + }; +} + +/** + * Rejects a proposed optimized/converted version of a file + * by essentially cancelling its further processing. + * + * @param id Item ID. + */ +export function rejectApproval( id: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { + return; + } + + dispatch.cancelItem( + item.id, + new UploadError( { + code: 'UPLOAD_CANCELLED', + message: 'File upload was cancelled', + file: item.file, + } ), + true + ); + }; +} + +/** + * Approves a proposed optimized/converted version of a file + * so it can continue being processed and uploaded. + * + * @param id Item ID. + */ +export function grantApproval( id: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { + return; + } + + dispatch< ApproveUploadAction >( { + type: Type.ApproveUpload, + id: item.id, + } ); + + dispatch.processItem( item.id ); + }; +} + /** * Cancels an item in the queue based on an error. * diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 4c9702c20fd70..f1c7e1000f750 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -20,10 +20,12 @@ import { UploadError } from '../upload-error'; import { cloneFile, convertBlobToFile, + fetchFile, getFileBasename, getFileExtension, isImageTypeSupported, renameFile, + validateMimeType, } from '../utils'; import { PREFERENCES_NAME } from '../constants'; import { StubFile } from '../stub-file'; @@ -76,6 +78,7 @@ type ActionCreators = { generateThumbnails: typeof generateThumbnails; uploadOriginal: typeof uploadOriginal; revokeBlobUrls: typeof revokeBlobUrls; + fetchRemoteFile: typeof fetchRemoteFile; < T = Record< string, unknown > >( args: T ): void; }; @@ -407,6 +410,13 @@ export function processItem( id: QueueItemId ) { operationArgs as OperationArgs[ OperationType.UploadOriginal ] ); break; + + case OperationType.FetchRemoteFile: + dispatch.fetchRemoteFile( + id, + operationArgs as OperationArgs[ OperationType.FetchRemoteFile ] + ); + break; } }; } @@ -970,6 +980,61 @@ export function sideloadItem( id: QueueItemId ) { }; } +type FetchRemoteFileArgs = OperationArgs[ OperationType.FetchRemoteFile ]; + +/** + * Fetches a remote file from another server and adds it to the item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function fetchRemoteFile( id: QueueItemId, args: FetchRemoteFileArgs ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const sourceFile = await fetchFile( args.url, args.fileName ); + + validateMimeType( sourceFile, args.allowedTypes ); + + if ( args.skipAttachment ) { + dispatch.finishOperation( id, { + sourceFile, + } ); + } else { + const file = args.newFileName + ? renameFile( cloneFile( sourceFile ), args.newFileName ) + : cloneFile( sourceFile ); + + const blobUrl = createBlobURL( sourceFile ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + sourceFile, + file, + attachment: { + url: blobUrl, + }, + } ); + } + } catch ( error ) { + dispatch.cancelItem( + id, + new UploadError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'Remote file could not be downloaded', + file: item.file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + }; +} + /** * Revokes all blob URLs for a given item, freeing up memory. * diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 49397dd8ae04d..6218dc67d2af3 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -185,11 +185,22 @@ export enum OperationType { ResizeCrop = 'RESIZE_CROP', TranscodeImage = 'TRANSCODE_IMAGE', Compress = 'TRANSCODE_COMPRESS', + FetchRemoteFile = 'FETCH_REMOTE_FILE', GenerateMetadata = 'GENERATE_METADATA', Upload = 'UPLOAD', } export interface OperationArgs { + [ OperationType.Compress ]: { + requireApproval?: boolean; + }; + [ OperationType.FetchRemoteFile ]: { + url: string; + fileName: string; + newFileName?: string; + skipAttachment?: boolean; + allowedTypes?: string[]; + }; [ OperationType.TranscodeImage ]: { requireApproval?: boolean; outputFormat?: ImageFormat; diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts index 397ee2ca39fc5..9c64cc1b8e645 100644 --- a/packages/upload-media/src/utils.ts +++ b/packages/upload-media/src/utils.ts @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import mime from 'mime/lite'; + /** * WordPress dependencies */ @@ -94,6 +99,46 @@ export function getFileNameFromUrl( url: string ) { return getFilename( url ) || _x( 'unnamed', 'file name' ); } +/** + * Fetches a remote file and returns a File instance. + * + * @param url URL. + * @param nameOverride File name to use, instead of deriving it from the URL. + */ +export async function fetchFile( url: string, nameOverride?: string ) { + const response = await fetch( url ); + if ( ! response.ok ) { + throw new Error( `Could not fetch remote file: ${ response.status }` ); + } + + const name = nameOverride || getFileNameFromUrl( url ); + const blob = await response.blob(); + + const ext = getFileExtension( name ); + const guessedMimeType = ext ? mime.getType( ext ) : ''; + + let type = ''; + + // blob.type can be an empty string when server does not return a correct Content-Type. + if ( blob.type && blob.type !== 'application/octet-stream' ) { + type = blob.type; + } else if ( guessedMimeType ) { + type = guessedMimeType; + } + + const file = new File( [ blob ], name, { type } ); + + if ( ! type ) { + throw new UploadError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'File could not be downloaded', + file, + } ); + } + + return file; +} + /** * Verifies if the caller supports this mime type. *