> */ private $posts = array(); /** * Outcome of the collection pass. * * @var Report */ private $report; /** * Media collected alongside the posts. * * @var Media_Collector */ private $media; /** * Terms referenced by id from meta, keyed by source term id. * * Separate from a post's assigned terms. A pricing category is a term that * is attached to nothing and named only by its id inside a meta array, so * wp_get_object_terms() never sees it — but the id in that meta is * meaningless on the target unless the term travels with it. * * @var array> */ private $terms = array(); /** * Slices of global options the module's posts depend on. * * @var array> */ private $settings = array(); /** * The module's own payload (Module::export_payload()). * * @var array */ private $payload = array(); /** * Meta keys that must not be written, as a lookup. * * @var array */ private $excluded_meta_keys = array(); /** * Constructor. * * @param Job $job The export job. * @param string[] $excluded_meta_keys Meta keys the module forbids exporting. */ public function __construct( Job $job, array $excluded_meta_keys = array() ) { $this->job = $job; $this->report = new Report(); $this->media = new Media_Collector(); $this->excluded_meta_keys = array_flip( array_map( 'strval', $excluded_meta_keys ) ); } /** * Carry part of an option with the package. * * @param string $option Option name. * @param array $value The keys of that option to carry. * @return void */ public function add_setting( string $option, array $value ): void { $this->settings[ $option ] = $value; } /** * Carry the module's own payload with the package. * * @param array $payload Module::export_payload() output. * @return void */ public function set_payload( array $payload ): void { $this->payload = $payload; } /** * Collect an attachment, file and all. * * @param int $attachment_id Attachment id. * @return bool True when the attachment was added. */ public function add_media( int $attachment_id ): bool { if ( $this->media->has( $attachment_id ) ) { return true; } $added = $this->media->add( $attachment_id ); if ( true !== $added ) { $this->report->add_failure( (string) $attachment_id, (string) $added ); return false; } $this->report->add_created(); return true; } /** * Collect one post, its meta and its terms. * * @param int $post_id Post id. * @return bool True when the post was added. */ public function add_post( int $post_id ): bool { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { $this->report->add_failure( (string) $post_id, __( 'This item no longer exists on this site.', 'transtrix' ) ); return false; } if ( 'attachment' === $post->post_type ) { return $this->add_media( $post_id ); } $meta = array(); foreach ( get_post_meta( $post_id ) as $key => $values ) { // A6: a denied key never reaches the package, so it cannot leak by // being written, inspected in the zip, or shipped to a third party. if ( isset( $this->excluded_meta_keys[ $key ] ) ) { continue; } // get_post_meta() in bulk mode returns raw strings; unserialize each. $meta[ $key ] = array_map( 'maybe_unserialize', (array) $values ); } $terms = array(); foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) { $assigned = wp_get_object_terms( $post_id, $taxonomy ); if ( is_wp_error( $assigned ) ) { continue; } foreach ( $assigned as $term ) { $terms[ $taxonomy ][] = array( // The source id travels beside the slug. Terms are matched by // slug, but meta can reference a term by id, and without the // source id there is nothing to map that reference from. 'id' => (int) $term->term_id, 'slug' => $term->slug, 'name' => $term->name, 'description' => $term->description, 'parent_slug' => $this->parent_slug( $term->parent, $taxonomy ), ); } } $this->posts[] = array( 'id' => $post->ID, 'post_type' => $post->post_type, 'post_title' => $post->post_title, 'post_name' => $post->post_name, 'post_status' => $post->post_status, 'post_content' => $post->post_content, 'post_excerpt' => $post->post_excerpt, 'post_date_gmt' => $post->post_date_gmt, 'menu_order' => (int) $post->menu_order, 'post_parent' => (int) $post->post_parent, 'meta' => $meta, 'terms' => $terms, ); $this->report->add_created(); return true; } /** * Collect a term named only by its id from somewhere in a meta value. * * @param int $term_id Source term id. * @return bool True when the term was added or is already collected. */ public function add_term( int $term_id ): bool { if ( isset( $this->terms[ $term_id ] ) ) { return true; } $term = get_term( $term_id ); if ( ! $term instanceof \WP_Term ) { return false; } $this->terms[ $term_id ] = array( 'id' => (int) $term->term_id, 'taxonomy' => $term->taxonomy, 'slug' => $term->slug, 'name' => $term->name, 'description' => $term->description, 'parent_slug' => $this->parent_slug( $term->parent, $term->taxonomy ), ); return true; } /** * Write the zip to disk. * * @param string $path Destination file path. * @return Report|WP_Error The collection report, or an error when the zip could not be written. */ public function write( string $path ) { $posts_json = wp_json_encode( $this->posts ); $terms_json = wp_json_encode( array_values( $this->terms ) ); $settings_json = wp_json_encode( (object) $this->settings ); $payload_json = wp_json_encode( (object) $this->payload ); if ( ! is_string( $terms_json ) || ! is_string( $settings_json ) || ! is_string( $payload_json ) ) { return new WP_Error( 'transtrix_encode_failed', __( 'The package could not be written. Check that the uploads folder has free space.', 'transtrix' ) ); } if ( ! is_string( $posts_json ) ) { return new WP_Error( 'transtrix_encode_failed', __( 'The package could not be written. Check that the uploads folder has free space.', 'transtrix' ) ); } $media_records = $this->media->records(); $media_json = wp_json_encode( $media_records ); if ( ! is_string( $media_json ) ) { return new WP_Error( 'transtrix_encode_failed', __( 'The package could not be written. Check that the uploads folder has free space.', 'transtrix' ) ); } $manifest = new Manifest( array( 'module_id' => $this->job->module_id(), 'counts' => array( 'posts' => count( $this->posts ), 'media' => count( $media_records ), ), ) ); $manifest->add_checksum( 'content/posts.json', $posts_json ); $manifest->add_checksum( 'content/terms.json', $terms_json ); $manifest->add_checksum( 'content/media.json', $media_json ); $manifest->add_checksum( 'content/settings.json', $settings_json ); $manifest->add_checksum( 'content/payload.json', $payload_json ); foreach ( $this->media->files() as $relative_path => $file ) { $manifest->add_hash( $relative_path, $file['sha256'] ); } $manifest_json = wp_json_encode( $manifest->to_array() ); if ( ! is_string( $manifest_json ) ) { return new WP_Error( 'transtrix_encode_failed', __( 'The package could not be written. Check that the uploads folder has free space.', 'transtrix' ) ); } $zip = new ZipArchive(); $opened = $zip->open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ); if ( true !== $opened ) { return new WP_Error( 'transtrix_zip_open_failed', __( 'The package could not be created. Check that the uploads folder is writable.', 'transtrix' ) ); } $zip->addFromString( 'manifest.json', $manifest_json ); $zip->addFromString( 'content/posts.json', $posts_json ); $zip->addFromString( 'content/terms.json', $terms_json ); $zip->addFromString( 'content/media.json', $media_json ); $zip->addFromString( 'content/settings.json', $settings_json ); $zip->addFromString( 'content/payload.json', $payload_json ); foreach ( $this->media->files() as $relative_path => $file ) { $zip->addFile( $file['path'], $relative_path ); } // close() is where the zip is actually written; a full disk fails here. if ( ! $zip->close() ) { wp_delete_file( $path ); return new WP_Error( 'transtrix_zip_write_failed', __( 'The package could not be written. Check that the uploads folder has free space.', 'transtrix' ) ); } return $this->report; } /** * Slug of a term's parent, so hierarchy survives without carrying ids. * * @param int $parent_id Parent term id. * @param string $taxonomy Taxonomy name. * @return string Empty string when there is no parent. */ private function parent_slug( int $parent_id, string $taxonomy ): string { if ( $parent_id <= 0 ) { return ''; } $parent = get_term( $parent_id, $taxonomy ); return ( $parent instanceof \WP_Term ) ? $parent->slug : ''; } }