planner = $planner; $this->uuids = $uuids; } /** * Plans an import without writing anything. * * @param Manifest $manifest Manifest. * @return ImportPlan */ public function plan( Manifest $manifest ): ImportPlan { return $this->planner->plan( $manifest ); } /** * Imports a package in one go (used by WP-CLI and tests). * * @param Package $package Validated package. * @param string[] $skip_uuids UUIDs of entities not to write (deselected by the user). * @return ImportReport */ public function import( Package $package, array $skip_uuids = array() ): ImportReport { $job = $this->start( $package, $skip_uuids ); $this->run( $job, $package ); return $job->get_report(); } /** * Starts an import: plans it, maps matched entities, records the entities * that won't be written and decides the write order. Nothing is written yet. * * @param Package $package Validated package. * @param string[] $skip_uuids UUIDs of entities not to write (deselected by the user). * @return ImportJob */ public function start( Package $package, array $skip_uuids = array() ): ImportJob { $manifest = $package->get_manifest(); /** * Fires before a package is imported. * * @since 0.2.0 * * @param Manifest $manifest The manifest being imported. */ do_action( 'cherrylift_before_import', $manifest ); $plan = $this->planner->plan( $manifest ); $handlers = $this->planner->get_handlers(); $context = new ImportContext( $manifest, $package ); $report = new ImportReport(); $skip = array_fill_keys( array_map( 'strtolower', $skip_uuids ), true ); $to_write = array(); foreach ( $plan->get_items() as $item ) { if ( null !== $item['local_id'] ) { $context->set_local_id( $item['uuid'], $item['local_id'] ); } $writable = ImportPlan::CREATE === $item['action'] || ImportPlan::UPDATE === $item['action']; if ( $writable && ! isset( $skip[ $item['uuid'] ] ) ) { $to_write[ $item['uuid'] ] = $item; continue; } $this->record_unwritten( $item, $writable, $manifest, $handlers, $context, $report ); } $context->set_pending( array_keys( $to_write ) ); $job = new ImportJob( $to_write, $this->sort( $to_write, $manifest, $handlers, $context ), $report ); $job->set_context_state( $context->get_state() ); return $job; } /** * Runs (part of) an import job. * * Without limits, runs the job to completion. With limits, stops after the * given number of entities or seconds (at least one entity is processed), * so the job can continue in another request. * * @param ImportJob $job Job from start(), possibly restored. * @param Package $package Validated package. * @param array{max_entities: int, max_seconds: int}|null $limits Batch limits, or null for none. * @return ImportJob The same job, advanced. */ public function run( ImportJob $job, Package $package, ?array $limits = null ): ImportJob { $manifest = $package->get_manifest(); $handlers = $this->planner->get_handlers(); $context = new ImportContext( $manifest, $package ); $started = microtime( true ); $processed = 0; $context->restore_state( $job->get_context_state() ); while ( ! $job->is_done() ) { if ( null !== $limits && $processed > 0 && ( $processed >= $limits['max_entities'] || microtime( true ) - $started >= $limits['max_seconds'] ) ) { break; } if ( ImportJob::PHASE_WRITE === $job->get_phase() ) { $order = $job->get_order(); if ( $job->get_position() >= count( $order ) ) { $job->set_phase( ImportJob::PHASE_FIXUP ); continue; } $uuid = $order[ $job->get_position() ]; $item = $job->get_item( $uuid ); if ( null !== $item && $this->write( $item, $manifest, $handlers, $context, $job->get_report() ) ) { $job->mark_written( $uuid ); } $job->advance(); ++$processed; continue; } // Fixup phase: write again entities that referenced something written after them. $written = $job->get_written(); if ( $job->get_position() >= count( $written ) ) { $this->finish( $job, $context, $manifest ); break; } $uuid = $written[ $job->get_position() ]; $item = $job->get_item( $uuid ); if ( null !== $item && $context->needs_fixup( $uuid ) ) { $context->clear_fixup( $uuid ); $this->write( $item, $manifest, $handlers, $context, $job->get_report(), true ); ++$processed; } $job->advance(); } $job->set_context_state( $context->get_state() ); return $job; } /** * Completes a job: collects warnings and fires the after-import action. * * @param ImportJob $job Job. * @param ImportContext $context Import context. * @param Manifest $manifest Manifest. * @return void */ private function finish( ImportJob $job, ImportContext $context, Manifest $manifest ): void { $job->set_phase( ImportJob::PHASE_DONE ); $report = $job->get_report(); $report->add_warnings( $context->get_warnings() ); /** * Fires after a package has been imported. * * @since 0.2.0 * * @param ImportReport $report What happened to each entity. * @param Manifest $manifest The imported manifest. */ do_action( 'cherrylift_after_import', $report, $manifest ); } /** * Records entities that are not written: skipped, linked or missing. * * @param array $item Plan item. * @param bool $writable Whether it would have been written but was deselected. * @param Manifest $manifest Manifest. * @param ImportHandler[] $handlers Handlers. * @param ImportContext $context Import context. * @param ImportReport $report Report. * @return void */ private function record_unwritten( array $item, bool $writable, Manifest $manifest, array $handlers, ImportContext $context, ImportReport $report ): void { $entity = $manifest->get_entity( $item['uuid'] ); $handler = null !== $entity ? $this->planner->find_handler( $entity, $handlers ) : null; if ( null !== $entity && null !== $item['local_id'] && $handler instanceof LinksExistingEntities ) { $handler->link( $entity, $item['local_id'], $context ); } switch ( $item['action'] ) { case ImportPlan::LINK: $report->record( $item, ImportReport::LINKED, $item['local_id'] ); break; case ImportPlan::MISSING: $report->record( $item, ImportReport::MISSING, null, __( 'Not found on this site: references to it will not be remapped.', 'cherrylift' ) ); break; default: $report->record( $item, ImportReport::SKIPPED, $item['local_id'], $writable ? __( 'Deselected.', 'cherrylift' ) : $item['reason'] ); } } /** * Writes one entity. * * @param array $item Plan item. * @param Manifest $manifest Manifest. * @param ImportHandler[] $handlers Handlers. * @param ImportContext $context Import context. * @param ImportReport $report Report. * @param bool $is_fixup Whether this is a second write to resolve references. * @return bool Whether it was written. */ private function write( array $item, Manifest $manifest, array $handlers, ImportContext $context, ImportReport $report, bool $is_fixup = false ): bool { $entity = $manifest->get_entity( $item['uuid'] ); $handler = null !== $entity ? $this->planner->find_handler( $entity, $handlers ) : null; if ( null === $entity || null === $handler ) { return false; } $local_id = $context->peek_local_id( $item['uuid'] ); $result = $is_fixup || null !== $item['local_id'] ? ImportReport::UPDATED : ImportReport::CREATED; /** * Filters an entity just before it is written. * * @since 0.2.0 * * @param array $entity Manifest entity. * @param int|null $local_id Local ID being updated, or null when creating. */ $filtered = apply_filters( 'cherrylift_import_entity_data', $entity, $local_id ); $entity = is_array( $filtered ) ? $filtered : $entity; $context->set_current( $item['uuid'] ); try { $new_id = $handler->import( $entity, $local_id, $context ); $this->uuids->assign( $handler->get_object_type(), $new_id, $item['uuid'] ); $context->set_local_id( $item['uuid'], $new_id ); } catch ( Throwable $e ) { $context->set_current( null ); $report->record( $item, ImportReport::FAILED, $local_id, PlainText::from_exception( $e ) ); /** * Fires when an entity fails to import. The import continues with the other entities. * * @since 0.2.0 * * @param array $entity Manifest entity. * @param string $message Error message (plain text). */ do_action( 'cherrylift_import_failed', $entity, PlainText::from_exception( $e ) ); return false; } $context->set_current( null ); if ( ! $is_fixup ) { $report->record( $item, $result, $new_id ); /** * Fires after an entity has been written. * * @since 0.2.0 * * @param array $entity Manifest entity. * @param int $local_id Local ID (post or term ID). * @param string $result 'created' or 'updated'. */ do_action( 'cherrylift_entity_imported', $entity, $new_id, $result ); } return true; } /** * Orders entities so dependencies are written first (Kahn's algorithm). * * Entities caught in a dependency cycle are appended in manifest order; * their unresolved references are fixed in a second pass. * * @param array> $to_write Plan items to write, keyed by UUID. * @param Manifest $manifest Manifest. * @param ImportHandler[] $handlers Handlers. * @param ImportContext $context Import context. * @return string[] UUIDs in write order. */ private function sort( array $to_write, Manifest $manifest, array $handlers, ImportContext $context ): array { $dependencies = array(); $dependents = array(); foreach ( array_keys( $to_write ) as $uuid ) { $entity = $manifest->get_entity( $uuid ); $handler = null !== $entity ? $this->planner->find_handler( $entity, $handlers ) : null; $deps = null !== $handler && null !== $entity ? $handler->get_dependencies( $entity, $context ) : array(); $dependencies[ $uuid ] = array(); foreach ( $deps as $dependency ) { $dependency = strtolower( $dependency ); if ( $dependency !== $uuid && isset( $to_write[ $dependency ] ) ) { $dependencies[ $uuid ][ $dependency ] = true; $dependents[ $dependency ][] = $uuid; } } } $order = array(); $ready = array_keys( array_filter( $dependencies, static function ( array $deps ) { return array() === $deps; } ) ); while ( array() !== $ready ) { $uuid = array_shift( $ready ); $order[] = $uuid; foreach ( $dependents[ $uuid ] ?? array() as $dependent ) { unset( $dependencies[ $dependent ][ $uuid ] ); if ( array() === $dependencies[ $dependent ] && ! in_array( $dependent, $order, true ) && ! in_array( $dependent, $ready, true ) ) { $ready[] = $dependent; } } } foreach ( array_keys( $to_write ) as $uuid ) { if ( ! in_array( $uuid, $order, true ) ) { $order[] = $uuid; } } return $order; } }