*/ private array $fields = array(); public function begin( string $import_id, bool $dry_run ): void { $this->import_id = $import_id; $this->dry_run = $dry_run; $this->fields = array(); } /** * What was written, by field. The screen reports this rather than a bare * count: "412 titles, 380 descriptions, 12 canonicals" is checkable. * * @return array */ public function fields(): array { return $this->fields; } /** * Apply one post record. * * @param array $record * @return bool Whether anything changed (or would change, in a dry run). */ public function post( int $post_id, array $record, string $mode ): bool { $changed = false; if ( '' !== (string) ( $record['title'] ?? '' ) ) { $changed = $this->meta( $post_id, '_airanksuite_seo_title', (string) $record['title'], $mode, 'title' ) || $changed; } if ( '' !== (string) ( $record['desc'] ?? '' ) ) { $changed = $this->meta( $post_id, '_airanksuite_seo_desc', (string) $record['desc'], $mode, 'desc' ) || $changed; } if ( '' !== (string) ( $record['canonical'] ?? '' ) ) { $url = esc_url_raw( (string) $record['canonical'] ); if ( '' !== $url ) { $changed = $this->meta( $post_id, PostSeo::CANONICAL, $url, $mode, 'canonical' ) || $changed; } } if ( '' !== (string) ( $record['breadcrumb_title'] ?? '' ) ) { $changed = $this->meta( $post_id, PostSeo::CRUMB_TITLE, (string) $record['breadcrumb_title'], $mode, 'breadcrumb' ) || $changed; } if ( '' !== (string) ( $record['schema_type'] ?? '' ) ) { $changed = $this->meta( $post_id, '_airanksuite_schema_type', (string) $record['schema_type'], $mode, 'schema' ) || $changed; } if ( ! empty( $record['exclude_sitemap'] ) ) { $changed = $this->meta( $post_id, '_airanksuite_exclude_sitemap', '1', $mode, 'sitemap' ) || $changed; } // Robots is a list: an imported noindex is added to whatever is there // rather than replacing the list, because "noindex" and "nofollow" are // separate decisions and an import should not undo one by setting the other. $robots = array_values( array_filter( array_map( 'strval', (array) ( $record['robots'] ?? array() ) ) ) ); if ( ! empty( $robots ) ) { $current = get_post_meta( $post_id, '_airanksuite_robots', true ); $current = is_array( $current ) ? array_values( array_map( 'strval', $current ) ) : array(); $merged = array_values( array_unique( array_merge( $current, $robots ) ) ); if ( $merged !== $current ) { $changed = $this->meta_raw( $post_id, '_airanksuite_robots', $merged, 'robots' ) || $changed; } } // Keywords: the source's list becomes our short-tail group, the first // one the focus keyword. In fill mode a post that already has keywords // keeps them. $keywords = array_values( array_filter( array_map( 'strval', (array) ( $record['keywords'] ?? array() ) ) ) ); if ( ! empty( $keywords ) ) { $current = \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::get( $post_id ); $have = (array) ( $current['shorttail'] ?? array() ); if ( 'overwrite' === $mode || empty( $have ) ) { if ( ! $this->dry_run ) { $this->snapshot( 'post_meta', $post_id, \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::META_KEY ); $this->snapshot( 'post_meta', $post_id, '_airanksuite_focus_keyword' ); $current['shorttail'] = 'overwrite' === $mode ? array_values( array_unique( array_merge( $keywords, $have ) ) ) : $keywords; \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::save( $post_id, $current ); } $this->count( 'keywords' ); $changed = true; } } $social = array_filter( array_map( 'strval', (array) ( $record['social'] ?? array() ) ), static fn( $v ) => '' !== trim( $v ) ); if ( ! empty( $social ) ) { $current = PostSeo::social( $post_id ); $merged = $current; $touched = false; foreach ( $social as $key => $value ) { if ( 'overwrite' === $mode || '' === (string) ( $current[ $key ] ?? '' ) ) { $merged[ $key ] = $value; $touched = true; } } if ( $touched ) { if ( ! $this->dry_run ) { $this->snapshot( 'post_meta', $post_id, PostSeo::SOCIAL ); PostSeo::save_social( $post_id, $merged ); } $this->count( 'social' ); $changed = true; } } if ( ! empty( $record['redirect']['target'] ) ) { $from = (string) get_permalink( $post_id ); $path = \AiRankSuite\Modules\Redirects\RedirectsModule::normalize( $from ); $this->redirect( $path, (string) $record['redirect']['target'], (int) ( $record['redirect']['type'] ?? 301 ) ); $changed = true; } return $changed; } /** * Apply one term record. * * @param array $record */ public function term( int $term_id, array $record, string $mode ): bool { $changed = false; if ( '' !== (string) ( $record['title'] ?? '' ) ) { $changed = $this->meta( $term_id, self::TERM_TITLE, (string) $record['title'], $mode, 'term_title', true ) || $changed; } if ( '' !== (string) ( $record['desc'] ?? '' ) ) { $changed = $this->meta( $term_id, self::TERM_DESC, (string) $record['desc'], $mode, 'term_desc', true ) || $changed; } if ( '' !== (string) ( $record['canonical'] ?? '' ) ) { $changed = $this->meta( $term_id, self::TERM_CANONICAL, esc_url_raw( (string) $record['canonical'] ), $mode, 'term_canonical', true ) || $changed; } $robots = array_values( array_filter( array_map( 'strval', (array) ( $record['robots'] ?? array() ) ) ) ); if ( ! empty( $robots ) ) { $current = get_term_meta( $term_id, self::TERM_ROBOTS, true ); $current = is_array( $current ) ? array_values( array_map( 'strval', $current ) ) : array(); $merged = array_values( array_unique( array_merge( $current, $robots ) ) ); if ( $merged !== $current ) { $changed = $this->meta_raw( $term_id, self::TERM_ROBOTS, $merged, 'term_robots', true ) || $changed; } } $social = array_filter( array_map( 'strval', (array) ( $record['social'] ?? array() ) ), static fn( $v ) => '' !== trim( $v ) ); if ( ! empty( $social ) ) { $current = get_term_meta( $term_id, self::TERM_SOCIAL, true ); $current = is_array( $current ) ? $current : array(); $merged = $current; $touched = false; foreach ( $social as $key => $value ) { if ( 'overwrite' === $mode || '' === (string) ( $current[ $key ] ?? '' ) ) { $merged[ $key ] = $value; $touched = true; } } if ( $touched ) { $changed = $this->meta_raw( $term_id, self::TERM_SOCIAL, $merged, 'term_social', true ) || $changed; } } return $changed; } /** * Apply the source's site-wide settings onto ours. * * This is the part that decides whether an import looks like it worked: a * Rank Math site with 8,000 posts usually has eight per-post values and one * template that produces the other 7,992 titles. * * @param array $settings Normalised settings from an adapter. * @return int Number of settings applied. */ public function settings( array $settings, string $mode ): int { if ( empty( $settings ) ) { return 0; } $options = \AiRankSuite\Kernel::service( \AiRankSuite\Support\Options::class ); $defaults = \AiRankSuite\Modules\Seo\SeoModule::defaults(); $current = $options->get( 'seo', $defaults ); $next = $current; $applied = 0; $overwrite = 'overwrite' === $mode; /** Is our value still the default (i.e. nobody chose it)? */ $untouched = static function ( string $key ) use ( $current, $defaults ): bool { $mine = $current[ $key ] ?? null; $default = $defaults[ $key ] ?? null; return $mine === $default || '' === $mine || null === $mine || array() === $mine; }; $scalars = array( 'separator' => 'separator', 'home_title' => 'home_title', 'home_desc' => 'home_desc', 'author_title' => 'author_title', 'author_desc' => 'author_desc', 'search_title' => 'search_title', 'notfound_title' => 'notfound_title', 'og_default_image' => 'og_default_image', 'twitter_site' => 'twitter_site', 'twitter_card' => 'twitter_card', 'facebook_app_id' => 'facebook_app_id', ); foreach ( $scalars as $from => $to ) { $value = (string) ( $settings[ $from ] ?? '' ); if ( '' === $value || ! array_key_exists( $to, $defaults ) ) { continue; } if ( $overwrite || $untouched( $to ) ) { $next[ $to ] = $value; ++$applied; } } $booleans = array( 'author_enabled', 'author_noindex', 'date_enabled', 'date_noindex', 'attachment_redirect' ); foreach ( $booleans as $key ) { if ( ! array_key_exists( $key, $settings ) || ! array_key_exists( $key, $defaults ) ) { continue; } if ( $overwrite || $untouched( $key ) ) { $next[ $key ] = (bool) $settings[ $key ]; ++$applied; } } // Per-post-type and per-taxonomy templates: merged key by key, so a // template we already have is only replaced in overwrite mode. foreach ( array( 'templates' => 'templates', 'taxonomy_templates' => 'taxonomy_templates' ) as $from => $to ) { $incoming = (array) ( $settings[ $from ] ?? array() ); if ( empty( $incoming ) ) { continue; } $merged = (array) ( $next[ $to ] ?? array() ); foreach ( $incoming as $key => $pair ) { foreach ( array( 'title', 'desc' ) as $part ) { $value = (string) ( $pair[ $part ] ?? '' ); if ( '' === $value ) { continue; } if ( $overwrite || '' === trim( (string) ( $merged[ $key ][ $part ] ?? '' ) ) ) { $merged[ $key ][ $part ] = $value; ++$applied; } } } $next[ $to ] = $merged; } foreach ( array( 'noindex_types', 'noindex_taxonomies' ) as $key ) { $incoming = array_values( array_filter( array_map( 'strval', (array) ( $settings[ $key ] ?? array() ) ) ) ); if ( empty( $incoming ) ) { continue; } $merged = array_values( array_unique( array_merge( (array) ( $next[ $key ] ?? array() ), $incoming ) ) ); if ( $merged !== (array) ( $next[ $key ] ?? array() ) ) { $next[ $key ] = $merged; ++$applied; } } // The global title template: only when the source has one for posts // and ours is still the shipped default. $post_title = (string) ( $settings['templates']['post']['title'] ?? '' ); if ( '' !== $post_title && ( $overwrite || $untouched( 'title_template' ) ) ) { $next['title_template'] = $post_title; ++$applied; } if ( $applied > 0 && ! $this->dry_run ) { $this->snapshot( 'option', 0, 'airanksuite_seo_settings' ); $options->set( 'seo', $next, $defaults ); } if ( $applied > 0 ) { $this->count( 'settings', $applied ); } // The organisation identity belongs to the schema module. $org = (array) ( $settings['organisation'] ?? array() ); if ( '' !== (string) ( $org['name'] ?? '' ) ) { $applied += $this->schema_identity( $org, (array) ( $settings['social_profiles'] ?? array() ), $mode ); } return $applied; } /** * @param array $org * @param array $profiles */ private function schema_identity( array $org, array $profiles, string $mode ): int { $options = \AiRankSuite\Kernel::service( \AiRankSuite\Support\Options::class ); $defaults = \AiRankSuite\Modules\Schema\SchemaModule::defaults(); $current = $options->get( 'schema', $defaults ); $next = $current; $applied = 0; $identity = (array) ( $current['identity'] ?? array() ); $name = (string) ( $org['name'] ?? '' ); if ( '' !== $name && ( 'overwrite' === $mode || '' === trim( (string) ( $current['org_name'] ?? '' ) ) ) ) { $next['org_name'] = $name; ++$applied; } if ( '' !== (string) ( $org['type'] ?? '' ) && ( 'overwrite' === $mode || 'Organization' === (string) ( $current['org_type'] ?? '' ) ) ) { $next['org_type'] = 'person' === $org['type'] ? 'Person' : 'Organization'; ++$applied; } $logo = (string) ( $org['logo'] ?? '' ); if ( '' !== $logo && ( 'overwrite' === $mode || '' === trim( (string) ( $identity['logo'] ?? '' ) ) ) ) { $identity['logo'] = $logo; ++$applied; } $profiles = array_values( array_unique( array_filter( array_map( 'strval', $profiles ) ) ) ); if ( ! empty( $profiles ) ) { $have = array_values( array_filter( (array) ( $identity['same_as'] ?? array() ) ) ); if ( 'overwrite' === $mode || empty( $have ) ) { $identity['same_as'] = 'overwrite' === $mode ? array_values( array_unique( array_merge( $have, $profiles ) ) ) : $profiles; ++$applied; } } $next['identity'] = $identity; if ( $applied > 0 && ! $this->dry_run ) { $this->snapshot( 'option', 0, 'airanksuite_schema_settings' ); $options->set( 'schema', $next, $defaults ); } if ( $applied > 0 ) { $this->count( 'settings', $applied ); } return $applied; } /** * Write one redirect, journaling whatever was at that source before. */ public function redirect( string $source, string $target, int $type ): bool { global $wpdb; $path = \AiRankSuite\Modules\Redirects\RedirectsModule::normalize( $source ); if ( '' === $path || '/' === $path || '' === trim( $target ) ) { return false; } $this->count( 'redirects' ); if ( $this->dry_run ) { return true; } $table = Database::redirects(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE source = %s", mb_substr( $path, 0, 191 ) ), ARRAY_A ); $this->journal( 'redirect', 0, mb_substr( $path, 0, 191 ), null === $existing ? '' : (string) wp_json_encode( $existing ), null !== $existing ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->replace( $table, array( 'source' => mb_substr( $path, 0, 191 ), 'target' => $target, 'type' => in_array( $type, array( 301, 302, 307, 308, 410 ), true ) ? $type : 301, 'status' => 'active', 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%d', '%s', '%s' ) ); return true; } // ------------------------------------------------------------- internals -- private function meta( int $object_id, string $key, string $value, string $mode, string $field, bool $term = false ): bool { $current = $term ? get_term_meta( $object_id, $key, true ) : get_post_meta( $object_id, $key, true ); if ( 'overwrite' !== $mode && '' !== (string) $current ) { // The source has something and we already have something else. // Counted, because "nothing was imported" and "everything was // already filled in" look identical from the outside, and the // second one has an answer: run it again with overwriting on. $this->count( 'kept' ); return false; } if ( (string) $current === $value ) { return false; } return $this->meta_raw( $object_id, $key, $value, $field, $term ); } /** * @param mixed $value */ private function meta_raw( int $object_id, string $key, $value, string $field, bool $term = false ): bool { if ( ! $this->dry_run ) { $this->snapshot( $term ? 'term_meta' : 'post_meta', $object_id, $key ); if ( $term ) { update_term_meta( $object_id, $key, $value ); } else { update_post_meta( $object_id, $key, $value ); } } $this->count( $field ); return true; } private function count( string $field, int $by = 1 ): void { $this->fields[ $field ] = ( $this->fields[ $field ] ?? 0 ) + $by; } /** * Journal the current value of something about to be written. */ private function snapshot( string $kind, int $object_id, string $name ): void { $existed = false; $value = ''; if ( 'post_meta' === $kind ) { $existed = metadata_exists( 'post', $object_id, $name ); $value = $existed ? get_post_meta( $object_id, $name, true ) : ''; } elseif ( 'term_meta' === $kind ) { $existed = metadata_exists( 'term', $object_id, $name ); $value = $existed ? get_term_meta( $object_id, $name, true ) : ''; } elseif ( 'option' === $kind ) { $raw = get_option( $name, null ); $existed = null !== $raw; $value = $existed ? $raw : ''; } $this->journal( $kind, $object_id, $name, maybe_serialize( $value ), $existed ); } private function journal( string $kind, int $object_id, string $name, string $value, bool $existed ): void { global $wpdb; if ( $this->dry_run || '' === $this->import_id ) { return; } // One journal row per (import, thing): the first write is the one that // knows the pre-import value, and a second write in the same run must // not overwrite it with a value the import itself produced. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO ' . Database::import_undo() . ' (import_id, kind, object_id, name, prev_value, existed, created_at) VALUES (%s, %s, %d, %s, %s, %d, %s)', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from this plugin's own Database class/adapter, never from input; values go through $wpdb->prepare(). $this->import_id, $kind, $object_id, mb_substr( $name, 0, 191 ), $value, $existed ? 1 : 0, current_time( 'mysql', true ) ) ); } }