>> CPT => name => field def */ private $fields_by_cpt = array(); /** * Discover and merge all ACF field groups attached to a post type. * * Handles zero / one / many groups. The result is a name-indexed map of * top-level fields. Conflicting names across groups (same top-level field * declared in two groups bound to the same CPT) keep the first occurrence; * an error_log line is emitted so ops can spot it. * * @param string $cpt * @return array> name => field def */ public function load_fields_for_post_type( $cpt ) { // Cache per blog: the same walker is used on source and target. $cache_key = get_current_blog_id() . ':' . $cpt; if ( isset( $this->fields_by_cpt[ $cache_key ] ) ) { return $this->fields_by_cpt[ $cache_key ]; } $by_name = array(); if ( function_exists( 'acf_get_field_groups' ) ) { $groups = (array) acf_get_field_groups( array( 'post_type' => $cpt ) ); foreach ( $groups as $group ) { if ( empty( $group['key'] ) ) { continue; } $fields = (array) acf_get_fields( $group['key'] ); foreach ( $fields as $field ) { if ( empty( $field['name'] ) ) { continue; } if ( isset( $by_name[ $field['name'] ] ) ) { continue; } $by_name[ $field['name'] ] = $field; } } } $this->fields_by_cpt[ $cache_key ] = $by_name; return $by_name; } /** * Every ACF field that applies to ONE specific post — not just the ones * assigned by post type. Picks up groups located by single page/post, * page template, page type, parent, taxonomy, format, etc., plus any * field that has a stored value reference on the post (covers groups * whose rules no longer match but whose data is still there). * * Run on the SOURCE blog. * * @param int $post_id * @param string $cpt * @return array> name => field def */ public function load_fields_for_post( $post_id, $cpt ) { $post_id = (int) $post_id; $by_name = $this->load_fields_for_post_type( $cpt ); if ( function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ) ) { $groups = (array) acf_get_field_groups( array( 'post_id' => $post_id, 'post_type' => $cpt, ) ); foreach ( $groups as $group ) { if ( empty( $group['key'] ) ) { continue; } foreach ( (array) acf_get_fields( $group['key'] ) as $field ) { if ( ! empty( $field['name'] ) && ! isset( $by_name[ $field['name'] ] ) ) { $by_name[ $field['name'] ] = $field; } } } } if ( function_exists( 'get_field_objects' ) ) { $objects = get_field_objects( $post_id, false, false ); if ( is_array( $objects ) ) { foreach ( $objects as $name => $field ) { if ( isset( $by_name[ $name ] ) || empty( $field['key'] ) ) { continue; } unset( $field['value'] ); $by_name[ $name ] = $field; } } } return $by_name; } /** * Field groups whose location rules target this exact post * ("Page is equal to X" / "Post is equal to X"). On the target the new * post has a different ID, so these rules need the new ID added or the * fields won't show in the editor. * * Run on the SOURCE blog. * * @param int $post_id * @param string $cpt * @return array */ public function find_post_specific_groups( $post_id, $cpt ) { $post_id = (int) $post_id; if ( ! function_exists( 'acf_get_field_groups' ) ) { return array(); } $out = array(); $groups = (array) acf_get_field_groups( array( 'post_id' => $post_id, 'post_type' => $cpt, ) ); foreach ( $groups as $group ) { if ( empty( $group['key'] ) || empty( $group['location'] ) || ! is_array( $group['location'] ) ) { continue; } foreach ( $group['location'] as $and_rules ) { foreach ( (array) $and_rules as $rule ) { $param = isset( $rule['param'] ) ? (string) $rule['param'] : ''; $op = isset( $rule['operator'] ) ? (string) $rule['operator'] : ''; $value = isset( $rule['value'] ) ? (int) $rule['value'] : 0; if ( in_array( $param, array( 'page', 'post' ), true ) && '==' === $op && $value === $post_id ) { $out[ $group['key'] ] = array( 'key' => (string) $group['key'], 'title' => isset( $group['title'] ) ? (string) $group['title'] : (string) $group['key'], 'param' => $param, ); break 2; } } } } return array_values( $out ); } /** * Resolve source field definitions on the TARGET blog by field key, so * location rules don't matter. Fields the target doesn't know keep their * source definition (so values can still be written and walked). * * Run on the TARGET blog. * * @param array> $source_fields name => source def * @return array{0:array>,1:array} * [ name => def to use when writing, field_key => meta for keys that exist on target ] */ public function resolve_fields_on_target( array $source_fields ) { $write = array(); $present = array(); foreach ( $source_fields as $name => $src ) { $target = ( function_exists( 'acf_get_field' ) && ! empty( $src['key'] ) ) ? acf_get_field( $src['key'] ) : false; if ( is_array( $target ) && ! empty( $target['key'] ) ) { $write[ $name ] = $target; $present[ $name ] = $target; } else { $write[ $name ] = $src; } } return array( $write, $this->flatten_keys( $present ) ); } /** * Flatten every field_key reachable from a fields definition (recurses into * repeater / group / flexible_content sub_fields). Used for drift detection. * * @param array> $fields_by_name * @return array field_key => meta */ public function flatten_keys( array $fields_by_name ) { $out = array(); $walk = function ( array $fields ) use ( &$walk, &$out ) { foreach ( $fields as $field ) { if ( empty( $field['key'] ) ) { continue; } $out[ $field['key'] ] = array( 'name' => isset( $field['name'] ) ? (string) $field['name'] : '', 'label' => isset( $field['label'] ) ? (string) $field['label'] : '', 'type' => isset( $field['type'] ) ? (string) $field['type'] : '', ); if ( ! empty( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ) { $walk( $field['sub_fields'] ); } if ( ! empty( $field['layouts'] ) && is_array( $field['layouts'] ) ) { foreach ( $field['layouts'] as $layout ) { if ( ! empty( $layout['sub_fields'] ) && is_array( $layout['sub_fields'] ) ) { $walk( $layout['sub_fields'] ); } } } } }; $walk( array_values( $fields_by_name ) ); return $out; } /** * Visit every leaf in $values that matches one of $leaf_types, invoking * $visitor( $value, $field ). Read-only — does not rewrite. Used pre-switch * to collect attachment IDs and reference captures. * * @param array $values * @param array> $fields_by_name * @param string[] $leaf_types * @param callable $visitor fn($value, array $field): void */ public function visit_leaves( array $values, array $fields_by_name, array $leaf_types, callable $visitor ) { foreach ( $values as $name => $value ) { if ( ! isset( $fields_by_name[ $name ] ) ) { continue; } $this->visit_value( $value, $fields_by_name[ $name ], $leaf_types, $visitor ); } } private function visit_value( $value, array $field, array $leaf_types, callable $visitor ) { $type = isset( $field['type'] ) ? $field['type'] : ''; if ( in_array( $type, $leaf_types, true ) ) { call_user_func( $visitor, $value, $field ); return; } switch ( $type ) { case 'repeater': if ( ! is_array( $value ) ) { return; } $sub_by_name = $this->index_by_name( isset( $field['sub_fields'] ) ? (array) $field['sub_fields'] : array() ); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { continue; } foreach ( $row as $sub_name => $sub_val ) { if ( isset( $sub_by_name[ $sub_name ] ) ) { $this->visit_value( $sub_val, $sub_by_name[ $sub_name ], $leaf_types, $visitor ); } } } return; case 'group': if ( ! is_array( $value ) ) { return; } $sub_by_name = $this->index_by_name( isset( $field['sub_fields'] ) ? (array) $field['sub_fields'] : array() ); foreach ( $value as $sub_name => $sub_val ) { if ( isset( $sub_by_name[ $sub_name ] ) ) { $this->visit_value( $sub_val, $sub_by_name[ $sub_name ], $leaf_types, $visitor ); } } return; case 'flexible_content': if ( ! is_array( $value ) ) { return; } $layouts = $this->index_layouts( isset( $field['layouts'] ) ? (array) $field['layouts'] : array() ); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { continue; } $layout_name = isset( $row['acf_fc_layout'] ) ? $row['acf_fc_layout'] : null; if ( ! $layout_name || ! isset( $layouts[ $layout_name ] ) ) { continue; } $sub_by_name = $this->index_by_name( isset( $layouts[ $layout_name ]['sub_fields'] ) ? (array) $layouts[ $layout_name ]['sub_fields'] : array() ); foreach ( $row as $sub_name => $sub_val ) { if ( 'acf_fc_layout' === $sub_name ) { continue; } if ( isset( $sub_by_name[ $sub_name ] ) ) { $this->visit_value( $sub_val, $sub_by_name[ $sub_name ], $leaf_types, $visitor ); } } } return; } } /** * Rewrite every leaf in $values via $rewriter, returning the new structured * tree plus a field_key => rewritten_value map for top-level fields (which * is what the cloner hands to update_field() — using field keys is what * makes ACF write reference rows at every nesting depth). * * @param array $values * @param array> $fields_by_name * @param callable $rewriter fn(mixed $value, array $field): mixed * @param array $key_value_map Out-param. * @return array */ public function rewrite_top_level( array $values, array $fields_by_name, callable $rewriter, array &$key_value_map ) { $out = array(); foreach ( $values as $name => $value ) { if ( ! isset( $fields_by_name[ $name ] ) ) { $out[ $name ] = $value; continue; } $field = $fields_by_name[ $name ]; $new_value = $this->rewrite_value( $value, $field, $rewriter ); $out[ $name ] = $new_value; $key_value_map[ $field['key'] ] = $new_value; } return $out; } private function rewrite_value( $value, array $field, callable $rewriter ) { $type = isset( $field['type'] ) ? $field['type'] : ''; if ( in_array( $type, self::ATTACHMENT_TYPES, true ) || in_array( $type, self::REFERENCE_TYPES, true ) || in_array( $type, self::CONTENT_TYPES, true ) ) { return call_user_func( $rewriter, $value, $field ); } switch ( $type ) { case 'repeater': if ( ! is_array( $value ) ) { return $value; } $sub_by_name = $this->index_by_name( isset( $field['sub_fields'] ) ? (array) $field['sub_fields'] : array() ); $out = array(); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { $out[] = $row; continue; } $new_row = array(); foreach ( $row as $sub_name => $sub_val ) { $new_row[ $sub_name ] = isset( $sub_by_name[ $sub_name ] ) ? $this->rewrite_value( $sub_val, $sub_by_name[ $sub_name ], $rewriter ) : $sub_val; } $out[] = $new_row; } return $out; case 'group': if ( ! is_array( $value ) ) { return $value; } $sub_by_name = $this->index_by_name( isset( $field['sub_fields'] ) ? (array) $field['sub_fields'] : array() ); $out = array(); foreach ( $value as $sub_name => $sub_val ) { $out[ $sub_name ] = isset( $sub_by_name[ $sub_name ] ) ? $this->rewrite_value( $sub_val, $sub_by_name[ $sub_name ], $rewriter ) : $sub_val; } return $out; case 'flexible_content': if ( ! is_array( $value ) ) { return $value; } $layouts = $this->index_layouts( isset( $field['layouts'] ) ? (array) $field['layouts'] : array() ); $out = array(); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { $out[] = $row; continue; } $layout_name = isset( $row['acf_fc_layout'] ) ? $row['acf_fc_layout'] : null; if ( ! $layout_name || ! isset( $layouts[ $layout_name ] ) ) { $out[] = $row; continue; } $sub_by_name = $this->index_by_name( isset( $layouts[ $layout_name ]['sub_fields'] ) ? (array) $layouts[ $layout_name ]['sub_fields'] : array() ); $new_row = array(); foreach ( $row as $sub_name => $sub_val ) { if ( 'acf_fc_layout' === $sub_name ) { $new_row[ $sub_name ] = $sub_val; continue; } $new_row[ $sub_name ] = isset( $sub_by_name[ $sub_name ] ) ? $this->rewrite_value( $sub_val, $sub_by_name[ $sub_name ], $rewriter ) : $sub_val; } $out[] = $new_row; } return $out; default: return $value; } } /** * @param array> $fields * @return array> */ private function index_by_name( array $fields ) { $by_name = array(); foreach ( $fields as $f ) { if ( isset( $f['name'] ) ) { $by_name[ $f['name'] ] = $f; } } return $by_name; } /** * @param array> $layouts * @return array> */ private function index_layouts( array $layouts ) { $by_name = array(); foreach ( $layouts as $l ) { if ( isset( $l['name'] ) ) { $by_name[ $l['name'] ] = $l; } } return $by_name; } }