module = $module; } /** * Write the job's objects to a package file. * * @param Job $job The export job. * @param string $path Destination zip path. * @return Report|\WP_Error */ public function export( Job $job, string $path ) { $this->module->before_export( $job ); $writer = new Package_Writer( $job, $this->module->excluded_meta_keys() ); $post_ids = array_map( 'intval', $job->object_ids() ); $post_ids = array_merge( $post_ids, $this->dependent_ids( $post_ids ) ); $post_ids = array_values( array_unique( $post_ids ) ); $plan = array(); // One query per 500 posts and their meta, instead of one per post below. foreach ( array_chunk( $post_ids, 500 ) as $chunk ) { _prime_post_caches( array_map( 'intval', $chunk ), false, true ); } foreach ( $post_ids as $post_id ) { $type = (string) get_post_type( (int) $post_id ); $plan[ $type ] = isset( $plan[ $type ] ) ? $plan[ $type ] + 1 : 1; } $progress = array_fill_keys( array_keys( $plan ), 0 ); $media = array(); $this->report( $job, array( 'phase' => 'posts', 'total' => count( $post_ids ), 'plan' => $plan, 'progress' => $progress, 'media' => 0, ), true ); foreach ( $post_ids as $index => $post_id ) { $writer->add_post( (int) $post_id ); foreach ( $this->media_ids( (int) $post_id ) as $attachment_id ) { $writer->add_media( $attachment_id ); $media[ $attachment_id ] = true; } foreach ( $this->term_ids( (int) $post_id ) as $term_id ) { $writer->add_term( $term_id ); } ++$progress[ (string) get_post_type( (int) $post_id ) ]; $this->report( $job, array( 'done' => $index + 1, 'progress' => $progress, 'media' => count( $media ), ) ); } $this->report( $job, array( 'phase' => 'settings', 'done' => count( $post_ids ), 'progress' => $progress, 'media' => count( $media ), ), true ); foreach ( $this->module->shared_settings() as $option => $keys ) { $value = get_option( (string) $option ); if ( is_array( $value ) ) { $writer->add_setting( (string) $option, array_intersect_key( $value, array_flip( (array) $keys ) ) ); } } $writer->set_payload( $this->module->export_payload( $job ) ); $this->report( $job, array( 'phase' => 'write' ), true ); return $writer->write( $path ); } /** * Record progress on the job so a client polling it can show it. * * Throttled: the job record is one option row, and rewriting it per post * would cost more than the export itself on a large site. * * @param Job $job The running job. * @param array $data Fields to merge into the record. * @param bool $force Write even inside the throttle window. * @return void */ private function report( Job $job, array $data, bool $force = false ): void { $now = microtime( true ); if ( ! $force && $now - $this->reported_at < 0.4 ) { return; } $this->reported_at = $now; Job_Cancelled::check( $job->id() ); Job_Store::update( $job->id(), $data ); } /** * Ids of dependent-type posts the given posts reference. * * Driven by the module's post hints, so the exporter never learns what a * package is (invariant A1). One level deep: a dependent that references * another dependent is not followed. * * @param int[] $post_ids Selected post ids. * @return int[] */ private function dependent_ids( array $post_ids ): array { $types = $this->module->followed_post_types(); if ( array() === $types ) { return array(); } $ids = array(); foreach ( $this->module->reference_hints() as $hint_key => $notation ) { $hint = Reference_Hint::from( (string) $notation ); if ( ! $hint instanceof Reference_Hint || Reference_Hint::TYPE_POST !== $hint->type() ) { continue; } $parsed = Hint_Path::split( (string) $hint_key ); foreach ( $post_ids as $post_id ) { foreach ( get_post_meta( (int) $post_id, $parsed['key'], false ) as $value ) { $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) ); } } } $ids = array_merge( $ids, $this->child_ids( $post_ids ) ); return array_values( array_filter( array_unique( array_map( 'intval', $ids ) ), static function ( $id ) use ( $types ) { return $id > 0 && in_array( get_post_type( $id ), $types, true ); } ) ); } /** * Ids of dependent-type posts whose parent is one of the given posts. * * A WooCommerce variation names its product only through post_parent — no * meta on the product lists it — so a hint can never find it. Read straight * from the posts table for the same reason Object_Query does: a * pre_get_posts filter must not decide what leaves the site. * * @param int[] $post_ids Selected post ids. * @return int[] */ private function child_ids( array $post_ids ): array { global $wpdb; $types = $this->module->dependent_post_types(); $post_ids = array_values( array_filter( array_map( 'intval', $post_ids ) ) ); if ( array() === $types || array() === $post_ids ) { return array(); } $ids_in = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); $types_in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders built above; export enumeration must bypass query filters. $children = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent IN ($ids_in) AND post_type IN ($types_in) AND post_status <> 'auto-draft'", array_merge( $post_ids, array_map( 'strval', $types ) ) ) ); return array_map( 'intval', (array) $children ); } /** * Term ids a post names by id inside a meta value. * * These are not the post's assigned terms — those travel on the record. * A pricing category is attached to nothing and referenced only by id, so * without this it never leaves the site and the id means nothing on arrival. * * @param int $post_id Post id. * @return int[] */ private function term_ids( int $post_id ): array { $ids = array(); foreach ( $this->module->reference_hints() as $hint_key => $notation ) { $hint = Reference_Hint::from( (string) $notation ); if ( ! $hint instanceof Reference_Hint || Reference_Hint::TYPE_TERM !== $hint->type() ) { continue; } $parsed = Hint_Path::split( (string) $hint_key ); $taxonomy = $hint->taxonomy(); foreach ( get_post_meta( $post_id, $parsed['key'], false ) as $value ) { foreach ( Hint_Path::collect_at( $value, $parsed['segments'] ) as $id ) { $term = $id > 0 ? get_term( (int) $id ) : null; // An id that is not a term of the named taxonomy is not a // reference at all. WP Travel Engine's pricing categories // are mostly plain local keys that happen to collide with // unrelated term ids, and exporting those terms would // invite the importer to rewrite a price into a trip tag. if ( ! $term instanceof \WP_Term ) { continue; } if ( '' !== $taxonomy && $term->taxonomy !== $taxonomy ) { continue; } $ids[] = (int) $term->term_id; } } } return array_values( array_unique( $ids ) ); } /** * Attachment ids a post references, through hints and through its content. * * @param int $post_id Post id. * @return int[] */ private function media_ids( int $post_id ): array { $ids = array(); foreach ( $this->module->reference_hints() as $hint_key => $notation ) { $hint = Reference_Hint::from( (string) $notation ); if ( ! $hint instanceof Reference_Hint || Reference_Hint::TYPE_ATTACHMENT !== $hint->type() ) { continue; } // A hint may address a path inside the value, so only the ids at // that path are collected — not every number in the array. $parsed = Hint_Path::split( (string) $hint_key ); foreach ( get_post_meta( $post_id, $parsed['key'], false ) as $value ) { $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) ); } } $content = (string) get_post_field( 'post_content', $post_id ); if ( 1 === preg_match_all( '#wp-image-(\d+)#', $content, $matches ) || ! empty( $matches[1] ) ) { $ids = array_merge( $ids, array_map( 'intval', $matches[1] ) ); } return array_values( array_filter( array_unique( $ids ), static function ( $id ) { return $id > 0 && 'attachment' === get_post_type( $id ); } ) ); } }