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; } }