attribute names that hold attachment IDs. */
const ID_BLOCKS = array(
'core/image' => array( 'id' ),
'core/cover' => array( 'id' ),
'core/file' => array( 'id' ),
'core/video' => array( 'id' ),
'core/audio' => array( 'id' ),
'core/media-text' => array( 'mediaId' ),
'core/gallery' => array( 'ids' ),
);
const BLOCK_RE = '//s';
/** Attribute names that hold a media ID (or list of IDs) in custom blocks. */
const MEDIA_KEY_RE = '/(?:image|img|media|photo|picture|logo|icon|background|bg|video|audio|file|gallery|poster|thumbnail|thumb|avatar|attachment|featured)[_-]?(?:id|ids)$/i';
/** Attribute names that ARE a media reference when their value is numeric. */
const MEDIA_WORD_RE = '/^(?:image|img|media|photo|picture|logo|icon|background|bg|video|audio|file|gallery|poster|thumbnail|avatar|attachment)$/i';
/** @var array ACF field key => is image/file/gallery */
private static $media_field_cache = array();
/**
* Apply $cb to every attachment ID inside one block's attributes.
* $cb( int $id ): int — returns the replacement ID. Types are preserved
* (numeric strings stay strings).
*
* @param string $name
* @param array $attrs
* @param callable $cb
* @return array
*/
private static function walk_block( $name, array $attrs, callable $cb ) {
$skip = array();
if ( isset( self::ID_BLOCKS[ $name ] ) ) {
foreach ( self::ID_BLOCKS[ $name ] as $attr ) {
$skip[] = $attr;
if ( ! isset( $attrs[ $attr ] ) ) {
continue;
}
$attrs[ $attr ] = is_array( $attrs[ $attr ] )
? array_map( static function ( $v ) use ( $cb ) {
return self::map_scalar( $v, $cb );
}, $attrs[ $attr ] )
: self::map_scalar( $attrs[ $attr ], $cb );
}
}
if ( 0 === strpos( $name, 'acf/' ) && isset( $attrs['data'] ) && is_array( $attrs['data'] ) ) {
$attrs['data'] = self::walk_acf_data( $attrs['data'], $cb );
$skip[] = 'data';
}
return self::walk_generic( $attrs, $cb, $skip );
}
/**
* Heuristic walk for custom blocks: media-named keys, and "id" when the
* same object also carries a url/src/sizes/mime (a media object).
* Only IDs that turn out to be real source attachments are ever copied
* or remapped, which keeps false positives out.
*
* @param array $a
* @param callable $cb
* @param string[] $skip Top-level keys already handled.
* @return array
*/
private static function walk_generic( array $a, callable $cb, array $skip = array() ) {
$is_media_object = isset( $a['url'] ) || isset( $a['src'] ) || isset( $a['sizes'] ) || isset( $a['mime'] ) || isset( $a['mime_type'] )
|| ( isset( $a['type'] ) && in_array( $a['type'], array( 'image', 'video', 'audio', 'file', 'application' ), true ) );
foreach ( $a as $k => $v ) {
$k = (string) $k;
if ( in_array( $k, $skip, true ) ) {
continue;
}
$media_key = (bool) preg_match( self::MEDIA_KEY_RE, $k ) || ( (bool) preg_match( self::MEDIA_WORD_RE, $k ) && self::is_id( $v ) );
if ( is_array( $v ) ) {
if ( $media_key && $v && count( array_filter( $v, array( __CLASS__, 'is_id' ) ) ) === count( $v ) ) {
$a[ $k ] = array_map( static function ( $x ) use ( $cb ) {
return self::map_scalar( $x, $cb );
}, $v );
} else {
$a[ $k ] = self::walk_generic( $v, $cb );
}
continue;
}
if ( self::is_id( $v ) && ( $media_key || ( 'id' === $k && $is_media_object ) ) ) {
$a[ $k ] = self::map_scalar( $v, $cb );
}
}
return $a;
}
/**
* ACF block "data". Handles:
* { "hero_image": 12, "_hero_image": "field_abc" } (name-keyed)
* { "field_abc": 12 } and nested { "field_rep": { "row-0": { "field_img": 12 } } }
*
* @param array $data
* @param callable $cb
* @return array
*/
private static function walk_acf_data( array $data, callable $cb ) {
foreach ( $data as $k => $v ) {
$k = (string) $k;
if ( '' === $k || '_' === $k[0] ) {
continue;
}
$field_key = '';
if ( 0 === strpos( $k, 'field_' ) ) {
$field_key = $k;
} elseif ( isset( $data[ '_' . $k ] ) && is_string( $data[ '_' . $k ] ) && 0 === strpos( $data[ '_' . $k ], 'field_' ) ) {
$field_key = $data[ '_' . $k ];
}
if ( $field_key && self::is_media_field_key( $field_key ) ) {
if ( is_string( $v ) && is_serialized( $v ) ) {
$v = maybe_unserialize( $v );
}
if ( is_array( $v ) ) {
$data[ $k ] = array_map( static function ( $x ) use ( $cb ) {
return self::is_id( $x ) ? self::map_scalar( $x, $cb ) : $x;
}, $v );
} elseif ( self::is_id( $v ) ) {
$data[ $k ] = self::map_scalar( $v, $cb );
}
continue;
}
if ( is_array( $v ) ) {
$data[ $k ] = self::walk_acf_data( $v, $cb );
}
}
return $data;
}
/**
* Is this ACF field key an image / file / gallery field? The first lookup
* happens on the source (during collect), and the answer is cached for the
* rewrite on the target in the same request.
*
* @param string $field_key
* @return bool
*/
private static function is_media_field_key( $field_key ) {
if ( ! isset( self::$media_field_cache[ $field_key ] ) ) {
$field = function_exists( 'acf_get_field' ) ? acf_get_field( $field_key ) : false;
self::$media_field_cache[ $field_key ] = is_array( $field )
&& isset( $field['type'] )
&& in_array( $field['type'], array( 'image', 'file', 'gallery' ), true );
}
return self::$media_field_cache[ $field_key ];
}
/**
* @param mixed $v
* @return bool
*/
public static function is_id( $v ) {
return ( is_int( $v ) && $v > 0 ) || ( is_string( $v ) && ctype_digit( $v ) && '0' !== $v );
}
/**
* @param mixed $v
* @param callable $cb
* @return mixed
*/
private static function map_scalar( $v, callable $cb ) {
if ( ! self::is_id( $v ) ) {
return $v;
}
$new = (int) call_user_func( $cb, (int) $v );
return is_string( $v ) ? (string) $new : $new;
}
/**
* Reusable block / synced pattern IDs referenced by content.
*
* @param string $content
* @return int[]
*/
public static function collect_block_refs( $content ) {
$refs = array();
if ( preg_match_all( self::BLOCK_RE, (string) $content, $m, PREG_SET_ORDER ) ) {
foreach ( $m as $match ) {
if ( 'core/block' !== self::block_name( $match[1] ) ) {
continue;
}
$attrs = json_decode( $match[2], true );
if ( is_array( $attrs ) && isset( $attrs['ref'] ) && self::is_id( $attrs['ref'] ) ) {
$refs[] = (int) $attrs['ref'];
}
}
}
return array_values( array_unique( $refs ) );
}
/* ---------------------------------------------------------------------
* Source side (run BEFORE switch_to_blog)
* ------------------------------------------------------------------- */
/**
* All attachment IDs referenced by a piece of content.
*
* @param string $content
* @param bool $urls_only Only detect by upload URL (for arbitrary meta).
* @return int[]
*/
public static function collect_ids( $content, $urls_only = false ) {
$content = (string) $content;
if ( '' === $content ) {
return array();
}
$ids = array();
if ( ! $urls_only ) {
if ( preg_match_all( self::BLOCK_RE, $content, $m, PREG_SET_ORDER ) ) {
$collector = static function ( $id ) use ( &$ids ) {
$ids[] = (int) $id;
return $id;
};
foreach ( $m as $match ) {
$attrs = json_decode( $match[2], true );
if ( is_array( $attrs ) ) {
self::walk_block( self::block_name( $match[1] ), $attrs, $collector );
}
}
}
if ( preg_match_all( '/\bwp-image-(\d+)\b/', $content, $m ) ) {
$ids = array_merge( $ids, $m[1] );
}
if ( preg_match_all( '/\battachment_(\d+)\b/', $content, $m ) ) {
$ids = array_merge( $ids, $m[1] );
}
if ( preg_match_all( '/\bdata-id=(["\'])(\d+)\1/', $content, $m ) ) {
$ids = array_merge( $ids, $m[2] );
}
if ( preg_match_all( '/\[gallery\b[^\]]*\bids=(["\'])([\d,\s]+)\1/', $content, $m ) ) {
foreach ( $m[2] as $list ) {
$ids = array_merge( $ids, explode( ',', $list ) );
}
}
}
$ids = array_merge( $ids, self::collect_ids_from_urls( $content ) );
return array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) );
}
/**
* Walk a meta value (string / array) and collect URL-referenced IDs.
*
* @param mixed $value
* @return int[]
*/
public static function collect_ids_deep( $value ) {
if ( is_string( $value ) ) {
return self::collect_ids( $value, true );
}
$ids = array();
if ( is_array( $value ) ) {
foreach ( $value as $v ) {
$ids = array_merge( $ids, self::collect_ids_deep( $v ) );
}
}
return $ids;
}
/**
* @param string $content
* @return int[]
*/
private static function collect_ids_from_urls( $content ) {
$uploads = wp_get_upload_dir();
if ( empty( $uploads['baseurl'] ) ) {
return array();
}
$base_noscheme = preg_replace( '#^https?:#i', '', $uploads['baseurl'] );
$scheme = wp_parse_url( $uploads['baseurl'], PHP_URL_SCHEME );
$scheme = $scheme ? $scheme : 'https';
// Page builders store JSON with escaped slashes.
$haystack = str_replace( '\/', '/', $content );
$pattern = '#' . preg_quote( $base_noscheme, '#' ) . '/[^\s"\'<>()\\\\,]+?\.[a-z0-9]{2,5}\b#i';
if ( ! preg_match_all( $pattern, $haystack, $m ) ) {
return array();
}
$ids = array();
$seen = array();
foreach ( array_unique( $m[0] ) as $url ) {
$full = $scheme . ':' . $url;
if ( isset( $seen[ $full ] ) ) {
continue;
}
$seen[ $full ] = true;
$id = self::url_to_attachment_id( $full );
if ( $id ) {
$ids[] = $id;
}
}
return $ids;
}
/**
* attachment_url_to_postid() only knows full-size URLs; also try with the
* -WxH size suffix stripped and with the -scaled variant.
*
* @param string $url
* @return int
*/
private static function url_to_attachment_id( $url ) {
$url = strtok( $url, '?#' );
$id = attachment_url_to_postid( $url );
if ( $id ) {
return (int) $id;
}
$stripped = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $url );
if ( $stripped !== $url ) {
$id = attachment_url_to_postid( $stripped );
if ( $id ) {
return (int) $id;
}
}
$scaled = preg_replace( '/(\.[a-z0-9]+)$/i', '-scaled$1', $stripped );
$id = attachment_url_to_postid( $scaled );
return (int) $id;
}
/* ---------------------------------------------------------------------
* Target side (run AFTER switch_to_blog and after media is sideloaded)
* ------------------------------------------------------------------- */
/**
* @param string $content
* @param array $id_map source attachment ID => target ID (0 = failed)
* @param array $url_map source URL => target URL
* @param array $ref_map source wp_block ID => target wp_block ID
* @return string
*/
public static function rewrite( $content, array $id_map, array $url_map, array $ref_map = array() ) {
$content = (string) $content;
if ( '' === $content ) {
return $content;
}
$id_map = array_filter( array_map( 'intval', $id_map ) );
$ref_map = array_filter( array_map( 'intval', $ref_map ) );
if ( $ref_map ) {
$content = preg_replace_callback(
self::BLOCK_RE,
static function ( $m ) use ( $ref_map ) {
if ( 'core/block' !== self::block_name( $m[1] ) ) {
return $m[0];
}
$attrs = json_decode( $m[2], true );
if ( ! is_array( $attrs ) || ! isset( $attrs['ref'] ) || ! isset( $ref_map[ (int) $attrs['ref'] ] ) ) {
return $m[0];
}
$attrs['ref'] = $ref_map[ (int) $attrs['ref'] ];
return self::block_comment( $m[1], $attrs, ! empty( $m[3] ) );
},
$content
);
}
if ( $id_map ) {
$map = static function ( $id ) use ( $id_map ) {
$id = (int) $id;
return isset( $id_map[ $id ] ) ? $id_map[ $id ] : $id;
};
// Block attributes.
$content = preg_replace_callback(
self::BLOCK_RE,
static function ( $m ) use ( $map ) {
$attrs = json_decode( $m[2], true );
if ( ! is_array( $attrs ) ) {
return $m[0];
}
$new = self::walk_block( self::block_name( $m[1] ), $attrs, $map );
if ( $new === $attrs ) {
return $m[0]; // Untouched blocks stay byte-identical.
}
return self::block_comment( $m[1], $new, ! empty( $m[3] ) );
},
$content
);
$content = preg_replace_callback(
'/\bwp-image-(\d+)\b/',
static function ( $m ) use ( $map ) {
return 'wp-image-' . $map( $m[1] );
},
$content
);
$content = preg_replace_callback(
'/\battachment_(\d+)\b/',
static function ( $m ) use ( $map ) {
return 'attachment_' . $map( $m[1] );
},
$content
);
$content = preg_replace_callback(
'/\bdata-id=(["\'])(\d+)\1/',
static function ( $m ) use ( $map ) {
return 'data-id=' . $m[1] . $map( $m[2] ) . $m[1];
},
$content
);
$content = preg_replace_callback(
'/(\[gallery\b[^\]]*\bids=)(["\'])([\d,\s]+)\2/',
static function ( $m ) use ( $map ) {
$ids = array_map( $map, array_filter( array_map( 'trim', explode( ',', $m[3] ) ), 'strlen' ) );
return $m[1] . $m[2] . implode( ',', $ids ) . $m[2];
},
$content
);
}
return self::replace_urls( $content, $url_map );
}
/**
* Replace source media URLs with target URLs in a string or (nested)
* array. Scheme-agnostic, and handles JSON-escaped slashes. Objects are
* left untouched. Always call on UNSERIALIZED values — never on a
* serialized string, or the length prefixes break.
*
* @param mixed $value
* @param array $url_map
* @return mixed
*/
public static function replace_urls( $value, array $url_map ) {
if ( empty( $url_map ) ) {
return $value;
}
if ( is_array( $value ) ) {
foreach ( $value as $k => $v ) {
$value[ $k ] = self::replace_urls( $v, $url_map );
}
return $value;
}
if ( ! is_string( $value ) || '' === $value ) {
return $value;
}
static $cache = array();
$cache_key = md5( wp_json_encode( $url_map ) );
if ( ! isset( $cache[ $cache_key ] ) ) {
$pairs = array();
foreach ( $url_map as $from => $to ) {
$f = preg_replace( '#^https?:#i', '', (string) $from );
$t = preg_replace( '#^https?:#i', '', (string) $to );
if ( '' === $f || $f === $t ) {
continue;
}
$pairs[ $f ] = $t;
$pairs[ str_replace( '/', '\/', $f ) ] = str_replace( '/', '\/', $t );
}
$cache[ $cache_key ] = $pairs;
}
// strtr() matches the longest key first, so size variants never
// collide with the full-size URL.
return $cache[ $cache_key ] ? strtr( $value, $cache[ $cache_key ] ) : $value;
}
/**
* @param string $raw_name
* @param array $attrs
* @param bool $self_closing
* @return string
*/
private static function block_comment( $raw_name, array $attrs, $self_closing ) {
$json = function_exists( 'serialize_block_attributes' )
? serialize_block_attributes( $attrs )
: wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
return '';
}
/**
* @param string $raw
* @return string
*/
private static function block_name( $raw ) {
return false === strpos( $raw, '/' ) ? 'core/' . $raw : $raw;
}
}