Skip to content

Commit

Permalink
Deduplicate uploads and media filenames
Browse files Browse the repository at this point in the history
  • Loading branch information
sgomes committed Sep 6, 2024
1 parent 058ebdc commit 01505fd
Show file tree
Hide file tree
Showing 3 changed files with 393 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
import { useState } from '@wordpress/element';
import { isBlobURL } from '@wordpress/blob';

/**
* Internal dependencies
*/
import { fetchMedia } from './media-util';

function flattenBlocks( blocks ) {
const result = [];

Expand All @@ -25,6 +30,10 @@ function flattenBlocks( blocks ) {
return result;
}

// Determine whether a block has external media.
// Different blocks use different attribute names (and potentially
// different logic as well) in determining whether the media is
// present, and whether it's external.
function hasExternalMedia( block ) {
if ( block.name === 'core/image' || block.name === 'core/cover' ) {
return block.attributes.url && ! block.attributes.id;
Expand All @@ -35,6 +44,9 @@ function hasExternalMedia( block ) {
}
}

// Retrieve media info from a block.
// Different blocks use different attribute names, so we need this
// function to normalize things into a consistent naming scheme.
function getMediaInfo( block ) {
if ( block.name === 'core/image' || block.name === 'core/cover' ) {
const { url, alt, id } = block.attributes;
Expand All @@ -47,6 +59,7 @@ function getMediaInfo( block ) {
}
}

// Image component to represent a single image in the upload dialog.
function Image( { clientId, alt, url } ) {
const { selectBlock } = useDispatch( blockEditorStore );
return (
Expand Down Expand Up @@ -80,7 +93,7 @@ function Image( { clientId, alt, url } ) {
);
}

export default function PostFormatPanel() {
export default function MaybeUploadMediaPanel() {
const [ isUploading, setIsUploading ] = useState( false );
const [ isAnimating, setIsAnimating ] = useState( false );
const [ hadUploadError, setHadUploadError ] = useState( false );
Expand All @@ -91,6 +104,8 @@ export default function PostFormatPanel() {
} ),
[]
);

// Get a list of blocks with external media.
const blocksWithExternalMedia = flattenBlocks( editorBlocks ).filter(
( block ) => hasExternalMedia( block )
);
Expand All @@ -107,6 +122,9 @@ export default function PostFormatPanel() {
</span>,
];

// Update an individual block to point to newly-added library media.
// Different blocks use different attribute names, so we need this
// function to ensure we modify the correct attributes for each type.
function updateBlockWithUploadedMedia( block, media ) {
if ( block.name === 'core/image' || block.name === 'core/cover' ) {
return updateBlockAttributes( block.clientId, {
Expand All @@ -123,16 +141,26 @@ export default function PostFormatPanel() {
}
}

// Handle fetching and uploading all external media in the post.
function uploadImages() {
setIsUploading( true );
setHadUploadError( false );
Promise.all(

// Multiple blocks can be using the same URL, so we
// should ensure we only fetch and upload each of them once.
const mediaUrls = new Set(
blocksWithExternalMedia.map( ( block ) => {
const { url } = getMediaInfo( block );
return window
.fetch( url.includes( '?' ) ? url : url + '?' )
.then( ( response ) => response.blob() )
.then( ( blob ) =>
return url;
} )
);

// Create an upload promise for each URL, that we can wait for in all
// blocks that make use of that media.
const uploadPromises = Object.fromEntries(
fetchMedia( [ ...mediaUrls ] ).map( ( { url, blobPromise } ) => {
const uploadPromise = blobPromise.then(
( blob ) =>
new Promise( ( resolve, reject ) => {
mediaUpload( {
filesList: [ blob ],
Expand All @@ -141,22 +169,30 @@ export default function PostFormatPanel() {
return;
}

updateBlockWithUploadedMedia(
block,
media
);
resolve();
resolve( media );
},
onError() {
setHadUploadError( true );
reject();
},
} );
} ).then( () => setIsAnimating( true ) )
} )
);

return [ url, uploadPromise ];
} )
);

// Wait for all blocks to be updated with library media.
Promise.all(
blocksWithExternalMedia.map( ( block ) => {
const { url } = getMediaInfo( block );

return uploadPromises[ url ]
.then( ( media ) =>
updateBlockWithUploadedMedia( block, media )
)
.catch( () => {
setHadUploadError( true );
} );
.then( () => setIsAnimating( true ) )
.catch( () => setHadUploadError( true ) );
} )
).finally( () => {
setIsUploading( false );
Expand Down
120 changes: 120 additions & 0 deletions packages/editor/src/components/post-publish-panel/media-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { v4 as uuid } from 'uuid';

/**
* WordPress dependencies
*/
import { getFilename } from '@wordpress/url';

// Generate a list of unique filenames given a list of URLs.
// We want all basenames to be unique, since sometimes the extension
// doesn't reflect the mime type, and may end up getting changed by
// the server, on upload.
export function generateUniqueFilenames( urls ) {
const basenames = new Set();

return urls.map( ( url ) => {
// We prefer to match the remote filename, if possible.
const filename = getFilename( url );
let basename = '';
let extension = '';

if ( filename ) {
const parts = filename.split( '.' );
if ( parts.length > 1 ) {
// Assume the last part is the extension.
extension = parts.pop();
}
basename = parts.join( '.' );
}

if ( ! basename ) {
// It looks like we don't have a basename, so let's use a UUID.
basename = uuid();
}

if ( basenames.has( basename ) ) {
// Append a UUID to deduplicate the basename.
// The server will try to deduplicate on its own if we don't do this,
// but it may run into a race condition
// (see https://github.com/WordPress/gutenberg/issues/64899).
// Deduplicating the filenames before uploading is safer.
basename = `${ basename }-${ uuid() }`;
}

basenames.add( basename );

return { url, basename, extension };
} );
}

const mimeTypeToExtension = {
'image/apng': 'apng',
'image/avif': 'avif',
'image/bmp': 'bmp',
'image/gif': 'gif',
'image/vnd.microsoft.icon': 'ico',
'image/jpeg': 'jpg',
'image/jxl': 'jxl',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/tiff': 'tiff',
'image/webp': 'webp',
'video/x-msvideo': 'avi',
'video/mp4': 'mp4',
'video/mpeg': 'mpeg',
'video/ogg': 'ogg',
'video/mp2t': 'ts',
'video/webm': 'webm',
'video/3gpp': '3gp',
'video/3gpp2': '3g2',
};

// Get the file extension to use for a given mime type.
export function getExtensionFromMimeType( mime ) {
mime = ( mime ?? '' ).toLowerCase();

let extension = mimeTypeToExtension[ mime ];

if ( ! extension ) {
// We don't know which extension to use, so we need to fall back to
// something safe. The server should replace it with an appropriate
// extension for the mime type.
if ( mime.startsWith( 'image/' ) ) {
extension = 'png';
}
if ( mime.startsWith( 'video/' ) ) {
extension = 'mp4';
}
}

// If all else fails, try an empty extension.
// The server will probably reject the upload, but there isn't much
// else we can do.
return extension || '';
}

// Returns an array of { url, blobPromise } objects, where the promise
// points to a fetched blob. Blobs will have unique filenames.
export function fetchMedia( urls ) {
return generateUniqueFilenames( urls ).map(
( { url, basename, extension } ) => {
const blobPromise = window
.fetch( url.includes( '?' ) ? url : url + '?' )
.then( ( response ) => response.blob() )
.then( ( blob ) => {
// Not all remote filenames have an extension, but we need to
// provide one, or the server is likely to reject the upload.
if ( ! extension ) {
extension = getExtensionFromMimeType( blob.type );
}
blob.name = `${ basename }.${ extension }`;
return blob;
} );

return { url, blobPromise };
}
);
}
Loading

0 comments on commit 01505fd

Please sign in to comment.