Introduction
If you’ve ever pasted content from Google Docs, copied images from other websites, or imported posts that reference external image URLs, you know the pain: WordPress keeps those images as remote links. They’re not in your media library. They break when the source site removes them. And WordPress doesn’t even recognize them as “real” images.
I built this plugin to solve exactly that problem. Every time you save a post, it automatically downloads all external images, uploads them to your media library, and updates your content to use the local versions. No manual intervention is needed.
What This Plugin Does
When you save any post or page (draft, published, or whatever), this plugin:
- Scans your content for any external image URLs
- Downloads each image to your server
- Creates a proper media library entry with all the metadata
- Replaces the entire image tag with WordPress-native markup
- Adds responsive image sizes (srcset, sizes attributes)
- Includes the wp-image-{ID} class so WordPress knows it’s local
It works with both the classic editor and the block editor (Gutenberg). It even handles Google Docs images and other redirected URLs.
Key Features
Smart Image Naming
Images are named based on your post title, not random URLs. If your post is “ChatGPT vs Claude vs Gemini for Product Strategy,” the images become:
chatgpt-vs-claude-vs-gemini-for-product-strategy.jpgchatgpt-vs-claude-vs-gemini-for-product-strategy-2.jpgchatgpt-vs-claude-vs-gemini-for-product-strategy-3.jpg
Optional Metadata Auto-Fill
You can configure whether the plugin auto-fills:
- Image title
- Alt text
- Caption
- Description
By default, all are set to false so you control this yourself.
Performance Safeguards
- Maximum 50 images per save (configurable)
- Content size limit of 5 MB to prevent timeouts
- Deduplication – won’t re-download images that already exist
- Stores a map of processed URLs to avoid redundant downloads
Block Editor Compatible
This version (2.0.0) is a complete rewrite that properly integrates with Gutenberg. The images now include:
- Proper
wp-image-{ID}classes - Responsive image sizes (thumbnail, medium, large, full)
- Srcset and sizes attributes for responsive loading
- Integration with WordPress’s native image handling
Configuration
At the top of the plugin file, you’ll find these settings:
define( 'RR_EXTIMG_SET_TITLE', false );
define( 'RR_EXTIMG_SET_ALT', false );
define( 'RR_EXTIMG_SET_CAPTION', false );
define( 'RR_EXTIMG_SET_DESCRIPTION', false );
define( 'RR_EXTIMG_DEFAULT_SIZE', 'large' );Set any to true to auto-fill that metadata field. The RR_EXTIMG_DEFAULT_SIZE controls which image size gets used in your content (options: thumbnail, medium, large, full).
How It Works (Technical Overview)
The plugin hooks into WordPress’s save process at multiple points:
save_posthook – Catches manual saves in the adminrest_after_insert_posthook – Catches block editor saves via REST APIrest_after_insert_pagehook – Catches page saves via REST API
When triggered, it:
Extracts Images:
- Parses HTML for
<img>tags - Also parses Gutenberg block JSON for image URLs
- Captures the full tag, not just the src
Validates & Downloads:
- Checks if URL is external (not your own domain)
- Validates image extension (jpg, png, gif, webp, svg, ico, bmp)
- Handles Google Docs/Drive redirects
- Downloads to temp file using WordPress’s
download_url()
Creates Media Entry:
- Uses
media_handle_sideload()for proper WordPress integration - Generates all responsive image sizes
- Stores original URL as post meta for reference
- Optionally fills in title, alt, caption, and description
Replaces Content:
- Generates proper WordPress image HTML with all attributes
- Includes
wp-image-{ID}class - Adds srcset and sizes for responsive images
- Replaces the entire
<img>tag in your content - Updates the post without triggering another save loop
Stores Mapping:
- Monitors which URLs have been processed
- Prevents re-downloading on subsequent saves
- Stored as post meta:
_rr_extimg_map
Use Cases
I use this for:
- Content imports from other sites or Google Docs
- Guest posts where contributors paste content with external images
- Legacy content migration to ensure all images are local
- SEO to control image file names and metadata
- Site reliability – no more broken images when external sites go down
Installation
Method 1: Manual Installation
- Copy the plugin code into a new file:
wp-content/plugins/rr-import-external-images/rr-import-external-images.php - Activate the plugin:
- Go to WordPress Admin → Plugins
- Find “RR — Import External Images on Save”
- Click Activate
Method 2: Use a Code Snippet plugin like WPCodeBox or FluentSnippets.
Copy the snippet below and paste it in your favorite code snippet plugin.
Code Sample
The Code
This is version 2.3.0 – a major update that makes images fully compatible with WordPress’s block editor and media library system.
<?php
/**
* Plugin Name: RR — Import External Images on Save
* Description: On save (draft/publish), sideload external <img> to Media Library, rewrite src, and set alt/title/description from post title + increment.
* Version: 2.3.0
*
* Changelog:
* 2.3.0 - Fixed block validation by removing srcset/sizes from saved content
* - Gutenberg adds srcset/sizes automatically on frontend render
* - Block attributes now perfectly match saved HTML
* - No more "unexpected or invalid content" errors
* 2.2.0 - Complete block rebuild for validation
* 2.1.0 - Fixed duplicate uploads
* 2.0.0 - Major update: Full Gutenberg compatibility
* 1.4.0 - Previous stable version
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
/**
* ============================================
* CONFIGURATION OPTIONS
* ============================================
*/
define( 'RR_EXTIMG_SET_TITLE', false );
define( 'RR_EXTIMG_SET_ALT', false );
define( 'RR_EXTIMG_SET_CAPTION', false );
define( 'RR_EXTIMG_SET_DESCRIPTION', false );
define( 'RR_EXTIMG_DEFAULT_SIZE', 'large' );
add_action( 'save_post', 'rr_import_external_images_on_save', 10, 3 );
add_action( 'rest_after_insert_post', 'rr_import_external_images_rest', 10, 3 );
add_action( 'rest_after_insert_page', 'rr_import_external_images_rest', 10, 3 );
function rr_import_external_images_rest( $post, $request, $creating ) {
rr_import_external_images_on_save( $post->ID, $post, ! $creating );
}
function rr_import_external_images_on_save( $post_id, $post, $update ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) return;
if ( empty( $post ) || empty( $post->post_content ) ) return;
rr_require_media_libs();
$ptype = get_post_type( $post );
if ( ! post_type_supports( $ptype, 'editor' ) ) return;
$content = $post->post_content;
if ( strlen( $content ) > 5000000 ) return;
$content = apply_filters( 'rr_extimg_pre_process_content', $content, $post_id );
// Get existing map
$map = get_post_meta( $post_id, '_rr_extimg_map', true );
if ( ! is_array( $map ) ) $map = [];
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
$changed = false;
$image_counter = 1;
// Process Gutenberg image blocks
$content = preg_replace_callback(
'#<!-- wp:image(\s+\{[^}]*\})?\s*-->.*?<!-- /wp:image -->#s',
function( $matches ) use ( &$map, &$image_counter, $post_id, $post, $site_host, &$changed ) {
$block = $matches[0];
// Extract the current URL from JSON or img src
$src = '';
// Try JSON first
if ( preg_match( '#"url"\s*:\s*"([^"]+)"#', $block, $url_match ) ) {
$src = trim( html_entity_decode( stripslashes( $url_match[1] ) ) );
}
// Fallback to img src
if ( empty( $src ) && preg_match( '#<img[^>]+src=(["\'])([^"\']+)\1#i', $block, $src_match ) ) {
$src = trim( html_entity_decode( $src_match[2] ) );
}
if ( empty( $src ) || ! wp_http_validate_url( $src ) ) {
return $block;
}
$host = wp_parse_url( $src, PHP_URL_HOST );
if ( ! $host || strcasecmp( $host, $site_host ) === 0 ) return $block;
// Check if already processed
$attach_id = $map[ $src ] ?? 0;
if ( ! $attach_id ) {
// Check if image already exists in media library
$existing_id = attachment_url_to_postid( $src );
if ( $existing_id ) {
$attach_id = $existing_id;
rr_fill_attachment_texts_if_empty( $existing_id, $src, $post, $image_counter );
} else {
// Download and sideload
$attach_id = rr_sideload_image_as_attachment( $src, $post_id, $post, $image_counter );
if ( is_wp_error( $attach_id ) || ! $attach_id ) {
return $block;
}
}
// Save to map
$map[ $src ] = $attach_id;
}
if ( $attach_id ) {
$new_block = rr_rebuild_image_block( $block, $attach_id );
if ( $new_block !== $block ) {
$changed = true;
$image_counter++;
return $new_block;
}
}
return $block;
},
$content
);
// Process any remaining standalone img tags (classic editor)
$content = preg_replace_callback(
'#<img[^>]+>#i',
function( $matches ) use ( &$map, &$image_counter, $post_id, $post, $site_host, &$changed ) {
$img_tag = $matches[0];
if ( ! preg_match( '#\s+src=(["\'])([^"\']+)\1#i', $img_tag, $src_match ) ) {
return $img_tag;
}
$src = trim( html_entity_decode( $src_match[2] ) );
if ( ! wp_http_validate_url( $src ) ) return $img_tag;
$host = wp_parse_url( $src, PHP_URL_HOST );
if ( ! $host || strcasecmp( $host, $site_host ) === 0 ) return $img_tag;
// Skip if already has wp-image class (already processed)
if ( preg_match( '#wp-image-\d+#', $img_tag ) ) {
return $img_tag;
}
$attach_id = $map[ $src ] ?? 0;
if ( ! $attach_id ) {
$existing_id = attachment_url_to_postid( $src );
if ( $existing_id ) {
$attach_id = $existing_id;
rr_fill_attachment_texts_if_empty( $existing_id, $src, $post, $image_counter );
} else {
$attach_id = rr_sideload_image_as_attachment( $src, $post_id, $post, $image_counter );
if ( is_wp_error( $attach_id ) || ! $attach_id ) {
return $img_tag;
}
}
$map[ $src ] = $attach_id;
}
if ( $attach_id ) {
// Extract existing attributes
$alt = '';
$class = '';
if ( preg_match( '#\s+alt=(["\'])([^"\']*)\1#i', $img_tag, $alt_match ) ) {
$alt = $alt_match[2];
}
if ( preg_match( '#\s+class=(["\'])([^"\']*)\1#i', $img_tag, $class_match ) ) {
$class = $class_match[2];
}
$new_img = rr_generate_wp_image_html( $attach_id, [
'alt' => $alt,
'class' => $class,
] );
if ( $new_img !== $img_tag ) {
$changed = true;
$image_counter++;
return $new_img;
}
}
return $img_tag;
},
$content
);
if ( $changed ) {
remove_action( 'save_post', 'rr_import_external_images_on_save', 10 );
remove_action( 'rest_after_insert_post', 'rr_import_external_images_rest', 10 );
remove_action( 'rest_after_insert_page', 'rr_import_external_images_rest', 10 );
wp_update_post( [ 'ID' => $post_id, 'post_content' => $content ] );
update_post_meta( $post_id, '_rr_extimg_map', $map );
add_action( 'save_post', 'rr_import_external_images_on_save', 10, 3 );
add_action( 'rest_after_insert_post', 'rr_import_external_images_rest', 10, 3 );
add_action( 'rest_after_insert_page', 'rr_import_external_images_rest', 10, 3 );
}
}
/**
* Completely rebuild the image block with proper WordPress structure
*/
function rr_rebuild_image_block( string $old_block, int $attach_id ) : string {
$size = RR_EXTIMG_DEFAULT_SIZE;
// Get image metadata
$meta = wp_get_attachment_metadata( $attach_id );
$full_url = wp_get_attachment_url( $attach_id );
// Get the sized image
$image = wp_get_attachment_image_src( $attach_id, $size );
if ( ! $image ) {
$image = wp_get_attachment_image_src( $attach_id, 'full' );
$size = 'full';
}
if ( ! $image ) return $old_block;
list( $src, $width, $height ) = $image;
// Get alt text (from AI plugin or metadata)
$alt = get_post_meta( $attach_id, '_wp_attachment_image_alt', true );
if ( empty( $alt ) ) {
// Try to extract from old block
if ( preg_match( '#<img[^>]+alt=(["\'])([^"\']*)\1#i', $old_block, $alt_match ) ) {
$alt = $alt_match[2];
}
}
// Extract any existing caption
$caption = '';
if ( preg_match( '#<figcaption[^>]*>(.*?)</figcaption>#s', $old_block, $caption_match ) ) {
$caption = trim( $caption_match[1] );
}
// Extract existing linkDestination if present
$link_destination = 'none';
if ( preg_match( '#"linkDestination"\s*:\s*"([^"]+)"#', $old_block, $link_match ) ) {
$link_destination = $link_match[1];
}
// Extract href if image is linked
$href = '';
if ( preg_match( '#<a[^>]+href=(["\'])([^"\']+)\1#i', $old_block, $href_match ) ) {
$href = $href_match[2];
}
// Build block attributes JSON
$attrs = [
'id' => $attach_id,
'sizeSlug' => $size,
'linkDestination' => $link_destination,
];
// Add width/height if available from metadata
if ( isset( $meta['sizes'][ $size ] ) ) {
$attrs['width'] = (int) $meta['sizes'][ $size ]['width'];
$attrs['height'] = (int) $meta['sizes'][ $size ]['height'];
} elseif ( $size === 'full' && isset( $meta['width'] ) && isset( $meta['height'] ) ) {
$attrs['width'] = (int) $meta['width'];
$attrs['height'] = (int) $meta['height'];
}
$attrs_json = wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES );
// Build srcset and sizes
$srcset = wp_get_attachment_image_srcset( $attach_id, $size );
$sizes_attr = wp_get_attachment_image_sizes( $attach_id, $size );
// Build the img tag - DO NOT include srcset/sizes
// Gutenberg will add them automatically on the frontend
$img_tag = sprintf(
'<img src="%s" alt="%s" class="wp-image-%d"/>',
esc_url( $src ),
esc_attr( $alt ),
$attach_id
);
// Wrap in link if needed
if ( ! empty( $href ) ) {
$img_tag = sprintf( '<a href="%s">%s</a>', esc_url( $href ), $img_tag );
}
// Build figure
$figure_classes = 'wp-block-image size-' . $size;
$figure = sprintf( '<figure class="%s">%s', esc_attr( $figure_classes ), $img_tag );
if ( ! empty( $caption ) ) {
$figure .= '<figcaption class="wp-element-caption">' . $caption . '</figcaption>';
}
$figure .= '</figure>';
// Build complete block
$new_block = sprintf(
"<!-- wp:image %s -->\n%s\n<!-- /wp:image -->",
$attrs_json,
$figure
);
return $new_block;
}
/**
* Generate proper WordPress image HTML with all required attributes
* For classic editor content (not Gutenberg blocks)
*/
function rr_generate_wp_image_html( int $attach_id, array $original_data ) : string {
$size = RR_EXTIMG_DEFAULT_SIZE;
$image = wp_get_attachment_image_src( $attach_id, $size );
if ( ! $image ) {
$image = wp_get_attachment_image_src( $attach_id, 'full' );
}
if ( ! $image ) return '';
list( $src, $width, $height ) = $image;
$alt = get_post_meta( $attach_id, '_wp_attachment_image_alt', true );
if ( empty( $alt ) && ! empty( $original_data['alt'] ) ) {
$alt = $original_data['alt'];
}
$classes = [ "wp-image-{$attach_id}" ];
if ( ! empty( $original_data['class'] ) ) {
$existing_classes = explode( ' ', $original_data['class'] );
foreach ( $existing_classes as $cls ) {
$cls = trim( $cls );
if ( $cls && ! preg_match( '/^wp-image-\d+$/', $cls ) ) {
$classes[] = $cls;
}
}
}
$class_attr = implode( ' ', array_unique( $classes ) );
// For classic editor, we CAN include srcset since it's not validated by Gutenberg
$srcset = wp_get_attachment_image_srcset( $attach_id, $size );
$sizes_attr = wp_get_attachment_image_sizes( $attach_id, $size );
$img_html = sprintf(
'<img src="%s" alt="%s" class="%s"',
esc_url( $src ),
esc_attr( $alt ),
esc_attr( $class_attr )
);
if ( $srcset ) {
$img_html .= sprintf( ' srcset="%s"', esc_attr( $srcset ) );
}
if ( $sizes_attr ) {
$img_html .= sprintf( ' sizes="%s"', esc_attr( $sizes_attr ) );
}
$img_html .= '/>';
return $img_html;
}
function rr_sideload_image_as_attachment( string $url, int $post_id, $post, int $counter ) {
if ( strpos( $url, 'googleusercontent.com' ) !== false ||
strpos( $url, 'docs.google.com' ) !== false ) {
$url = rr_resolve_redirect( $url );
}
$path_parts = pathinfo( wp_parse_url( $url, PHP_URL_PATH ) );
$ext = strtolower( $path_parts['extension'] ?? '' );
$allowed = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp' ];
if ( ! in_array( $ext, $allowed, true ) ) {
return new WP_Error( 'invalid_image', 'Unsupported image format: ' . $ext );
}
$post_title = $post->post_title;
if ( empty( $post_title ) ) {
$post_title = 'image';
}
$custom_filename = rr_generate_filename( $post_title, $counter, $ext );
$temp_file = download_url( $url );
if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}
$file_array = [
'name' => $custom_filename,
'tmp_name' => $temp_file,
];
$id = media_handle_sideload( $file_array, $post_id );
if ( file_exists( $temp_file ) ) {
@unlink( $temp_file );
}
if ( is_wp_error( $id ) ) {
return $id;
}
$path = get_attached_file( $id );
if ( $path ) {
$meta = wp_generate_attachment_metadata( $id, $path );
if ( $meta ) wp_update_attachment_metadata( $id, $meta );
}
update_post_meta( $id, '_rr_source_url', esc_url_raw( $url ) );
// Only fill texts if configured - allows AI plugins to run first
rr_fill_attachment_texts_if_empty( $id, $url, $post, $counter );
return (int) $id;
}
function rr_generate_filename( string $post_title, int $counter, string $ext ) : string {
$slug = sanitize_title( $post_title );
$slug = preg_replace( '/[^a-z0-9\-]/', '', $slug );
$slug = substr( $slug, 0, 50 );
if ( $counter > 1 ) {
$filename = $slug . '-' . $counter . '.' . $ext;
} else {
$filename = $slug . '.' . $ext;
}
return $filename;
}
function rr_resolve_redirect( string $url ) : string {
$response = wp_safe_remote_head( $url, [
'timeout' => 10,
'redirection' => 5,
] );
if ( is_wp_error( $response ) ) {
return $url;
}
$final_url = wp_remote_retrieve_header( $response, 'location' );
return $final_url ? $final_url : $url;
}
/**
* Fill attachment metadata only if enabled and field is empty
* This allows AI plugins and other metadata plugins to run first
*/
function rr_fill_attachment_texts_if_empty( int $attach_id, string $fallback_url = '', $post = null, int $counter = 1 ) : void {
if ( ! RR_EXTIMG_SET_TITLE && ! RR_EXTIMG_SET_ALT && ! RR_EXTIMG_SET_CAPTION && ! RR_EXTIMG_SET_DESCRIPTION ) {
return;
}
$label = '';
if ( $post && ! empty( $post->post_title ) ) {
$label = $post->post_title;
if ( $counter > 1 ) {
$label .= ' ' . $counter;
}
} else {
$path = get_attached_file( $attach_id );
$base = '';
if ( $path && file_exists( $path ) ) {
$pi = pathinfo( $path );
$base = $pi['filename'] ?? '';
}
if ( ! $base && $fallback_url ) {
$pi = pathinfo( wp_parse_url( $fallback_url, PHP_URL_PATH ) ?? '' );
$base = $pi['filename'] ?? '';
}
$label = rr_humanize_filename( $base );
}
if ( $label === '' ) return;
if ( RR_EXTIMG_SET_TITLE ) {
$att = get_post( $attach_id );
if ( $att && empty( trim( $att->post_title ) ) ) {
wp_update_post( [
'ID' => $attach_id,
'post_title' => $label,
] );
}
}
if ( RR_EXTIMG_SET_ALT ) {
$alt = get_post_meta( $attach_id, '_wp_attachment_image_alt', true );
if ( empty( $alt ) ) {
update_post_meta( $attach_id, '_wp_attachment_image_alt', $label );
}
}
if ( RR_EXTIMG_SET_CAPTION ) {
$cur_caption = get_post_field( 'post_excerpt', $attach_id );
if ( empty( trim( $cur_caption ) ) ) {
wp_update_post( [
'ID' => $attach_id,
'post_excerpt' => $label,
] );
}
}
if ( RR_EXTIMG_SET_DESCRIPTION ) {
$cur_desc = get_post_field( 'post_content', $attach_id );
if ( empty( trim( $cur_desc ) ) ) {
wp_update_post( [
'ID' => $attach_id,
'post_content' => $label,
] );
}
}
}
function rr_humanize_filename( string $name ) : string {
$name = urldecode( $name );
$name = preg_replace( '/[_\-]+/u', ' ', $name );
$name = preg_replace( '/\s+/u', ' ', $name );
$name = trim( $name );
if ( $name === '' ) return '';
if ( function_exists( 'mb_convert_case' ) ) {
$name = mb_convert_case( $name, MB_CASE_TITLE, 'UTF-8' );
} else {
$name = ucwords( strtolower( $name ) );
}
return sanitize_text_field( $name );
}
function rr_require_media_libs() : void {
if ( ! function_exists( 'download_url' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
require_once ABSPATH . 'wp-admin/includes/image.php';
}
if ( ! function_exists( 'media_sideload_image' ) ) {
require_once ABSPATH . 'wp-admin/includes/media.php';
}
}