meta = $meta; $this->jobs = $jobs; } /** * Registers job handlers. */ public function register() { $this->jobs->register( self::JOB_IMPORT, array( $this, 'import_batch' ), 50 ); $this->jobs->register( self::JOB_ROLLBACK, array( $this, 'rollback_batch' ), 100 ); } /** * Available sources keyed by id. * * @return array */ public function sources() { $sources = array(); foreach ( array( new Yoast(), new RankMath(), new Aioseo(), new Seopress(), new Tsf() ) as $source ) { $sources[ $source->id() ] = $source; } return $sources; } /** * One source by id. * * @param string $id Source id. * @return Source|null */ public function source( $id ) { $sources = $this->sources(); return isset( $sources[ $id ] ) ? $sources[ $id ] : null; } /** * Starts an import job. * * @param string $source_id Source id. * @return bool */ public function start( $source_id ) { if ( ! $this->source( $source_id ) || $this->busy() ) { return false; } update_option( self::SOURCE_OPTION, $source_id, false ); return $this->jobs->start( self::JOB_IMPORT ); } /** * Starts a rollback job. * * @return bool */ public function start_rollback() { if ( $this->busy() ) { return false; } return $this->jobs->start( self::JOB_ROLLBACK ); } /** * Whether an import or rollback is running. * * @return bool */ public function busy() { foreach ( array( self::JOB_IMPORT, self::JOB_ROLLBACK ) as $job ) { $state = $this->jobs->status( $job ); if ( $state && 'running' === $state['status'] ) { return true; } } return false; } /** * Preview rows: what an import would do, without writing anything. * * @param string $source_id Source id. * @param int $limit Maximum items to inspect. * @return array */ public function preview( $source_id, $limit = 20 ) { $source = $this->source( $source_id ); if ( ! $source ) { return array(); } $rows = array(); foreach ( $source->post_ids_after( 0, $limit ) as $id ) { $rows = array_merge( $rows, $this->preview_object( 'post', $id, get_the_title( $id ), $source->read_post( $id ) ) ); } foreach ( $source->term_ids_after( 0, $limit ) as $id ) { $term = get_term( $id ); $rows = array_merge( $rows, $this->preview_object( 'term', $id, $term && ! is_wp_error( $term ) ? $term->name : '#' . $id, $source->read_term( $id ) ) ); } $source->templates(); $this->preview_dropped = $source->dropped_variables(); return $rows; } /** * Variables seen by the last preview() that have no Orbitstem equivalent. * * @var string[] */ public $preview_dropped = array(); /** * Change log, for undoable template imports. * * @var \Orbitstem\SEO\Repair\ChangeLog|null */ private $log; /** * Sets the change log (created after the importer). * * @param \Orbitstem\SEO\Repair\ChangeLog $log Change log. */ public function set_log( $log ) { $this->log = $log; } /** * Templates a source would fill in: only settings still at Orbitstem's * default are changed, so templates you wrote are kept. * * @param string $source_id Source id. * @return array Keyed by settings path. */ public function template_changes( $source_id ) { $source = $this->source( $source_id ); if ( ! $source ) { return array(); } $stored = (array) get_option( \Orbitstem\SEO\Core\Settings::OPTION, array() ); $changes = array(); foreach ( $source->templates() as $path => $value ) { $current = self::setting_at( $stored, $path ); $default = self::default_at( $path ); // A value still equal to an earlier version's default counts as untouched. $legacy = 'home_title' === $path ? \Orbitstem\SEO\Core\Settings::legacy_defaults()['home_title'] : ( preg_match( '/^post_types\.[^.]+\.title$/', $path ) ? \Orbitstem\SEO\Core\Settings::legacy_defaults()['post_type_title'] : null ); if ( null === $default || ( null !== $current && $current !== $default && $current !== $legacy ) || $value === $default ) { continue; } $changes[ $path ] = array( // What the site uses now: the saved value, or the default when none is saved. 'from' => (string) ( null !== $current ? $current : $default ), 'to' => (string) $value, ); } return $changes; } /** * Applies template_changes() and records them so they can be undone. * * @param string $source_id Source id. * @return int Settings changed. */ public function import_templates( $source_id ) { $changes = $this->template_changes( $source_id ); if ( ! $changes ) { return 0; } $stored = (array) get_option( \Orbitstem\SEO\Core\Settings::OPTION, array() ); $before = array(); $after = array(); foreach ( $changes as $path => $change ) { $before[ $path ] = self::setting_at( $stored, $path ); $after[ $path ] = $change['to']; $stored = self::with_setting( $stored, $path, $change['to'] ); } update_option( \Orbitstem\SEO\Core\Settings::OPTION, $stored ); // Record what was actually saved (after sanitizing), so undo can recognise it. $saved = (array) get_option( \Orbitstem\SEO\Core\Settings::OPTION, array() ); foreach ( array_keys( $after ) as $path ) { $after[ $path ] = self::setting_at( $saved, $path ); } if ( $this->log ) { $this->log->record( 'settings_import', 'site', 0, /* translators: 1: number of settings, 2: plugin name */ sprintf( _n( 'Imported %1$d title template setting from %2$s', 'Imported %1$d title template settings from %2$s', count( $after ), 'orbitstem-seo' ), count( $after ), $this->source( $source_id )->name() ), array( 'values' => $before ), array( 'values' => $after ) ); } return count( $after ); } /** * Value at a dotted settings path in stored settings, or null when unset. * * @param array $settings Stored settings. * @param string $path Path. * @return mixed */ public static function setting_at( array $settings, $path ) { $value = $settings; foreach ( explode( '.', $path ) as $part ) { if ( ! is_array( $value ) || ! array_key_exists( $part, $value ) ) { return null; } $value = $value[ $part ]; } return $value; } /** * Orbitstem's default for a settings path, or null for unknown paths. * * @param string $path Path. * @return mixed */ private static function default_at( $path ) { $parts = explode( '.', $path ); if ( 3 === count( $parts ) && 'post_types' === $parts[0] ) { $defaults = \Orbitstem\SEO\Core\Settings::post_type_defaults( $parts[1] ); return isset( $defaults[ $parts[2] ] ) ? $defaults[ $parts[2] ] : null; } if ( 3 === count( $parts ) && 'taxonomies' === $parts[0] ) { $defaults = \Orbitstem\SEO\Core\Settings::taxonomy_defaults( $parts[1] ); return isset( $defaults[ $parts[2] ] ) ? $defaults[ $parts[2] ] : null; } return self::setting_at( \Orbitstem\SEO\Core\Settings::defaults(), $path ); } /** * Stored settings with a dotted path set. * * @param array $settings Settings. * @param string $path Path. * @param mixed $value Value. * @return array */ public static function with_setting( array $settings, $path, $value ) { $parts = explode( '.', $path ); $ref = &$settings; foreach ( $parts as $i => $part ) { if ( count( $parts ) - 1 === $i ) { $ref[ $part ] = $value; break; } if ( ! isset( $ref[ $part ] ) || ! is_array( $ref[ $part ] ) ) { $ref[ $part ] = array(); } $ref = &$ref[ $part ]; } unset( $ref ); return $settings; } /** * Job handler: imports one batch. Cursor: stage (posts|terms), last ID, totals. * * @param mixed $cursor Cursor. * @param int $size Batch size. * @return array * @throws \RuntimeException When the stored source id is unknown; the job runner records it as a failed batch. */ public function import_batch( $cursor, $size ) { $cursor = is_array( $cursor ) ? $cursor : array( 'stage' => 'posts', 'last' => 0, 'items' => 0, 'fields' => 0, 'skipped' => 0, ); $source = $this->source( (string) get_option( self::SOURCE_OPTION ) ); if ( ! $source ) { throw new \RuntimeException( 'Unknown import source.' ); } $ids = 'posts' === $cursor['stage'] ? $source->post_ids_after( $cursor['last'], $size ) : $source->term_ids_after( $cursor['last'], $size ); foreach ( $ids as $id ) { $values = 'posts' === $cursor['stage'] ? $source->read_post( $id ) : $source->read_term( $id ); $result = $this->import_object( 'posts' === $cursor['stage'] ? 'post' : 'term', $id, $values, $source->id() ); $cursor['fields'] += $result['written']; $cursor['skipped'] += $result['skipped']; $cursor['items'] += $result['written'] ? 1 : 0; $cursor['last'] = $id; } if ( count( $ids ) < $size ) { if ( 'posts' === $cursor['stage'] ) { $cursor['stage'] = 'terms'; $cursor['last'] = 0; } else { return array( 'cursor' => $cursor, 'done' => true, ); } } return array( 'cursor' => $cursor, 'done' => false, ); } /** * Job handler: rolls back one batch of imported items. * * @param mixed $cursor Cursor. * @param int $size Batch size. * @return array */ public function rollback_batch( $cursor, $size ) { global $wpdb; $cursor = is_array( $cursor ) ? $cursor : array( 'stage' => 'posts', 'last' => 0, 'removed' => 0, 'kept' => 0, ); $type = 'posts' === $cursor['stage'] ? 'post' : 'term'; if ( 'post' === $type ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d", self::MARKER, (int) $cursor['last'], (int) $size ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $ids = $wpdb->get_col( $wpdb->prepare( "SELECT term_id FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id > %d ORDER BY term_id ASC LIMIT %d", self::MARKER, (int) $cursor['last'], (int) $size ) ); } foreach ( array_map( 'intval', $ids ) as $id ) { $result = $this->rollback_object( $type, $id ); $cursor['removed'] += $result['removed']; $cursor['kept'] += $result['kept']; $cursor['last'] = $id; } if ( count( $ids ) < $size ) { if ( 'posts' === $cursor['stage'] ) { $cursor['stage'] = 'terms'; $cursor['last'] = 0; } else { return array( 'cursor' => $cursor, 'done' => true, ); } } return array( 'cursor' => $cursor, 'done' => false, ); } /** * Writes one item's empty fields and records what was written. * * @param string $type 'post' or 'term'. * @param int $id Object ID. * @param array $values Converted source values. * @param string $source_id Source id. * @return array{written:int,skipped:int} */ public function import_object( $type, $id, array $values, $source_id ) { $fields = MetaRepository::fields(); $writes = array(); $skipped = 0; foreach ( $values as $field => $value ) { if ( ! isset( $fields[ $field ] ) ) { continue; } $clean = MetaRepository::sanitize( $fields[ $field ], $value ); if ( '' === $clean || false === $clean ) { continue; } if ( MetaRepository::has_value( $type, $id, $field ) ) { ++$skipped; continue; } $writes[ $field ] = $clean; } if ( $writes ) { \Orbitstem\SEO\Guardian\Guardian::suspend(); // Imports are not risky edits to record one by one. $this->meta->update_fields( $type, $id, $writes ); \Orbitstem\SEO\Guardian\Guardian::resume(); $marker = $this->marker( $type, $id ); foreach ( $writes as $field => $value ) { $marker['fields'][ $field ] = $value; } $marker['source'] = $source_id; update_metadata( $type, $id, self::MARKER, wp_slash( wp_json_encode( $marker ) ) ); } return array( 'written' => count( $writes ), 'skipped' => $skipped, ); } /** * Removes imported fields from one item unless edited since import. * * @param string $type 'post' or 'term'. * @param int $id Object ID. * @return array{removed:int,kept:int} */ public function rollback_object( $type, $id ) { $marker = $this->marker( $type, $id ); $removed = 0; $kept = 0; foreach ( $marker['fields'] as $field => $imported ) { $key = MetaRepository::PREFIX . $field; $current = get_metadata( $type, $id, $key, true ); $same = is_bool( $imported ) ? (bool) $current === $imported && metadata_exists( $type, $id, $key ) : (string) $current === (string) $imported; if ( $same ) { \Orbitstem\SEO\Guardian\Guardian::suspend(); delete_metadata( $type, $id, $key ); \Orbitstem\SEO\Guardian\Guardian::resume(); ++$removed; } else { ++$kept; } } delete_metadata( $type, $id, self::MARKER ); return array( 'removed' => $removed, 'kept' => $kept, ); } /** * Whether any imported items exist (so rollback is meaningful). * * @return bool */ public function has_imported() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = %s LIMIT 1", self::MARKER ) ) || (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->termmeta} WHERE meta_key = %s LIMIT 1", self::MARKER ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Preview rows for one item. * * @param string $type 'post' or 'term'. * @param int $id Object ID. * @param string $label Item label. * @param array $values Converted source values. * @return array */ private function preview_object( $type, $id, $label, array $values ) { $rows = array(); $fields = MetaRepository::fields(); foreach ( $values as $field => $value ) { if ( ! isset( $fields[ $field ] ) ) { continue; } $clean = MetaRepository::sanitize( $fields[ $field ], $value ); if ( '' === $clean || false === $clean ) { continue; } $exists = MetaRepository::has_value( $type, $id, $field ); $rows[] = array( 'object' => $type, 'id' => $id, 'label' => $label, 'field' => $field, 'to' => is_bool( $clean ) ? 'yes' : (string) $clean, 'action' => $exists ? 'skip' : 'import', ); } return $rows; } /** * Decoded marker for an item. * * @param string $type 'post' or 'term'. * @param int $id Object ID. * @return array{source:string,fields:array} */ private function marker( $type, $id ) { $raw = get_metadata( $type, $id, self::MARKER, true ); $marker = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( ! is_array( $marker ) || ! isset( $marker['fields'] ) || ! is_array( $marker['fields'] ) ) { $marker = array( 'source' => '', 'fields' => array(), ); } return $marker; } }