walker = $walker ?: new ACF_Walker(); } /** * @param int $source_post_id * @param int $target_blog_id * @return array{new_id:int,target_blog_id:int,edit_url:string,source_cpt:string,stats:array,report:array}|\WP_Error */ public function copy( $source_post_id, $target_blog_id ) { $source_post_id = (int) $source_post_id; $target_blog_id = (int) $target_blog_id; if ( $target_blog_id === (int) get_current_blog_id() ) { return new \WP_Error( 'mspc_same_site', __( 'Target site is the same as the current site.', '42works-multisite-cpt-migrator' ) ); } if ( ! get_site( $target_blog_id ) ) { return new \WP_Error( 'mspc_invalid_target', __( 'Target subsite does not exist.', '42works-multisite-cpt-migrator' ) ); } self::prepare_runtime(); $payload = $this->capture_source( $source_post_id ); if ( is_wp_error( $payload ) ) { return $payload; } switch_to_blog( $target_blog_id ); try { return $this->write_target( $payload, $target_blog_id ); } finally { restore_current_blog(); } } /** * Step 1 of a chunked copy: transfer the post's media to the target in a * time-boxed batch, so no single web request comes near a host timeout * (WP Engine ~60s). Call repeatedly with the returned offset until done, * then call copy() — all media is then found by _mspc_source_key and * reused (and attached to the new post), so the final step is quick. * Idempotent: safe to retry after a failed request. * * @param int $source_post_id * @param int $target_blog_id * @param int $offset * @return array{total:int,next:int,done:bool,uploaded:int,existing:int,failed:int}|\WP_Error */ public function prepare_media( $source_post_id, $target_blog_id, $offset = 0 ) { $started = microtime( true ); $target_blog_id = (int) $target_blog_id; if ( $target_blog_id === (int) get_current_blog_id() || ! get_site( $target_blog_id ) ) { return new \WP_Error( 'mspc_invalid_target', __( 'Invalid target subsite.', '42works-multisite-cpt-migrator' ) ); } self::prepare_runtime(); $payload = $this->capture_source( (int) $source_post_id ); if ( is_wp_error( $payload ) ) { return $payload; } /** @var Attachment_Sideloader $sideloader */ $sideloader = $payload['sideloader']; $ids = $sideloader->captured_ids(); $total = count( $ids ); /** * Seconds of media work per request before handing back to the browser. * * @param int $seconds */ $budget = max( 5, (int) apply_filters( 'mspc_media_step_seconds', 20 ) ); $i = max( 0, (int) $offset ); switch_to_blog( $target_blog_id ); try { for ( ; $i < $total; $i++ ) { if ( microtime( true ) - $started > $budget ) { break; } $sideloader->sideload_to_target( $ids[ $i ] ); } } finally { restore_current_blog(); } $stats = $sideloader->stats(); return array( 'total' => $total, 'next' => $i, 'done' => $i >= $total, 'uploaded' => (int) $stats['successes'], 'existing' => (int) $stats['existing'], 'failed' => (int) $stats['failures'], ); } /** * Same runtime for every entry point (single, bulk, CLI): never LOWER the * execution limit (set_time_limit() resets it, it doesn't add to it), keep * going if the browser disconnects, and give image processing memory. */ public static function prepare_runtime() { if ( function_exists( 'set_time_limit' ) && 0 !== (int) ini_get( 'max_execution_time' ) ) { /** * Seconds allowed per copy. 0 = no PHP limit (hosts like WP Engine * still enforce their own request timeout). * * @param int $seconds */ $limit = (int) apply_filters( 'mspc_copy_time_limit', 0 ); $now = (int) ini_get( 'max_execution_time' ); if ( 0 === $limit || $limit > $now ) { // Needed so a long image-heavy copy is not killed mid-way. Guarded by function_exists() above (which is false when a host disables it), so no error suppression is needed. set_time_limit( $limit ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions } } if ( function_exists( 'ignore_user_abort' ) ) { ignore_user_abort( true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions -- let a started copy finish even if the browser disconnects. } if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'image' ); } } /** * Phase 1 — runs while still on the source blog. * * @param int $source_post_id * @return array|\WP_Error */ private function capture_source( $source_post_id ) { $post = get_post( $source_post_id ); if ( ! $post ) { return new \WP_Error( 'mspc_invalid_source', __( 'Source post not found.', '42works-multisite-cpt-migrator' ) ); } $source_cpt = (string) $post->post_type; $source_blog = (int) get_current_blog_id(); // Fields for THIS post, including groups located by single page/post, // template, page type, taxonomy, etc. — not only by post type. $fields_by_name = $this->walker->load_fields_for_post( $source_post_id, $source_cpt ); $source_keys = $this->walker->flatten_keys( $fields_by_name ); $specific_groups = $this->walker->find_post_specific_groups( $source_post_id, $source_cpt ); $acf_values = function_exists( 'get_fields' ) ? get_fields( $source_post_id, false ) : array(); if ( ! is_array( $acf_values ) ) { $acf_values = array(); } $report = new Warning_Report(); $sideloader = new Attachment_Sideloader(); $remapper = new Reference_Remapper( $report ); $this->walker->visit_leaves( $acf_values, $fields_by_name, ACF_Walker::ATTACHMENT_TYPES, static function ( $value, $field ) use ( $sideloader ) { $type = isset( $field['type'] ) ? $field['type'] : ''; if ( 'gallery' === $type ) { foreach ( (array) $value as $vid ) { $sideloader->capture_source( (int) $vid ); } return; } $sideloader->capture_source( (int) $value ); } ); $this->walker->visit_leaves( $acf_values, $fields_by_name, ACF_Walker::REFERENCE_TYPES, static function ( $value, $field ) use ( $remapper ) { $type = isset( $field['type'] ) ? $field['type'] : ''; if ( 'relationship' === $type ) { foreach ( (array) $value as $rid ) { $remapper->capture( (int) $rid ); } return; } if ( 'post_object' === $type ) { if ( is_array( $value ) ) { foreach ( $value as $rid ) { $remapper->capture( (int) $rid ); } } else { $remapper->capture( (int) $value ); } return; } if ( 'page_link' === $type ) { $items = is_array( $value ) ? $value : array( $value ); foreach ( $items as $v ) { $id = $remapper->source_page_link_to_id( $v ); if ( $id ) { $remapper->capture( $id ); } } return; } } ); $featured_id = (int) get_post_thumbnail_id( $source_post_id ); if ( $featured_id ) { $sideloader->capture_source( $featured_id ); } // Media embedded in the body and in ACF free-text fields. $content_ids = Content_Rewriter::collect_ids( (string) $post->post_content ); $content_ids = array_merge( $content_ids, Content_Rewriter::collect_ids( (string) $post->post_excerpt, true ) ); $this->walker->visit_leaves( $acf_values, $fields_by_name, ACF_Walker::CONTENT_TYPES, static function ( $value ) use ( &$content_ids ) { if ( is_string( $value ) ) { $content_ids = array_merge( $content_ids, Content_Rewriter::collect_ids( $value ) ); } } ); // Reusable blocks / synced patterns (and any nested inside them). $reusable_blocks = $this->capture_reusable_blocks( (string) $post->post_content ); foreach ( $reusable_blocks as $rb ) { $content_ids = array_merge( $content_ids, Content_Rewriter::collect_ids( $rb['content'] ) ); } // Every non-ACF post meta (SEO, page builders, custom meta…). $extra_meta = $this->capture_meta( $source_post_id ); foreach ( $extra_meta as $values ) { foreach ( $values as $v ) { $content_ids = array_merge( $content_ids, Content_Rewriter::collect_ids_deep( $v ) ); } } $media_ids = array(); foreach ( array_unique( array_map( 'intval', $content_ids ) ) as $cid ) { $sideloader->capture_source( $cid ); if ( $sideloader->has_capture( $cid ) ) { $media_ids[] = $cid; } } $parent_info = null; if ( $post->post_parent ) { $parent = get_post( (int) $post->post_parent ); if ( $parent ) { $parent_info = array( 'slug' => (string) $parent->post_name, 'post_type' => (string) $parent->post_type, 'title' => (string) $parent->post_title, ); } } $template = (string) get_post_meta( $source_post_id, '_wp_page_template', true ); $taxonomies = get_object_taxonomies( $source_cpt, 'objects' ); $tax_data = array(); foreach ( $taxonomies as $tax_slug => $tax_obj ) { if ( empty( $tax_obj->public ) ) { continue; } $terms = wp_get_object_terms( $source_post_id, $tax_slug ); if ( is_wp_error( $terms ) || empty( $terms ) ) { continue; } $records = array(); foreach ( $terms as $term ) { $parent_slug = ''; if ( $term->parent ) { $parent_term = get_term( $term->parent, $tax_slug ); if ( $parent_term && ! is_wp_error( $parent_term ) ) { $parent_slug = $parent_term->slug; } } $records[] = array( 'slug' => $term->slug, 'name' => $term->name, 'description' => $term->description, 'parent_slug' => $parent_slug, ); } $tax_data[ $tax_slug ] = array( 'label' => isset( $tax_obj->labels->singular_name ) ? (string) $tax_obj->labels->singular_name : (string) $tax_obj->label, 'records' => $records, ); } return array( 'source_id' => (int) $source_post_id, 'source_blog_id' => $source_blog, 'source_cpt' => $source_cpt, 'post_data' => array( 'post_title' => $post->post_title, 'post_content' => $post->post_content, 'post_excerpt' => $post->post_excerpt, 'post_date' => $post->post_date, 'post_date_gmt' => $post->post_date_gmt, 'menu_order' => (int) $post->menu_order, 'post_name' => $post->post_name, 'comment_status' => $post->comment_status, 'ping_status' => $post->ping_status, 'post_password' => $post->post_password, ), 'parent_info' => $parent_info, 'template' => $template, 'acf_values' => $acf_values, 'source_keys' => $source_keys, 'source_fields' => $fields_by_name, 'specific_groups' => $specific_groups, 'featured_id' => $featured_id, 'media_ids' => $media_ids, 'reusable_blocks' => $reusable_blocks, 'extra_meta' => $extra_meta, 'taxonomies' => $tax_data, 'report' => $report, 'sideloader' => $sideloader, 'remapper' => $remapper, ); } /** * Phase 2 — runs after switch_to_blog( $target ). * * @param array $payload * @param int $target_blog_id * @return array|\WP_Error */ private function write_target( array $payload, $target_blog_id ) { $source_cpt = (string) $payload['source_cpt']; if ( ! post_type_exists( $source_cpt ) ) { return new \WP_Error( 'mspc_no_cpt', sprintf( /* translators: 1: CPT slug, 2: target blog ID */ __( 'Target subsite (blog %2$d) does not have the %1$s post type registered. Aborting copy.', '42works-multisite-cpt-migrator' ), $source_cpt, (int) $target_blog_id ) ); } /** @var Warning_Report $report */ $report = $payload['report']; /** @var Attachment_Sideloader $sideloader */ $sideloader = $payload['sideloader']; /** @var Reference_Remapper $remapper */ $remapper = $payload['remapper']; $report->set_source( (int) $payload['source_blog_id'], (int) $payload['source_id'], $source_cpt ); // Resolve by field key on the target, independent of location rules. list( $target_fields, $target_keys ) = $this->walker->resolve_fields_on_target( (array) $payload['source_fields'] ); $drift_keys = array_diff_key( (array) $payload['source_keys'], $target_keys ); foreach ( $drift_keys as $key => $meta ) { $label = '' !== $meta['label'] ? $meta['label'] : $meta['name']; $report->add_drift( $label, $meta['name'], (string) $key ); } $new_parent_id = 0; if ( ! empty( $payload['parent_info'] ) ) { $pi = $payload['parent_info']; list( $resolved, $status ) = $remapper->lookup_on_target( $pi['slug'], $pi['post_type'], $pi['title'] ); if ( 'matched' === $status || 'unverified' === $status ) { $new_parent_id = $resolved; } else { $report->add_parent_dropped( $pi['title'], $pi['post_type'] ); } } $insert = array_merge( (array) $payload['post_data'], array( 'post_type' => $source_cpt, 'post_status' => 'draft', 'post_author' => get_current_user_id(), 'post_parent' => (int) $new_parent_id, ) ); $new_id = wp_insert_post( wp_slash( $insert ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } if ( ! $new_id ) { return new \WP_Error( 'mspc_insert_failed', __( 'Failed to insert target post.', '42works-multisite-cpt-migrator' ) ); } $new_id = (int) $new_id; if ( '' !== $payload['template'] ) { update_post_meta( $new_id, '_wp_page_template', (string) $payload['template'] ); } // Everything uploaded from here on is attached to the new post. $sideloader->set_parent( $new_id ); // Body / free-text media first, so URL + ID maps are complete before // any content is rewritten. foreach ( (array) $payload['media_ids'] as $mid ) { if ( ! $sideloader->sideload_to_target( (int) $mid ) ) { $cap = $sideloader->get_capture( (int) $mid ); $report->add_attachment_failure( __( 'Post content', '42works-multisite-cpt-migrator' ), 'post_content', (int) $mid, $cap ? (string) $cap['title'] : '' ); } } $rewriter = $this->make_rewriter( $sideloader, $remapper, $report ); $key_value_map = array(); $this->walker->rewrite_top_level( (array) $payload['acf_values'], $target_fields, $rewriter, $key_value_map ); $fields_by_key = array(); foreach ( $target_fields as $f ) { if ( ! empty( $f['key'] ) ) { $fields_by_key[ $f['key'] ] = $f; } } /** * Write values for fields whose group doesn't exist on the target yet, * using the source field definition. The data is then already in place * once the group is synced/imported on the target. * * @param bool $write */ $write_missing = (bool) apply_filters( 'mspc_write_missing_fields', true ); $acf_written = 0; foreach ( $key_value_map as $field_key => $value ) { if ( isset( $target_keys[ $field_key ] ) ) { if ( function_exists( 'update_field' ) ) { update_field( $field_key, $value, $new_id ); $acf_written++; } continue; } if ( $write_missing && isset( $fields_by_key[ $field_key ] ) && function_exists( 'acf_update_value' ) ) { acf_update_value( $value, $new_id, $fields_by_key[ $field_key ] ); $acf_written++; } } $this->extend_group_locations( $new_id, (array) $payload['specific_groups'], $report ); if ( ! empty( $payload['featured_id'] ) ) { $source_featured = (int) $payload['featured_id']; $new_thumb = $sideloader->sideload_to_target( $source_featured ); if ( $new_thumb ) { set_post_thumbnail( $new_id, $new_thumb ); } else { $cap = $sideloader->get_capture( $source_featured ); $title = $cap ? (string) $cap['title'] : ''; $report->add_attachment_failure( __( 'Featured image', '42works-multisite-cpt-migrator' ), '_thumbnail_id', $source_featured, $title ); } } // Rewrite media IDs + URLs in the body now that all media is on the target. $id_map = $sideloader->get_id_map(); $url_map = $sideloader->get_url_map(); $ref_map = $this->write_reusable_blocks( (array) $payload['reusable_blocks'], $id_map, $url_map ); $pd = (array) $payload['post_data']; $new_content = Content_Rewriter::rewrite( (string) $pd['post_content'], $id_map, $url_map, $ref_map ); $new_excerpt = Content_Rewriter::replace_urls( (string) $pd['post_excerpt'], $url_map ); if ( $new_content !== (string) $pd['post_content'] || $new_excerpt !== (string) $pd['post_excerpt'] ) { wp_update_post( wp_slash( array( 'ID' => $new_id, 'post_content' => $new_content, 'post_excerpt' => $new_excerpt, ) ) ); } $this->apply_meta( $new_id, (array) $payload['extra_meta'], $url_map ); $this->apply_taxonomies( $new_id, (array) $payload['taxonomies'], $report ); $report->persist( $new_id ); $edit_url = get_admin_url( $target_blog_id, 'post.php?post=' . $new_id . '&action=edit' ); return array( 'new_id' => $new_id, 'target_blog_id' => (int) $target_blog_id, 'edit_url' => $edit_url, 'source_cpt' => $source_cpt, 'acf_written' => $acf_written, 'acf_total' => count( (array) $payload['acf_values'] ), 'stats' => $sideloader->stats(), 'report' => $report->to_array(), ); } /** * Build the leaf-rewriter closure for attachments + references. * * @return callable */ private function make_rewriter( Attachment_Sideloader $sideloader, Reference_Remapper $remapper, Warning_Report $report ) { return function ( $value, $field ) use ( $sideloader, $remapper, $report ) { $type = isset( $field['type'] ) ? $field['type'] : ''; $label = ! empty( $field['label'] ) ? (string) $field['label'] : ( ! empty( $field['name'] ) ? (string) $field['name'] : '' ); $key = isset( $field['key'] ) ? (string) $field['key'] : ''; switch ( $type ) { case 'image': case 'file': $sid = $this->extract_id( $value ); if ( ! $sid ) { return ''; } $nid = $sideloader->sideload_to_target( $sid ); if ( ! $nid ) { $cap = $sideloader->get_capture( $sid ); $ttle = $cap ? (string) $cap['title'] : ''; $report->add_attachment_failure( $label, $key, $sid, $ttle ); return ''; } return $nid; case 'gallery': $out = array(); foreach ( (array) $value as $item ) { $sid = $this->extract_id( $item ); if ( ! $sid ) { continue; } $nid = $sideloader->sideload_to_target( $sid ); if ( ! $nid ) { $cap = $sideloader->get_capture( $sid ); $ttle = $cap ? (string) $cap['title'] : ''; $report->add_gallery_failure( $label, $key, $sid, $ttle ); continue; } $out[] = $nid; } return $out; case 'post_object': if ( is_array( $value ) ) { return $remapper->resolve_relationship( array_map( 'intval', $value ), $field ); } $nid = $remapper->resolve_id( (int) $value, $field ); return $nid ? $nid : ''; case 'relationship': return $remapper->resolve_relationship( array_map( 'intval', (array) $value ), $field ); case 'page_link': if ( is_array( $value ) ) { $out = array(); foreach ( $value as $v ) { $resolved = $remapper->resolve_page_link_single( $v, $field ); if ( '' !== $resolved && null !== $resolved && 0 !== $resolved ) { $out[] = $resolved; } } return $out; } return $remapper->resolve_page_link_single( $value, $field ); case 'wysiwyg': case 'textarea': return is_string( $value ) ? Content_Rewriter::rewrite( $value, $sideloader->get_id_map(), $sideloader->get_url_map() ) : $value; case 'text': case 'url': return Content_Rewriter::replace_urls( $value, $sideloader->get_url_map() ); } return $value; }; } /** * Resolve every captured term on the target by slug; create missing terms; * record auto-creations into the warning report. * * @param int $target_post_id * @param array}> $taxonomies * @param Warning_Report $report */ private function apply_taxonomies( $target_post_id, array $taxonomies, Warning_Report $report ) { foreach ( $taxonomies as $tax_slug => $bundle ) { if ( ! taxonomy_exists( $tax_slug ) ) { continue; } $label = (string) $bundle['label']; $records = (array) $bundle['records']; $resolved_by_slug = array(); $resolve = function ( $slug, $name, $description, $parent_slug ) use ( $tax_slug, $label, $report, &$resolved_by_slug, &$resolve ) { if ( '' === $slug ) { return 0; } if ( isset( $resolved_by_slug[ $slug ] ) ) { return $resolved_by_slug[ $slug ]; } $existing = get_term_by( 'slug', $slug, $tax_slug ); if ( $existing && ! is_wp_error( $existing ) ) { $resolved_by_slug[ $slug ] = (int) $existing->term_id; return (int) $existing->term_id; } $parent_id = 0; if ( $parent_slug ) { $parent = get_term_by( 'slug', $parent_slug, $tax_slug ); if ( $parent && ! is_wp_error( $parent ) ) { $parent_id = (int) $parent->term_id; } } $inserted = wp_insert_term( $name, $tax_slug, array( 'description' => $description, 'slug' => $slug, 'parent' => $parent_id, ) ); if ( is_wp_error( $inserted ) ) { return 0; } $resolved_by_slug[ $slug ] = (int) $inserted['term_id']; $report->add_term_created( $label, $name, $slug ); return (int) $inserted['term_id']; }; $term_ids = array(); foreach ( $records as $rec ) { $tid = $resolve( $rec['slug'], $rec['name'], $rec['description'], $rec['parent_slug'] ); if ( $tid ) { $term_ids[] = $tid; } } if ( $term_ids ) { wp_set_object_terms( $target_post_id, $term_ids, $tax_slug, false ); } } } /** * Capture every reusable block / synced pattern referenced by the content, * following nested references. Breadth-first, so parents come before the * blocks they contain. Run on the SOURCE blog. * * @param string $content * @return array> */ private function capture_reusable_blocks( $content ) { $out = array(); $queue = Content_Rewriter::collect_block_refs( $content ); $seen = array(); while ( $queue && count( $out ) < 100 ) { $ref = (int) array_shift( $queue ); if ( isset( $seen[ $ref ] ) ) { continue; } $seen[ $ref ] = true; $block = get_post( $ref ); if ( ! $block || 'wp_block' !== $block->post_type ) { continue; } $out[] = array( 'id' => (int) $block->ID, 'title' => (string) $block->post_title, 'name' => (string) $block->post_name, 'content' => (string) $block->post_content, 'status' => (string) $block->post_status, 'sync_status' => (string) get_post_meta( $block->ID, 'wp_pattern_sync_status', true ), ); $queue = array_merge( $queue, Content_Rewriter::collect_block_refs( $block->post_content ) ); } return $out; } /** * Create (or reuse) the reusable blocks on the TARGET blog. A target * wp_block with the same slug is reused as-is; otherwise a new one is * created with its media and nested references rewritten. Processed * innermost-first so nested refs resolve. * * @param array> $blocks * @param array $id_map * @param array $url_map * @return array source wp_block ID => target wp_block ID */ private function write_reusable_blocks( array $blocks, array $id_map, array $url_map ) { $ref_map = array(); foreach ( array_reverse( $blocks ) as $rb ) { $existing = '' !== $rb['name'] ? get_page_by_path( $rb['name'], OBJECT, 'wp_block' ) : null; if ( $existing ) { $ref_map[ (int) $rb['id'] ] = (int) $existing->ID; continue; } $new_block = wp_insert_post( wp_slash( array( 'post_type' => 'wp_block', 'post_status' => 'publish' === $rb['status'] ? 'publish' : 'draft', 'post_title' => $rb['title'], 'post_name' => $rb['name'], 'post_content' => Content_Rewriter::rewrite( $rb['content'], $id_map, $url_map, $ref_map ), ) ), true ); if ( is_wp_error( $new_block ) || ! $new_block ) { continue; } if ( '' !== $rb['sync_status'] ) { update_post_meta( (int) $new_block, 'wp_pattern_sync_status', $rb['sync_status'] ); } $ref_map[ (int) $rb['id'] ] = (int) $new_block; } return $ref_map; } /** * For field groups assigned to the source post by ID ("Page is equal to * X"), add an OR rule for the new target post so the fields appear in its * editor. Only groups stored in the target database can be edited; * JSON/PHP-registered groups are flagged for manual update. * * @param int $new_id * @param array $groups * @param Warning_Report $report */ private function extend_group_locations( $new_id, array $groups, Warning_Report $report ) { if ( empty( $groups ) || ! function_exists( 'acf_get_field_group' ) ) { return; } /** * Whether to add the new post to page/post-specific field group rules. * * @param bool $extend */ if ( ! apply_filters( 'mspc_extend_field_group_locations', true ) ) { return; } foreach ( $groups as $g ) { $group = acf_get_field_group( $g['key'] ); if ( ! $group ) { // Whole group missing — its fields are already reported as drift. continue; } $param = in_array( $g['param'], array( 'page', 'post' ), true ) ? $g['param'] : 'post'; $location = isset( $group['location'] ) && is_array( $group['location'] ) ? $group['location'] : array(); foreach ( $location as $and_rules ) { foreach ( (array) $and_rules as $rule ) { if ( isset( $rule['param'], $rule['value'] ) && $param === $rule['param'] && (int) $rule['value'] === (int) $new_id ) { continue 3; // Already assigned. } } } if ( empty( $group['ID'] ) || ! function_exists( 'acf_update_field_group' ) ) { $report->add_location( $g['title'], $param, 'manual' ); continue; } $location[] = array( array( 'param' => $param, 'operator' => '==', 'value' => (string) (int) $new_id, ), ); $group['location'] = $location; acf_update_field_group( $group ); $report->add_location( $g['title'], $param, 'added' ); } } /** * Capture every post meta row that isn't handled elsewhere: ACF values * (written via update_field), featured image, template, and WordPress * bookkeeping are excluded. Values are unserialized so they can be * URL-rewritten safely and re-serialized by add_post_meta(). * * @param int $post_id * @return array> meta_key => list of values */ private function capture_meta( $post_id ) { $all = get_post_meta( $post_id ); if ( ! is_array( $all ) ) { return array(); } $exclude_exact = array( '_edit_lock', '_edit_last', '_thumbnail_id', '_wp_page_template', '_wp_old_slug', '_wp_old_date', '_encloseme', '_pingme', '_wp_trash_meta_status', '_wp_trash_meta_time', '_wp_desired_post_slug', Warning_Report::META_KEY, ); $exclude_prefix = array( '_mspc_', '_oembed_', '_wp_attachment_' ); /** * Filter meta keys that must never be copied. * * @param string[] $exclude_exact * @param int $post_id */ $exclude_exact = (array) apply_filters( 'mspc_excluded_meta_keys', $exclude_exact, $post_id ); /** * Filter meta key prefixes that must never be copied. * * @param string[] $exclude_prefix * @param int $post_id */ $exclude_prefix = (array) apply_filters( 'mspc_excluded_meta_prefixes', $exclude_prefix, $post_id ); // ACF stores "name" => value and "_name" => field_xxx. Both are // written by update_field(), so skip them here. $acf_keys = array(); foreach ( $all as $key => $values ) { if ( '_' !== substr( $key, 0, 1 ) ) { continue; } $first = isset( $values[0] ) ? (string) $values[0] : ''; if ( 0 === strpos( $first, 'field_' ) ) { $acf_keys[ $key ] = true; $acf_keys[ substr( $key, 1 ) ] = true; } } $out = array(); foreach ( $all as $key => $values ) { $key = (string) $key; if ( isset( $acf_keys[ $key ] ) || in_array( $key, $exclude_exact, true ) ) { continue; } foreach ( $exclude_prefix as $prefix ) { if ( '' !== $prefix && 0 === strpos( $key, $prefix ) ) { continue 2; } } $out[ $key ] = array_map( 'maybe_unserialize', (array) $values ); } return $out; } /** * Write captured meta to the target post, swapping source media URLs for * target URLs. Existing rows for the same key (e.g. created by another * plugin's save_post hook) are replaced so the result matches the source. * * @param int $post_id * @param array> $meta * @param array $url_map */ private function apply_meta( $post_id, array $meta, array $url_map ) { foreach ( $meta as $key => $values ) { delete_post_meta( $post_id, $key ); foreach ( (array) $values as $value ) { $value = Content_Rewriter::replace_urls( $value, $url_map ); add_post_meta( $post_id, $key, wp_slash( $value ) ); } } } /** * Accept ID, numeric string, or ACF Array-return-format entry. * * @param mixed $value * @return int */ private function extract_id( $value ) { if ( is_array( $value ) ) { if ( isset( $value['ID'] ) ) { return (int) $value['ID']; } if ( isset( $value['id'] ) ) { return (int) $value['id']; } return 0; } return (int) $value; } }