*/ private const SECRETS = array( 'airanksuite_ai_key', 'airanksuite_gsc_credentials', 'airanksuite_gsc_tokens', 'airanksuite_license', 'airanksuite_google_service_account', 'airanksuite_indexnow_key', ); public function id(): string { return 'import_export'; } public function register( Container $container ): void { $container->share( Services\PluginImporter::class, static fn() => new Services\PluginImporter() ); $container->share( Services\ImportRun::class, static fn() => new Services\ImportRun() ); } public function boot( Container $container ): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); // A closed browser tab must not leave an import half applied: the // resumer ticks it from cron like every other long job. $run = $container->get( Services\ImportRun::class ); \AiRankSuite\Support\Job::register( 'import', array( $run, 'state' ), array( $run, 'tick' ) ); } public function routes(): void { // Anything that writes an import into the site, or reads an uploaded // file, replaces configuration and content wholesale. The plugin's // delegable capability is not enough for that; WordPress has to agree // that this is an administrator. register_rest_route( self::NAMESPACE, '/import/sources', array( 'methods' => 'GET', 'callback' => array( $this, 'sources' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/import/run', array( 'methods' => 'POST', 'callback' => array( $this, 'run' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'source' => array( 'type' => 'string', 'required' => true, 'enum' => array_keys( Services\PluginImporter::sources() ), ), 'after' => array( 'type' => 'integer', 'default' => null, 'minimum' => 0 ), 'offset' => array( 'type' => 'integer', 'default' => 0, ), 'overwrite' => array( 'type' => 'boolean', 'default' => false, ), 'redirects' => array( 'type' => 'boolean', 'default' => true, ), ), ) ); // The universal importer: adapters, dry run, undo, CSV/JSON. register_rest_route( self::NAMESPACE, '/import/detect', array( 'methods' => 'GET', 'callback' => static fn() => new WP_REST_Response( array( 'sources' => Services\ImportRegistry::detect(), 'conflicts' => \AiRankSuite\Kernel::service( Services\ImportRun::class )->conflicts(), 'runs' => Services\ImportLog::runs(), 'fields' => Services\FileImport::fields(), 'job' => \AiRankSuite\Kernel::service( Services\ImportRun::class )->state(), ) ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/import/start', array( 'methods' => 'POST', 'callback' => array( $this, 'import_start' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'source' => array( 'type' => 'string', 'default' => '' ), 'mode' => array( 'type' => 'string', 'default' => 'fill', 'enum' => array( 'fill', 'overwrite' ) ), 'dry_run' => array( 'type' => 'boolean', 'default' => false ), 'parts' => array( 'type' => 'array', 'default' => array( 'settings', 'posts', 'terms', 'redirects' ), 'items' => array( 'type' => 'string' ) ), 'undo' => array( 'type' => 'string', 'default' => '' ), 'file' => array( 'type' => 'object', 'default' => array() ), ), ) ); register_rest_route( self::NAMESPACE, '/import/tick', array( 'methods' => 'POST', 'callback' => static fn() => new WP_REST_Response( \AiRankSuite\Support\Job::guard( 'import', array( \AiRankSuite\Kernel::service( Services\ImportRun::class ), 'tick' ) ) ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/import/cancel', array( 'methods' => 'POST', 'callback' => static fn() => new WP_REST_Response( \AiRankSuite\Kernel::service( Services\ImportRun::class )->cancel() ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/import/preview-file', array( 'methods' => 'POST', 'callback' => static function ( WP_REST_Request $request ) { $parsed = Services\FileImport::parse( (string) $request['content'], (string) $request['format'] ); return new WP_REST_Response( array_merge( $parsed, array( 'fields' => Services\FileImport::fields() ) ) ); }, 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'content' => array( 'type' => 'string', 'required' => true ), 'format' => array( 'type' => 'string', 'default' => 'csv', 'enum' => array( 'csv', 'json' ) ), ), ) ); register_rest_route( self::NAMESPACE, '/settings/export', array( 'methods' => 'GET', 'callback' => array( $this, 'export' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/settings/import', array( 'methods' => 'POST', 'callback' => array( $this, 'import_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'payload' => array( 'type' => 'object', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/settings/backup', array( array( 'methods' => 'GET', 'callback' => array( $this, 'backup_info' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'restore' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), ) ); } /** * Start an import, an undo or a preview. */ public function import_start( WP_REST_Request $request ): WP_REST_Response|WP_Error { $run = \AiRankSuite\Kernel::service( Services\ImportRun::class ); $result = $run->start( array( 'source' => (string) $request['source'], 'mode' => (string) $request['mode'], 'dry_run' => (bool) $request['dry_run'], 'parts' => (array) $request['parts'], 'undo' => (string) $request['undo'], 'file' => (array) $request['file'], ) ); return $result instanceof WP_Error ? $result : new WP_REST_Response( $result ); } /** * The sources the setup wizard offers. * * This used to answer from PluginImporter, which knows nine plugins, * while the import screen answered from the adapter registry, which knows * fourteen. A site running Squirrly, Genesis, Jetpack, WooCommerce or * Redirection was told during setup that there was nothing to bring * across, and then found its data on the import screen ten minutes later. * One registry answers both, and it is the one that also performs the * import the wizard starts. */ public function sources(): WP_REST_Response { $importer = \AiRankSuite\Kernel::service( Services\PluginImporter::class ); return new WP_REST_Response( array( 'sources' => Services\ImportRegistry::detect(), 'total' => $importer->total_posts(), ) ); } /** * Import one batch; the client loops until done = true. */ public function run( WP_REST_Request $request ): WP_REST_Response { $importer = \AiRankSuite\Kernel::service( Services\PluginImporter::class ); $source = (string) $request['source']; $offset = max( 0, (int) $request['offset'] ); // Keyset paging: the client hands back the cursor it received. A client // from before this (offset only) is translated once per batch to the // ID at that position, so it still imports everything, batch by batch. if ( null !== $request['after'] ) { $after = max( 0, (int) $request['after'] ); } elseif ( 0 === $offset ) { $after = 0; } else { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $after = (int) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type NOT IN ('attachment','revision','nav_menu_item') AND post_status NOT IN ('auto-draft','trash') ORDER BY ID ASC LIMIT 1 OFFSET %d", max( 0, $offset - 1 ) ) ); } $batch = $importer->import_posts( $source, (bool) $request['overwrite'], $offset, 50, $after ); $redirects = array( 'imported' => 0, 'skipped' => 0, ); // Redirects and the site-wide templates are imported once, on the // first batch — the templates are what a Rank Math or Yoast site // actually runs on; per-post values exist only where somebody typed one. $site = array( 'applied' => 0, 'templates' => array() ); if ( 0 === $offset ) { if ( $request['redirects'] ) { $redirects = $importer->import_redirects( $source ); } $site = $importer->import_site_settings( $source, (bool) $request['overwrite'] ); } $next = $offset + $batch['processed']; $done = $batch['processed'] < 50; if ( $done ) { // Scores depend on the imported descriptions — mark analyses stale. delete_transient( \AiRankSuite\Modules\LlmsTxt\LlmsTxtModule::CACHE_KEY ); delete_transient( \AiRankSuite\Modules\LlmsTxt\LlmsTxtModule::CACHE_KEY_FULL ); } return new WP_REST_Response( array( 'done' => $done, 'offset' => $next, 'after' => (int) ( $batch['last_id'] ?? 0 ), 'total' => $importer->total_posts(), 'imported' => $batch['imported'], 'skipped' => $batch['skipped'], 'fields' => $batch['fields'] ?? array(), 'site' => $site, 'redirects' => $redirects, ) ); } /** * Export all settings as JSON — secrets are excluded by design. */ public function export(): WP_REST_Response { $options = array(); foreach ( Migrator::settings_keys() as $key ) { if ( in_array( $key, self::SECRETS, true ) ) { continue; } $value = get_option( $key, null ); if ( null !== $value ) { $options[ $key ] = $value; } } return new WP_REST_Response( array( 'format' => 'ai-rank-suite/1', 'plugin' => AIRANKSUITE_VERSION, 'site' => home_url( '/' ), 'created' => gmdate( 'c' ), 'options' => $options, ) ); } public function import_settings( WP_REST_Request $request ): WP_REST_Response|WP_Error { $payload = (array) $request->get_param( 'payload' ); if ( ( $payload['format'] ?? '' ) !== 'ai-rank-suite/1' || ! is_array( $payload['options'] ?? null ) ) { return new WP_Error( 'airanksuite_import_invalid', __( 'This file is not an AI Rank Suite settings export.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $allowed = array_diff( Migrator::settings_keys(), self::SECRETS ); $applied = 0; foreach ( $payload['options'] as $key => $value ) { // An export made before 0.2.36 names its options under the old prefix. $key = Migrator::current_key( (string) $key ); if ( ! in_array( $key, $allowed, true ) || ! is_array( $value ) ) { continue; } update_option( $key, $value, false ); ++$applied; } return new WP_REST_Response( array( 'applied' => $applied, 'skipped' => count( $payload['options'] ) - $applied, ) ); } public function backup_info(): WP_REST_Response { $backup = get_option( 'airanksuite_settings_backup' ); return new WP_REST_Response( array( 'exists' => is_array( $backup ) && ! empty( $backup['options'] ), 'from' => is_array( $backup ) ? (string) ( $backup['plugin'] ?? '' ) : '', 'time' => is_array( $backup ) ? (int) ( $backup['time'] ?? 0 ) : 0, ) ); } public function restore(): WP_REST_Response|WP_Error { if ( ! Migrator::restore_backup() ) { return new WP_Error( 'airanksuite_no_backup', __( 'No settings backup available.', 'ai-rank-suite' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'restored' => true ) ); } }